Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bb7a1ee86 | ||
|
|
363497fdd5 | ||
|
|
ff52f50c0c | ||
|
|
48158d1fe9 | ||
|
|
e288946b81 | ||
|
|
66b6d46e0e | ||
|
|
d970107716 | ||
|
|
6fbc450eea | ||
|
|
f7573e3565 | ||
|
|
4f6d40625a | ||
|
|
c6ed6c9ea1 | ||
|
|
6d03317970 | ||
|
|
976008065f | ||
|
|
8b5ada1272 | ||
|
|
48a9914124 | ||
|
|
002c859572 | ||
|
|
b5cf8253e6 | ||
|
|
beccfec307 | ||
|
|
a6137351d9 | ||
|
|
6a8e3fadbf | ||
|
|
643cb65c79 | ||
|
|
1b90c64b73 | ||
|
|
41f08ac9a2 | ||
|
|
98a5ec45bb | ||
|
|
e4421d966a | ||
|
|
39afb23d97 | ||
|
|
efffd72c52 | ||
|
|
bda9d77d2d | ||
|
|
d5e8193ce5 | ||
|
|
4fae62ba14 | ||
|
|
6765fbcb20 | ||
|
|
75b2478a55 | ||
|
|
1d05f57fbf | ||
|
|
484a50a189 | ||
|
|
7f769ca2f0 | ||
|
|
5bf1268c82 | ||
|
|
038dcd48cd | ||
|
|
71200c5179 | ||
|
|
00b45bafc5 | ||
|
|
9e4dcdd147 | ||
|
|
9e9e5950d0 | ||
|
|
268afccf05 | ||
|
|
e1c0ac983c | ||
|
|
33a92a2e1d | ||
|
|
077eab2b79 | ||
|
|
f076527722 | ||
|
|
e67802f15d | ||
|
|
73eef14ecd | ||
|
|
c3ceea9bcd | ||
|
|
35ef3af54b | ||
|
|
f10282294d | ||
|
|
6217b5f2fa | ||
|
|
b9e31178f3 | ||
|
|
dcf9689f64 | ||
|
|
97d70d0c80 | ||
|
|
9eeca79b5d | ||
|
|
224340edc9 | ||
|
|
b393e90854 | ||
|
|
45f720883a | ||
|
|
c814cb95d0 | ||
|
|
ff2c4ebfe0 | ||
|
|
03322d3166 | ||
|
|
2d08486278 | ||
|
|
f780ab2c6a | ||
|
|
396f2a830a | ||
|
|
59761ae015 | ||
|
|
50d5e14539 | ||
|
|
48eedbc2eb | ||
|
|
fd15f1de4c | ||
|
|
064cf95f6d | ||
|
|
8d75ede0ab | ||
|
|
2b89eeba1d | ||
|
|
0dbe18d824 | ||
|
|
75e92db2fa | ||
|
|
a1bc6522c3 | ||
|
|
10f96656e3 | ||
|
|
724b020e93 | ||
|
|
a1fa4ce140 | ||
|
|
a7663b3a5e | ||
|
|
a6fff434a4 | ||
|
|
cd40a194ed | ||
|
|
0037b1ef4b | ||
|
|
50405ccf88 | ||
|
|
32196b4260 | ||
|
|
7d328e20a5 | ||
|
|
fbe7ba9575 | ||
|
|
a3c6e5c984 | ||
|
|
5e12245932 | ||
|
|
df1810bae3 | ||
|
|
3f0e7b198a | ||
|
|
9110557e23 | ||
|
|
0016d9200c | ||
|
|
fe87a9d826 | ||
|
|
1b26c4f92c | ||
|
|
3db311da13 | ||
|
|
e1d138470f | ||
|
|
c711e136d4 | ||
|
|
19b9bc2dbe | ||
|
|
b768162139 | ||
|
|
56bbac4ddb | ||
|
|
93aebb5019 | ||
|
|
0181071b83 | ||
|
|
ca2a991f03 | ||
|
|
10bf18fd50 | ||
|
|
d65f7130d5 | ||
|
|
13f629af35 | ||
|
|
7efce19f4c | ||
|
|
623ce683d4 | ||
|
|
b1fa3afd40 | ||
|
|
aafd2dbda8 | ||
|
|
db75d1ca71 | ||
|
|
fd3a25434d | ||
|
|
14790ac768 | ||
|
|
a910f83ac1 | ||
|
|
13187e8f6a | ||
|
|
0f770dc88a | ||
|
|
e5f3c96275 | ||
|
|
0cb289d571 | ||
|
|
d7fc0189b5 | ||
|
|
8f379db15e | ||
|
|
2f5ba5c086 | ||
|
|
5592ee1570 | ||
|
|
b86dd3b07d | ||
|
|
1cd839e0f1 | ||
|
|
91aafc1371 | ||
|
|
eb78eba0dc | ||
|
|
fd0a284942 | ||
|
|
dfacf43e55 | ||
|
|
0530a6d045 | ||
|
|
39d28790a1 | ||
|
|
1580dae42e | ||
|
|
84ac687399 | ||
|
|
ccf5f3e123 | ||
|
|
0478626061 | ||
|
|
2ab2ffa65a | ||
|
|
d145537d9a | ||
|
|
636a4a47b7 | ||
|
|
26fff2a06c | ||
|
|
031ab6aa12 | ||
|
|
6c4d387ef3 | ||
|
|
dd9c009206 | ||
|
|
770f26c703 | ||
|
|
a90ffeda5f | ||
|
|
2544255825 | ||
|
|
1a0e54d8bd | ||
|
|
6fd35a0a78 | ||
|
|
46043f7e95 | ||
|
|
5c7ee97cd3 | ||
|
|
34ce457332 | ||
|
|
8a90e3e34f | ||
|
|
03729ce950 | ||
|
|
37153f69ae | ||
|
|
52d01bd815 | ||
|
|
e210407f31 | ||
|
|
fc2fa65f0d | ||
|
|
2a4a709ced | ||
|
|
cfb7465c22 | ||
|
|
07de29d58a | ||
|
|
f6e77cd98c | ||
|
|
6d73912f8c | ||
|
|
60571dbfac | ||
|
|
f324aac690 | ||
|
|
8b7c46a63a | ||
|
|
f3ae71ab05 | ||
|
|
eabf4cd1f9 | ||
|
|
99653151c4 | ||
|
|
d4486a7681 | ||
|
|
6ccf562756 | ||
|
|
a7ae41b94f | ||
|
|
1f6115b789 |
No files matched your search
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: steam-frame
|
||||
description: Operate the user's Valve Steam Frame headset from the Mac through this repo's helpers and field notes. Use for Steam Frame SSH, screen streaming, clipboard, file push, APK or Flatpak installs, launching apps on the headset, arranging floating windows or panels in VR space, or debugging SteamOS/gamescope/SteamVR on the Frame.
|
||||
---
|
||||
|
||||
# Steam Frame
|
||||
|
||||
SSH works through the `frame` alias
|
||||
(user `steamos`). The headset has to be awake for anything that touches its
|
||||
desktop or panels.
|
||||
|
||||
## Start here
|
||||
|
||||
1. Read `docs/how-the-frame-works.md`. It's the map: the layer cake (SteamVR →
|
||||
gamescope → nested Plasma), the verified facts, and debug recipes.
|
||||
2. Open the topic doc for the task:
|
||||
|
||||
| Task | Doc | Script |
|
||||
|---|---|---|
|
||||
| Floating windows in the room, one panel per app | `docs/panels.md` | `scripts/panel-on-frame.sh` |
|
||||
| First-time access, SSH keys | `docs/ssh.md` | `scripts/connect.sh` |
|
||||
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
|
||||
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
|
||||
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
|
||||
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
|
||||
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
|
||||
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
|
||||
| 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` |
|
||||
| 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` | — |
|
||||
|
||||
Each script's usage is in its header comment. Read the header rather than
|
||||
running `--help`: `paste-to-frame.sh`, `serve-bootstrap.sh` and
|
||||
`bootstrap-on-frame.sh` act on any argument.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- Label every claim **verified** (seen on the device, with the date and
|
||||
SteamOS build) or **inferred**. The docs use this convention. Keep it, and
|
||||
move items out of `docs/open-questions.md` once they're checked.
|
||||
- When you learn something new about the Frame, record it in
|
||||
`docs/how-the-frame-works.md` (or the topic doc) in the same change.
|
||||
- The Frame's rootfs is read-only and SteamOS updates replace it. Put changes in
|
||||
`~` (`--user` Flatpaks, `~/.config`) rather than `steamos-readonly disable`.
|
||||
- `sudo` on the Frame asks for the user's Developer Mode password. Hand those
|
||||
steps to the user (open Terminal) and keep automation to non-sudo commands.
|
||||
- The Mac uses BSD userland and zsh (no `timeout`, use `head -n`).
|
||||
- **First-party first.** For any new capability, investigate the first-party
|
||||
way before anything else: Valve (SteamOS, Steam, Steam Link), Apple (the Mac
|
||||
and iPhone), and KDE (the Frame's desktop is Plasma). It's usually the best
|
||||
answer. If it isn't, write down why not. If it is, find what Frame Control
|
||||
can do to make it easier to set up (install over SSH, pre-seed settings,
|
||||
pair automatically, tell the user the one setting to turn on).
|
||||
@@ -0,0 +1,8 @@
|
||||
# Scripts run on the Frame (Linux) and on macOS/Linux: keep LF even in Windows checkouts.
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
*.js text eol=lf
|
||||
*.html text eol=lf
|
||||
*.json text eol=lf
|
||||
*.md text eol=lf
|
||||
*.bat text eol=crlf
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
ko_fi: alexsouthwell
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
name: checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.9" # the oldest python3 the Mac app may pick up (Xcode CLT)
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
- name: Script syntax
|
||||
run: |
|
||||
sh -n ui/local-bin/ssh
|
||||
for f in scripts/*.sh frame/*/*.sh; do
|
||||
case "$(head -n 1 "$f")" in
|
||||
*zsh*) zsh -n "$f" ;;
|
||||
*) bash -n "$f" ;;
|
||||
esac
|
||||
done
|
||||
- name: Python compiles
|
||||
run: |
|
||||
python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.py ios/scripts/*.py
|
||||
# Valve's devkit-utils (vendored; run by the Frame's python3). Most have no .py suffix.
|
||||
python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: App syntax
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
|
||||
- name: Updater tests
|
||||
run: node --test app/test/updater.test.js
|
||||
- name: 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.
|
||||
server-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
python: "3.12"
|
||||
- os: macos-latest
|
||||
python: "3.12"
|
||||
- os: ubuntu-latest
|
||||
python: "3.13"
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
|
||||
# The iPhone app: builds for the Simulator and runs its unit tests.
|
||||
ios:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Generate the project
|
||||
run: brew install xcodegen && cd ios && xcodegen generate
|
||||
- name: Build and test
|
||||
run: |
|
||||
cd ios
|
||||
udid=$(xcrun simctl list devices available -j | python3 -c 'import json,sys; d=json.load(sys.stdin)["devices"]; print(next(x["udid"] for r in d for x in d[r] if x["name"].startswith("iPhone")))')
|
||||
xcodebuild -project FrameControl.xcodeproj -scheme FrameControl -destination "platform=iOS Simulator,id=$udid" CODE_SIGNING_ALLOWED=NO test
|
||||
|
||||
# End-to-end tests against the fake Frame (tests/fakeframe): Arch Linux ARM
|
||||
# in Docker, on a native arm64 runner like the headset. See docs/testing.md.
|
||||
e2e:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
- name: End-to-end tests
|
||||
run: scripts/e2e.sh
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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,61 @@
|
||||
name: release
|
||||
|
||||
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
|
||||
# the installers to that tag's GitHub release (created as a draft if missing).
|
||||
# Pull requests that touch the app build the same installers as artifacts.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
script: dist
|
||||
files: app/dist/*.dmg app/dist/*.zip
|
||||
- os: windows-latest
|
||||
script: dist:win
|
||||
files: app/dist/*.exe app/dist/*.zip
|
||||
- os: ubuntu-latest
|
||||
script: dist:linux
|
||||
files: app/dist/*.AppImage app/dist/*.deb
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Build
|
||||
working-directory: app
|
||||
shell: bash
|
||||
run: npm ci && npm run ${{ matrix.script }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
- name: Upload to the release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
|
||||
gh release upload "$tag" ${{ matrix.files }} --clobber
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frame-control-${{ matrix.os }}
|
||||
path: |
|
||||
app/dist/*.dmg
|
||||
app/dist/*.exe
|
||||
app/dist/*.zip
|
||||
app/dist/*.AppImage
|
||||
app/dist/*.deb
|
||||
if-no-files-found: ignore
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
apk-catalog/data/cache/
|
||||
apk-catalog/data/index-v2*.json*
|
||||
compat-db/.env.lakebed.server
|
||||
compat-db/.lakebed/
|
||||
tests/smoke/results/
|
||||
mac/bin/
|
||||
|
||||
# Downloaded at build time (frame/kdeconnect/fetch.py, app/build/fetch-deps.js)
|
||||
frame/kdeconnect/packages/
|
||||
@@ -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.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 saphid
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,260 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="docs/img/icon.png" width="112" alt="Frame Control icon">
|
||||
|
||||
# Frame Control
|
||||
|
||||
**Manage your Valve Steam Frame from your computer.**<br>
|
||||
See what the headset sees, install games and Android apps, move files and text across, and check battery and status, all over SSH.
|
||||
|
||||
[](https://github.com/saphid/steam-frame/releases/latest)
|
||||
[](#install)
|
||||
[](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
|
||||
[](LICENSE)
|
||||
|
||||
[**Website**](https://frame-control.pages.dev) · [**Download**](#install) · [Trailer](#trailer) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
|
||||
|
||||
<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>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**👓 Headset view**<br>
|
||||
Live video of what the lenses show (about 30 fps), or a still of both eyes. Zoom, pan, full screen, save as PNG.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**🔋 Battery and status**<br>
|
||||
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
**🎮 Steam games**<br>
|
||||
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
**🤖 Android apps**<br>
|
||||
About 4,500 F-Droid apps rated for the Frame. One click installs each as its own app in your Steam library.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
**📁 Files, games and clipboard**<br>
|
||||
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you. Send text or your clipboard straight to the headset's desktop.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
**📸 Screenshots**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder.
|
||||
|
||||
**⌨️ Keyboard and trackpad**<br>
|
||||
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Nothing else to install, anywhere.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
**🧩 Flatpaks and display**<br>
|
||||
Install desktop apps like Moonlight or VLC, and set each Android app's resolution and text size.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
**⚡ One-click tools**<br>
|
||||
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Nothing is installed on the Frame for any of this: the app uses what SteamOS
|
||||
already ships (sideloading a game copies Valve's own devkit scripts to
|
||||
`~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md).
|
||||
|
||||
## Install
|
||||
|
||||
| | Download | Needs |
|
||||
|---|---|---|
|
||||
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
|
||||
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra |
|
||||
| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
|
||||
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
|
||||
|
||||
**iPhone and iPad:** the same features from your phone, with nothing to install on
|
||||
a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/iphone.md).
|
||||
|
||||
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
|
||||
From 0.4 it updates itself: when a new version is published, a banner offers
|
||||
**Update and restart**. It sends anonymous usage statistics, which you can turn
|
||||
off. Sharing compatibility results and error details is opt-in. See
|
||||
[docs/privacy.md](docs/privacy.md).
|
||||
Google doesn't publish `adb` for arm64 Linux, so that build uses your
|
||||
distribution's. If you already have `adb`, the app uses yours.
|
||||
|
||||
<details>
|
||||
<summary><b>macOS: the app isn't notarized</b></summary>
|
||||
|
||||
There's no paid Apple developer account behind it, so macOS says the app is
|
||||
damaged or can't be checked. Drag it to Applications, then clear the download
|
||||
quarantine once:
|
||||
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
|
||||
```
|
||||
|
||||
The first time, macOS also asks to allow local network access (for SSH) and
|
||||
control of Terminal (for the password prompts).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Windows: SmartScreen warning</b></summary>
|
||||
|
||||
The installer isn't code-signed, so Windows SmartScreen may say it protected
|
||||
your PC. Choose **More info → Run anyway**. The portable `.zip` avoids the
|
||||
installer: unzip it anywhere and run `Frame Control.exe`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Linux: running the AppImage</b></summary>
|
||||
|
||||
```sh
|
||||
chmod +x Frame-Control-linux-*.AppImage && ./Frame-Control-linux-*.AppImage
|
||||
```
|
||||
|
||||
If it complains about FUSE, install `libfuse2` (Ubuntu 24.04+: `libfuse2t64`),
|
||||
or run it with `--appimage-extract-and-run`.
|
||||
</details>
|
||||
|
||||
## Set up the headset
|
||||
|
||||
You type one password on the headset, once. Everything else happens on your
|
||||
computer.
|
||||
|
||||
1. **On the Frame:** Steam Settings → System → **Enable Developer Mode**, then
|
||||
in the Developer section, **Set User Password**. Pick something short:
|
||||
you'll type it once more on your computer and then never again.
|
||||
2. **On your computer:** open Frame Control. It offers to **Set Up
|
||||
Connection**, which finds the headset, creates an SSH key, and asks for that
|
||||
password once in a terminal window. If it can't find the Frame, type the
|
||||
IP address from the Frame's Quick Settings.
|
||||
|
||||
Before asking for the password it tries Valve's SteamOS devkit pairing: in
|
||||
the headset, open Steam Settings → Developer → **Pair new host** and approve
|
||||
the request, and no password is needed. (The service and the pairing-mode
|
||||
step are verified on a Frame; the approval itself isn't yet. See
|
||||
[SSH](docs/ssh.md#password-free-pairing-steamos-devkit-service).)
|
||||
3. That's it. The app now reaches the headset whenever it's awake and on the
|
||||
same network. For anywhere else, see [Tailscale](docs/tailscale.md).
|
||||
|
||||
**What it changes:** only what you click. Installs go to your user account on
|
||||
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads, sideloaded
|
||||
games in `~/devkit-game`), and nothing
|
||||
needs `sudo` except the power buttons. On your computer it adds a `Host frame`
|
||||
entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
|
||||
`~/.ssh/id_rsa_frame_devkit` (the pairing service only takes RSA keys).
|
||||
|
||||
## Feedback
|
||||
|
||||
This is a first public test, so reports are really useful, especially from
|
||||
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
||||
warning-sign button at the top, or **Help → Report a Problem…**). It adds
|
||||
diagnostics with personal details removed, shows you exactly what's included,
|
||||
and sends it privately to the maintainer; nothing is published. Without the app,
|
||||
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
||||
|
||||
- what you tried and what happened
|
||||
- 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
|
||||
Frame's software fits together, all checked against a real headset and labelled
|
||||
**verified** or **inferred**.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Frame Control in detail](docs/frame-control.md) | Every feature, how it works, per-platform notes, building |
|
||||
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
|
||||
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
|
||||
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
|
||||
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
|
||||
| [Open questions](docs/open-questions.md) | What's still unchecked |
|
||||
|
||||
<details>
|
||||
<summary><b>Security notes</b></summary>
|
||||
|
||||
- With Developer Mode on, `sshd`, ADB and xrdp are all reachable on your LAN.
|
||||
Each running Lepton (Android) instance opens its own ADB port in 5555–5599,
|
||||
listening on `0.0.0.0` rather than only loopback. This was seen on the
|
||||
device on 2026-09-25, so anyone on the network can reach it. Use trusted
|
||||
networks only, and turn Developer Mode off when you don't need it.
|
||||
- Frame Control reaches ADB and the Steam client's DevTools port (Frame
|
||||
loopback `127.0.0.1:8080`) only through SSH tunnels. The compatibility
|
||||
database key (maintainer-only) is never written to the repo.
|
||||
- `steamos` has `sudo`, protected by the same Developer Mode password. Once
|
||||
you've switched to key auth, a short password still protects `sudo` and
|
||||
RDP, so pick one that isn't trivially guessable.
|
||||
- Don't port-forward 22, 3389, or 5555–5599 from your router. For remote access,
|
||||
use Tailscale: `scripts/tailscale-on-frame.sh` (no sudo). In its userspace mode
|
||||
**every** Frame port is reachable from your tailnet, including Steam's DevTools
|
||||
on loopback 8080; see [docs/tailscale.md](docs/tailscale.md).
|
||||
</details>
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests # server tests; no headset needed
|
||||
scripts/e2e.sh # end-to-end against a fake Frame (Linux with Docker)
|
||||
cd app && npm install && npm start # run the app from the checkout
|
||||
```
|
||||
|
||||
The server is Python stdlib only; the app is Electron. GitHub Actions runs the
|
||||
tests on macOS, Windows and Linux, and a `v*` tag builds all three installers
|
||||
into a draft release, which reaches users once published. See
|
||||
[building](docs/frame-control.md#building) and [releasing](docs/releasing.md).
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE). The apps also ship other people's software under its own
|
||||
licence, notably KDE Connect (GPL) for the keyboard and trackpad; see
|
||||
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Steam, Steam Frame and SteamVR are trademarks of Valve
|
||||
Corporation. This project isn't affiliated with or endorsed by Valve.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Third-party software in Frame Control
|
||||
|
||||
Frame Control's own code is under the [MIT licence](LICENSE). The apps also
|
||||
ship other people's software, unchanged, each under its own licence:
|
||||
|
||||
| What | Where | Licence | Details |
|
||||
|---|---|---|---|
|
||||
| KDE Connect 24.02.2 and five libraries, as Valve builds them for the Frame | Desktop apps and the iPhone app; copied to the Frame for the keyboard and trackpad | GPL and LGPL (per package) | [frame/kdeconnect/NOTICE.md](frame/kdeconnect/NOTICE.md), licence texts in [frame/kdeconnect/LICENSES](frame/kdeconnect/LICENSES), complete source in the [kdeconnect-frame-24.02.2-1 release](https://github.com/saphid/frame-control/releases/tag/kdeconnect-frame-24.02.2-1) |
|
||||
| Python 3.12 ([python-build-standalone](https://github.com/astral-sh/python-build-standalone)) | Desktop apps | PSF License and others | Included with it, in the app's `python` folder |
|
||||
| adb (Android SDK Platform-Tools) | Desktop apps (not Linux arm64) | Apache-2.0 and others | `NOTICE.txt` in the app's `tools` folder |
|
||||
| Mozilla's CA certificate list, as published by curl | Desktop apps | MPL-2.0 | https://curl.se/docs/caextract.html |
|
||||
| Electron | Desktop apps | MIT (Chromium: BSD-3-Clause and others) | `LICENSE` and `LICENSES.chromium.html` in the app |
|
||||
|
||||
Frame Control starts KDE Connect and talks to it over its network protocol;
|
||||
it doesn't link to it or include its code.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Android app catalogue and compatibility reports
|
||||
|
||||
The data behind Frame Control's **Android apps** section: every app in the
|
||||
F-Droid main repo, rated for Lepton (the Frame's Android container), plus our
|
||||
own compatibility reports. No ProtonDB-style database for sideloaded Android
|
||||
apps on the Frame existed as of 2026-09-25 (Steam Frame Hub and Valve's
|
||||
"Great on Frame" cover Steam games only), so we keep our own.
|
||||
|
||||
```sh
|
||||
scripts/frame-ui.sh # Frame Control → Android apps: search, Install, Test, Report
|
||||
scripts/apk-catalog.sh # refresh the F-Droid data (only scans what changed)
|
||||
```
|
||||
|
||||
## Verdicts
|
||||
|
||||
| Verdict | Meaning |
|
||||
|---|---|
|
||||
| Works on Frame | The latest report says it runs (automated Test or a person's rating) |
|
||||
| Should work | No known blocker found in the APK |
|
||||
| Might work | Something uncertain: Compose version unknown, Godot, Qt, Play Services, no launcher icon (widgets, tiles, keyboards), or a report of issues |
|
||||
| Probably crashes | Compose UI < 1.11, SDL or Kivy |
|
||||
| Won't work | Needs Android 12+ or has no 64-bit ARM build, or a report says it's broken |
|
||||
|
||||
"Should work" means the app opens. Features that need something Lepton lacks
|
||||
(browser links, file picker, Play Services, camera app) can still fail. The
|
||||
rules and the evidence behind them are in [docs/apks.md](../docs/apks.md).
|
||||
|
||||
## Compatibility reports
|
||||
|
||||
Reports are saved on your Mac. The maintainer's copy of Frame Control also
|
||||
syncs them to a private Lakebed database (see
|
||||
[compat-db/README.md](../compat-db/README.md), including backups). **Test**
|
||||
records whether an app stays up in its own instance (`result`); **Report**
|
||||
(on any installed app, catalogue card, or **+ Report an APK** for anything else, e.g. an
|
||||
APK file or your own build) records `works`, `issues` or `broken`, how it was run
|
||||
(own instance, Lepton Development, other), where the APK came from, and notes. Each report carries
|
||||
the SteamOS `BUILD_ID` and the Lepton build id. Newest wins, and a person's
|
||||
rating beats an automated result (`reports.py`).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `zipcd.py` | Reads an APK's zip directory and single entries with HTTP range requests |
|
||||
| `scan.py` | Per app: native ABIs, frameworks (from `lib/*.so`), Compose/GMS/Firebase resource names from `resources.arsc`. Writes `data/scan.jsonl` |
|
||||
| `scan2.py` | Per app: Compose UI version, launcher/IME/feature strings from `AndroidManifest.xml`. Writes `data/scan2.jsonl` |
|
||||
| `pick.py` | Which version to rate and install: newest with an arm64 build (or no native code) and minSdk ≤ 30 |
|
||||
| `pins.json` | Versions pinned by hand (F-Droid 1.17.2) |
|
||||
| `reports.py` | How reports override predictions (the reports are in compat-db) |
|
||||
| `build.py` | Applies the rules and writes `site/apps.js` (predictions; Frame Control adds reports at runtime) |
|
||||
|
||||
Frame Control's `ui/frame_catalog.py` loads `site/apps.js`, applies the
|
||||
reports from `ui/frame_compat_db.py`, downloads APKs (SHA-256 checked against the
|
||||
F-Droid index), and installs them with `ui/frame_android.py`.
|
||||
|
||||
Both scans skip apps whose version hasn't changed. Compose is detected by its
|
||||
resource ids (`compose_view_saveable_id_tag`) because many apps strip the
|
||||
`META-INF` version files; those apps are rated "Might work".
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Merge the F-Droid index, both APK scans and the on-device results into
|
||||
site/apps.js, applying the Lepton compatibility rules in docs/apks.md.
|
||||
|
||||
Usage: python3 build.py (run from apk-catalog/, after scan.py and scan2.py)
|
||||
"""
|
||||
import json, os, re, time
|
||||
from pick import pick_version, LEPTON_SDK
|
||||
import reports
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA = os.path.join(HERE, 'data')
|
||||
REPO = 'https://f-droid.org/repo'
|
||||
# Compose UI below this crashes on any Compose screen: it casts the missing
|
||||
# clipboard service to non-null while building AndroidComposeView.
|
||||
COMPOSE_OK = (1, 11)
|
||||
RANK = {'works': 0, 'likely': 1, 'maybe': 2, 'unlikely': 3, 'no': 4}
|
||||
|
||||
|
||||
def jsonl(path):
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
return {r['pkg']: r for r in map(json.loads, open(path))}
|
||||
|
||||
|
||||
def loc(d):
|
||||
if not isinstance(d, dict):
|
||||
return d or ''
|
||||
return d.get('en-US') or d.get('en') or next(iter(d.values()), '')
|
||||
|
||||
|
||||
def ver_tuple(v):
|
||||
m = re.match(r'(\d+)\.(\d+)', v or '')
|
||||
return (int(m[1]), int(m[2])) if m else None
|
||||
|
||||
|
||||
def classify(m, s, s2):
|
||||
"""Return (verdict, reasons). Hard failures first, then crash signals."""
|
||||
no, bad, maybe, notes = [], [], [], []
|
||||
min_sdk = m.get('usesSdk', {}).get('minSdkVersion', 1)
|
||||
if min_sdk > LEPTON_SDK:
|
||||
no.append(f'Needs Android API {min_sdk}; Lepton is Android 11 (API 30), so it won\'t install')
|
||||
native = m.get('nativecode') or s.get('abis') or []
|
||||
if native and 'arm64-v8a' not in native:
|
||||
no.append(f'Native code only for {", ".join(native)}; Lepton is 64-bit ARM only, so it won\'t install')
|
||||
|
||||
cv = s2.get('compose_ver')
|
||||
if cv and ver_tuple(cv) and ver_tuple(cv) < COMPOSE_OK:
|
||||
bad.append(f'Jetpack Compose {cv}: Compose screens crash (no clipboard service); 1.11+ is fine')
|
||||
elif s.get('compose') and not cv:
|
||||
maybe.append('Uses Jetpack Compose, version unknown: crashes if older than 1.11')
|
||||
elif cv:
|
||||
notes.append(f'Jetpack Compose {cv} (fine)')
|
||||
fw = set(s.get('frameworks', []))
|
||||
if 'sdl' in fw or s2.get('sdl3'):
|
||||
bad.append('SDL app: registers a clipboard listener at start-up and crashes')
|
||||
if 'kivy' in fw:
|
||||
bad.append('Kivy (SDL) app: crashes at start-up on the missing clipboard')
|
||||
if 'godot' in fw:
|
||||
maybe.append('Godot: 4.3 crashed (clipboard), 4.6 worked')
|
||||
if 'reactnative' in fw or 'hermes' in fw:
|
||||
notes.append('React Native: 2 of 3 tested apps worked')
|
||||
if 'qt' in fw or 'qt6' in fw:
|
||||
maybe.append('Qt app: the one tested crashed on a missing libc++ symbol')
|
||||
if 'flutter' in fw:
|
||||
notes.append('Flutter (tested apps worked)')
|
||||
if 'gdx' in fw:
|
||||
notes.append('libGDX (tested games worked)')
|
||||
if s.get('gms') or s2.get('gms_meta'):
|
||||
maybe.append('Uses Google Play Services, which Lepton lacks')
|
||||
if s2 and not s2.get('launcher'):
|
||||
if s2.get('ime'):
|
||||
maybe.append('Keyboard (IME), not an app you open; untested in Lepton')
|
||||
else:
|
||||
maybe.append('No launcher icon (widget, tile, wallpaper or plug-in)')
|
||||
feats = s2.get('features', [])
|
||||
if 'android.hardware.touchscreen.multitouch' in feats:
|
||||
notes.append('Mentions multi-touch; the Frame pointer is single-touch (inferred)')
|
||||
if any(f in feats for f in ('android.hardware.telephony', 'android.hardware.nfc')):
|
||||
notes.append('Mentions telephony or NFC, which Lepton lacks')
|
||||
if 'android.hardware.type.watch' in feats:
|
||||
maybe.append('Wear OS watch app')
|
||||
|
||||
if no:
|
||||
return 'no', no + bad + maybe + notes
|
||||
if bad:
|
||||
return 'unlikely', bad + maybe + notes
|
||||
if maybe:
|
||||
return 'maybe', maybe + notes
|
||||
if not s or 'error' in s:
|
||||
return 'maybe', ['APK not scanned'] + notes
|
||||
return 'likely', notes or ['No known blockers']
|
||||
|
||||
|
||||
def finalize(app, reps):
|
||||
"""Set the shown verdict ('r', 'why', 't') from the prediction plus any reports."""
|
||||
rv = reports.verdict(reps)
|
||||
if rv:
|
||||
app['r'], lines = rv
|
||||
app['why'] = lines + ['Rule check: ' + r for r in app['pw'] if not r.startswith('No known')]
|
||||
else:
|
||||
app['r'], app['why'] = app['pr'], list(app['pw'])
|
||||
app['t'] = bool(rv)
|
||||
return app
|
||||
|
||||
|
||||
def load_catalog(path=None):
|
||||
"""Read site/apps.js back into a list (for serve.py and Frame Control)."""
|
||||
src = open(path or os.path.join(HERE, 'site', 'apps.js'), encoding='utf-8').read()
|
||||
return json.loads(src.split('window.APPS=', 1)[1].rstrip().rstrip(';'))
|
||||
|
||||
|
||||
def main():
|
||||
idx = json.load(open(os.path.join(DATA, 'index-v2.json')))
|
||||
s1 = jsonl(os.path.join(DATA, 'scan.jsonl'))
|
||||
s2 = jsonl(os.path.join(DATA, 'scan2.jsonl'))
|
||||
pins = json.load(open(os.path.join(HERE, 'pins.json'))) if os.path.exists(os.path.join(HERE, 'pins.json')) else {}
|
||||
cats = idx.get('repo', {}).get('categories', {})
|
||||
apps = []
|
||||
for pkg, p in idx['packages'].items():
|
||||
if not p.get('versions'):
|
||||
continue
|
||||
v = pick_version(p)
|
||||
md, m = p['metadata'], v['manifest']
|
||||
verdict, why = classify(m, s1.get(pkg, {}), s2.get(pkg, {}))
|
||||
apk, sha, shown_ver = REPO + v['file']['name'], v['file'].get('sha256'), m.get('versionName')
|
||||
pin = pins.get(pkg)
|
||||
if pin:
|
||||
apk, sha, shown_ver = pin['apk'], pin['sha256'], pin['version']
|
||||
why = [pin['why']] + why
|
||||
icon = loc(md.get('icon'))
|
||||
apps.append(finalize({
|
||||
'p': pkg,
|
||||
'n': loc(md.get('name')) or pkg,
|
||||
's': loc(md.get('summary')),
|
||||
'c': [loc(cats.get(c, {}).get('name')) or c for c in md.get('categories', [])],
|
||||
'i': REPO + icon['name'] if isinstance(icon, dict) and icon.get('name') else '',
|
||||
'v': shown_ver,
|
||||
'z': v['file'].get('size'),
|
||||
'u': md.get('lastUpdated'),
|
||||
'a': apk,
|
||||
'h': sha,
|
||||
'af': sorted(v.get('antiFeatures', {}).keys()),
|
||||
'pr': verdict,
|
||||
'pw': why,
|
||||
}, None))
|
||||
apps.sort(key=lambda a: (RANK[a['r']], a['n'].lower()))
|
||||
out = os.path.join(HERE, 'site', 'apps.js')
|
||||
meta = {'built': time.strftime('%Y-%m-%d'), 'count': len(apps),
|
||||
'source': 'F-Droid main repo, rated on the newest version each app has that Lepton can install'}
|
||||
with open(out, 'w') as f:
|
||||
f.write('window.CATALOG_META=' + json.dumps(meta) + ';\n')
|
||||
f.write('window.APPS=' + json.dumps(apps, separators=(',', ':'), ensure_ascii=False) + ';\n')
|
||||
counts = {k: sum(a['r'] == k for a in apps) for k in RANK}
|
||||
print(out, counts)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Choose which F-Droid version of an app to rate and install.
|
||||
|
||||
F-Droid often publishes one APK per ABI under different version codes, and
|
||||
the highest code is frequently the x86_64 build. Prefer the newest version
|
||||
Lepton can install (arm64-v8a or no native code, minSdk <= 30), else the newest.
|
||||
"""
|
||||
LEPTON_SDK = 30
|
||||
|
||||
|
||||
def installable(v):
|
||||
m = v['manifest']
|
||||
native = m.get('nativecode') or []
|
||||
return (not native or 'arm64-v8a' in native) and \
|
||||
m.get('usesSdk', {}).get('minSdkVersion', 1) <= LEPTON_SDK
|
||||
|
||||
|
||||
def pick_version(p):
|
||||
vs = sorted(p['versions'].values(), key=lambda v: v['manifest'].get('versionCode', 0), reverse=True)
|
||||
return next((v for v in vs if installable(v)), vs[0])
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"org.fdroid.fdroid": {
|
||||
"apk": "https://f-droid.org/archive/org.fdroid.fdroid_1017002.apk",
|
||||
"sha256": "756b7dfc7fb43ef28c27d276428a2f7826cd482fd794bbd9eeaef24016b2081c",
|
||||
"version": "1.17.2",
|
||||
"why": "Pinned to 1.17.2: 1.23.2 crashed (old Compose). 2.0 uses Compose 1.12 but is untested. Don't let it update itself."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""How compatibility reports turn into a verdict. The reports themselves live
|
||||
in Frame Control's private database (ui/frame_compat_db.py, a Lakebed capsule
|
||||
in compat-db/); this module is the pure logic shared by the build and the app.
|
||||
|
||||
A report: package, version, result (runs | crashes | install_failed |
|
||||
instance_failed, from an automated test), rating (works | issues | broken, from
|
||||
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
|
||||
Newest wins, and a person's rating beats an automated result.
|
||||
"""
|
||||
|
||||
|
||||
def verdict(reports):
|
||||
"""(verdict, summary lines) for one package's reports, or None."""
|
||||
if not reports:
|
||||
return None
|
||||
rs = sorted(reports, key=lambda r: r.get('date') or '')
|
||||
people = [r for r in rs if r.get('rating')]
|
||||
best = people[-1] if people else rs[-1]
|
||||
kind = best.get('rating') or best.get('result')
|
||||
v = {'works': 'works', 'runs': 'works', 'issues': 'maybe'}.get(kind, 'no')
|
||||
n_ok = sum((r.get('rating') or r.get('result')) in ('works', 'runs') for r in rs)
|
||||
lines = [f"Reported on a Frame {(best.get('date') or '')[:10]} (v{best.get('version')}): "
|
||||
f"{kind}{' – ' + best['notes'] if best.get('notes') else ''}"]
|
||||
if len(rs) > 1:
|
||||
lines.append(f'{len(rs)} reports, {n_ok} working')
|
||||
return v, lines
|
||||
|
||||
|
||||
def by_package(reports):
|
||||
out = {}
|
||||
for r in reports:
|
||||
out.setdefault(r['package'], []).append(r)
|
||||
return out
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Scan F-Droid APKs (latest version per app) for Steam Frame / Lepton signals.
|
||||
|
||||
Reads only the zip central directory and the resources.arsc key-string pool
|
||||
through HTTP range requests. Output: one JSON line per package (resumable).
|
||||
"""
|
||||
import json, os, struct, sys, zlib, threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import zipcd
|
||||
from pick import pick_version
|
||||
|
||||
REPO = 'https://f-droid.org/repo'
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
IDX = os.path.join(HERE, 'data', 'index-v2.json')
|
||||
OUT = os.path.join(HERE, 'data', 'scan.jsonl')
|
||||
|
||||
KEYS = {
|
||||
'compose': [b'compose_view_saveable_id_tag', b'wrapped_composition_tag',
|
||||
b'androidx_compose_ui_view_compositionlocal_map'],
|
||||
'gms': [b'common_google_play_services_unknown_issue',
|
||||
b'common_google_play_services_install_title'],
|
||||
'firebase': [b'google_app_id', b'gcm_defaultSenderId'],
|
||||
}
|
||||
LIBS = {
|
||||
'unity': 'libunity.so', 'flutter': 'libflutter.so', 'reactnative': 'libreactnative',
|
||||
'hermes': 'libhermes', 'godot': 'libgodot_android.so', 'gdx': 'libgdx.so',
|
||||
'sdl': 'libSDL2.so', 'unreal': 'libUE4.so', 'unreal5': 'libUnreal.so',
|
||||
'xamarin': 'libmonodroid.so', 'qt': 'libQt5Core', 'qt6': 'libQt6Core',
|
||||
'cocos': 'libcocos', 'love': 'liblove.so', 'renpy': 'librenpy',
|
||||
'kivy': 'libpython', 'gomobile': 'libgojni.so',
|
||||
}
|
||||
|
||||
|
||||
def arsc_keys(url, entries):
|
||||
comp, csize, lho = entries['resources.arsc']
|
||||
h = zipcd.rng(url, lho, lho + 29)
|
||||
nl, el = struct.unpack('<HH', h[26:30])
|
||||
base = lho + 30 + nl + el
|
||||
if comp == 8:
|
||||
blob = zlib.decompress(zipcd.rng(url, base, base + csize - 1), -15)
|
||||
read = lambda o, n: blob[o:o + n]
|
||||
elif comp == 0:
|
||||
read = lambda o, n: zipcd.rng(url, base + o, base + o + n - 1)
|
||||
else:
|
||||
raise ValueError(f'arsc compression {comp}')
|
||||
th = read(0, 12)
|
||||
if struct.unpack('<H', th[:2])[0] != 2:
|
||||
raise ValueError('not a ResTable')
|
||||
off = struct.unpack('<H', th[2:4])[0]
|
||||
pools = []
|
||||
total = struct.unpack('<I', th[4:8])[0]
|
||||
while off < total:
|
||||
ch = read(off, 8)
|
||||
ctype, chdr, csz = struct.unpack('<HHI', ch)
|
||||
if ctype == 0x0200: # package
|
||||
ph = read(off, 288)
|
||||
key_off = struct.unpack('<I', ph[8 + 4 + 256 + 8:8 + 4 + 256 + 12])[0]
|
||||
kh = read(off + key_off, 8)
|
||||
ksz = struct.unpack('<I', kh[4:8])[0]
|
||||
pools.append(read(off + key_off, ksz))
|
||||
if csz <= 0:
|
||||
break
|
||||
off += csz
|
||||
return b''.join(pools)
|
||||
|
||||
|
||||
def scan(pkg, meta, ver):
|
||||
f = ver['file']
|
||||
url = REPO + f['name']
|
||||
m = ver['manifest']
|
||||
r = {'pkg': pkg, 'vc': m.get('versionCode'), 'vn': m.get('versionName'),
|
||||
'apk': url, 'size': f.get('size')}
|
||||
try:
|
||||
names, entries, _ = zipcd.list_names(url, f['size'])
|
||||
libs = {n for n in names if n.startswith('lib/')}
|
||||
r['frameworks'] = sorted(k for k, s in LIBS.items() if any(s in n for n in libs))
|
||||
r['abis'] = sorted({n.split('/')[1] for n in libs if n.count('/') >= 2})
|
||||
r['metainf_compose'] = any(n.startswith('META-INF/androidx.compose.ui') for n in names)
|
||||
r['assets_bin_data'] = any(n.startswith('assets/bin/Data/') for n in names)
|
||||
if 'resources.arsc' in entries:
|
||||
kp = arsc_keys(url, entries)
|
||||
for k, pats in KEYS.items():
|
||||
r[k] = any(p in kp or p.decode().encode('utf-16-le') in kp for p in pats)
|
||||
else:
|
||||
r['no_arsc'] = True
|
||||
except Exception as e: # keep going; record the failure
|
||||
r['error'] = f'{type(e).__name__}: {e}'[:200]
|
||||
return r
|
||||
|
||||
|
||||
def main():
|
||||
idx = json.load(open(IDX))
|
||||
done = set()
|
||||
if os.path.exists(OUT):
|
||||
for line in open(OUT):
|
||||
try:
|
||||
r = json.loads(line)
|
||||
done.add((r['pkg'], r.get('vc')))
|
||||
except Exception:
|
||||
pass
|
||||
jobs = []
|
||||
for pkg, p in idx['packages'].items():
|
||||
if not p.get('versions'):
|
||||
continue
|
||||
ver = pick_version(p)
|
||||
if (pkg, ver['manifest'].get('versionCode')) not in done:
|
||||
jobs.append((pkg, p['metadata'], ver))
|
||||
print(f'{len(done)} done, {len(jobs)} to scan', flush=True)
|
||||
lock = threading.Lock()
|
||||
n = 0
|
||||
with open(OUT, 'a') as out, ThreadPoolExecutor(int(os.environ.get('WORKERS', '12'))) as ex:
|
||||
futs = [ex.submit(scan, *j) for j in jobs]
|
||||
for fu in as_completed(futs):
|
||||
r = fu.result()
|
||||
with lock:
|
||||
out.write(json.dumps(r) + '\n'); out.flush()
|
||||
n += 1
|
||||
if n % 100 == 0:
|
||||
print(f'{n}/{len(jobs)}', flush=True)
|
||||
print('finished', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Second pass: Compose UI version, launcher activity, GMS meta-data, uses-feature
|
||||
strings from AndroidManifest.xml (binary XML string pool). Resumable JSONL."""
|
||||
import json, os, struct, sys, threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import zipcd
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
IN = os.path.join(HERE, 'data', 'scan.jsonl')
|
||||
OUT = os.path.join(HERE, 'data', 'scan2.jsonl')
|
||||
FEATURES = ['android.hardware.touchscreen.multitouch', 'android.hardware.telephony',
|
||||
'android.hardware.nfc', 'android.hardware.bluetooth_le', 'android.hardware.usb.host',
|
||||
'android.hardware.camera', 'android.hardware.vr.high_performance',
|
||||
'android.software.leanback', 'android.hardware.type.watch']
|
||||
|
||||
|
||||
def axml_strings(b):
|
||||
# ResXMLTree_header (8) then string pool chunk
|
||||
off = struct.unpack('<H', b[2:4])[0]
|
||||
t, hs, sz, cnt, _styles, flags, sstart, _ = struct.unpack('<HHIIIIII', b[off:off + 28])
|
||||
utf8 = flags & 0x100
|
||||
offs = struct.unpack(f'<{cnt}I', b[off + hs:off + hs + 4 * cnt])
|
||||
base = off + sstart
|
||||
out = []
|
||||
for o in offs:
|
||||
p = base + o
|
||||
if utf8:
|
||||
n = b[p]; p += 2 if n & 0x80 else 1
|
||||
n = b[p]; hi = n & 0x80
|
||||
if hi:
|
||||
n = ((n & 0x7f) << 8) | b[p + 1]; p += 2
|
||||
else:
|
||||
p += 1
|
||||
out.append(b[p:p + n].decode('utf-8', 'replace'))
|
||||
else:
|
||||
n = struct.unpack('<H', b[p:p + 2])[0]; p += 2
|
||||
if n & 0x8000:
|
||||
n = ((n & 0x7fff) << 16) | struct.unpack('<H', b[p:p + 2])[0]; p += 2
|
||||
out.append(b[p:p + 2 * n].decode('utf-16-le', 'replace'))
|
||||
return out
|
||||
|
||||
|
||||
def scan(r):
|
||||
o = {'pkg': r['pkg'], 'vc': r.get('vc')}
|
||||
try:
|
||||
names, ent, _ = zipcd.list_names(r['apk'], r['size'])
|
||||
for n in ('META-INF/androidx.compose.ui_ui.version', 'META-INF/androidx.compose.ui_ui-android.version'):
|
||||
if n in ent:
|
||||
o['compose_ver'] = zipcd.read_entry(r['apk'], ent, n).decode().strip()
|
||||
break
|
||||
o['sdl3'] = any(n.endswith('/libSDL3.so') for n in names)
|
||||
s = set(axml_strings(zipcd.read_entry(r['apk'], ent, 'AndroidManifest.xml')))
|
||||
o['launcher'] = 'android.intent.category.LAUNCHER' in s
|
||||
o['leanback_launcher'] = 'android.intent.category.LEANBACK_LAUNCHER' in s
|
||||
o['gms_meta'] = 'com.google.android.gms.version' in s
|
||||
o['ime'] = 'android.view.InputMethod' in s
|
||||
o['features'] = [f for f in FEATURES if f in s]
|
||||
except Exception as e:
|
||||
o['error2'] = f'{type(e).__name__}: {e}'[:200]
|
||||
return o
|
||||
|
||||
|
||||
def main():
|
||||
rows = list({r['pkg']: r for r in map(json.loads, open(IN))}.values()) # latest per app
|
||||
done = set()
|
||||
if os.path.exists(OUT):
|
||||
done = {(o['pkg'], o.get('vc')) for o in map(json.loads, open(OUT))}
|
||||
rows = [r for r in rows if (r['pkg'], r.get('vc')) not in done and 'error' not in r]
|
||||
print(len(done), 'done', len(rows), 'todo', flush=True)
|
||||
lock = threading.Lock(); n = 0
|
||||
with open(OUT, 'a') as out, ThreadPoolExecutor(int(os.environ.get('WORKERS', '40'))) as ex:
|
||||
for fu in as_completed([ex.submit(scan, r) for r in rows]):
|
||||
with lock:
|
||||
out.write(json.dumps(fu.result()) + '\n'); out.flush(); n += 1
|
||||
if n % 200 == 0:
|
||||
print(n, flush=True)
|
||||
print('finished', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
window.CATALOG_META={"built": "2026-09-25", "count": 4455, "source": "F-Droid main repo, rated on the newest version each app has that Lepton can install"};
|
||||
window.APPS=[{"p":"com.terokarvinen.x54ask","n":"0x54ask","s":"Todo.txt manager. Offline, works with Syncthing. Fork of SimpleTask Cloudless","c":["Calendar & Agenda","Note","Task"],"i":"https://f-droid.org/repo/com.terokarvinen.x54ask/en-US/icon_FOzSYq6etfsaWRiMc7bx-8vLVKtsug1dmhT9NvxRj9w=.png","v":"1.1.2 (fork of Simpletask)","z":13037003,"u":1788427366142,"a":"https://f-droid.org/repo/com.terokarvinen.x54ask_1010200.apk","h":"f05781226bb84205caa5b5aa6a511afcb8df86de8bc4b53e33b7de34c2940e8a","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"com.github.ashutoshgngwr.tenbitclockwidget","n":"10-bit Clock Widget","s":"A beautiful BCD clock for your home screen","c":["Clock"],"i":"https://f-droid.org/repo/com.github.ashutoshgngwr.tenbitclockwidget/en-US/icon_TrUyJLRoXZGniCc2uQM3OnsVmlOokr_KZk0ZQaPrtjY=.png","v":"2.2-1","z":1281564,"u":1696789501000,"a":"https://f-droid.org/repo/com.github.ashutoshgngwr.tenbitclockwidget_221.apk","h":"35ff9940fd3d73acd1099f3640be6367c311ec9f6c34fa17e4748e874ecfe763","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"dev.lonami.klooni","n":"1010! Klooni","s":"A libGDX game based on 1010","c":["Puzzle Game"],"i":"https://f-droid.org/repo/icons/dev.lonami.klooni.860.png","v":"0.8.6","z":2735506,"u":1598918400000,"a":"https://f-droid.org/repo/dev.lonami.klooni_860.apk","h":"55641cdb5dba7f30c1d229cf8a34f390a8ff6b3f60cdff9b45d277919f33ce24","af":[],"pr":"likely","pw":["libGDX (tested games worked)"],"r":"likely","why":["libGDX (tested games worked)"],"t":false},{"p":"eu.quelltext.counting","n":"12345 - Learn Counting","s":"Learn counting in different languages with pictures","c":["Educational Game","Science & Education"],"i":"https://f-droid.org/repo/eu.quelltext.counting/en-US/icon_30ymRTCTMZiTzSNXPRLEOukBSubDfmp1CV_cpbGudKw=.png","v":"1.3","z":2413060,"u":1646352000000,"a":"https://f-droid.org/repo/eu.quelltext.counting_3.apk","h":"98fe65f21ff8e51918b94e80d25d99d52f5527d24a69dcd8dd9ca1a5da9b7a02","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"com.roufsyed.onekey","n":"1Key Password Manager","s":"Offline password manager. 2FA + notes. No account, no network, no telemetry.","c":["Password & 2FA","Security"],"i":"https://f-droid.org/repo/com.roufsyed.onekey/en-US/icon_7Oq_UnE5rGthf-UdC05ENbWiZZe00b9J8cKU2qrdVMQ=.png","v":"1.1.1","z":4738420,"u":1784362608829,"a":"https://f-droid.org/repo/com.roufsyed.onekey_3.apk","h":"690a58bb75780d9f835183ae6deb563e06db659218a4275ddd40ba15353d66ce","af":[],"pr":"likely","pw":["Jetpack Compose 1.11.2 (fine)"],"r":"likely","why":["Jetpack Compose 1.11.2 (fine)"],"t":false},{"p":"org.og8.a1tox","n":"1toX","s":"Remember numbers quick to train your brain","c":["Educational Game"],"i":"https://f-droid.org/repo/icons/org.og8.a1tox.1.png","v":"1.00","z":639637,"u":1567641600000,"a":"https://f-droid.org/repo/org.og8.a1tox_1.apk","h":"34895a84a638d53bd5ed57d134511eee9468f5461cb0e41874a1968ac256e4c8","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"com.dasp.worldcup2026","n":"2026 Football Fixtures Widget","s":"2026 football fixtures and widgets.","c":["Sports & Health"],"i":"","v":"0.1.0","z":33934,"u":1780506857489,"a":"https://f-droid.org/repo/com.dasp.worldcup2026_1.apk","h":"8c7b60c9cef5a6343f000f12ff0a0714bc6f3de72ced17c57f1ce4a147bc4a67","af":["NonFreeNet"],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"org.secuso.privacyfriendly2048","n":"2048 (Privacy Friendly)","s":"(SECUSO) Try to reach 2048 in this puzzle game","c":["Puzzle Game"],"i":"https://f-droid.org/repo/org.secuso.privacyfriendly2048/en-US/icon__EtkwPp725lQQYnzjkzDUiOqD2X5nnY1CiZSIYN9TVU=.png","v":"1.4.2","z":9294779,"u":1753701498000,"a":"https://f-droid.org/repo/org.secuso.privacyfriendly2048_100.apk","h":"02c799d3d582669daf2acf920093c68d2933f60aa937bb72fa2a805557233fe8","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"org.mattvchandler.a2050","n":"2050","s":"A game loosely based on 2048, but with circles instead of squares","c":["Puzzle Game"],"i":"https://f-droid.org/repo/org.mattvchandler.a2050/en-US/icon_3BMQD76YZDYHbtVP8WR8CTKi6E7pd6L82YveKdLHjR4=.png","v":"1.0.10","z":5079962,"u":1693608133000,"a":"https://f-droid.org/repo/org.mattvchandler.a2050_190010010.apk","h":"98a0e75e589c319093db56cf98bfa32d920b9436a9cbe7c30b32dcf7a4a6d284","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"nl.eventinfra.wifisetup","n":"37C3 Wifi Setup","s":"Official NOC application for connecting to the 36C3 Wi-Fi","c":["Connectivity"],"i":"","v":"0.37","z":2405866,"u":1729155289000,"a":"https://f-droid.org/repo/nl.eventinfra.wifisetup_20231222.apk","h":"aa0ca052e9e48ad7945f9aa535f5fd691018a5356a8ff95b0e5bf94662a54a10"Line truncated
|
||||
@@ -0,0 +1,31 @@
|
||||
import struct, urllib.request, zlib
|
||||
UA={'User-Agent':'steam-frame-compat-scan/1.0'}
|
||||
def rng(url, start, end=None):
|
||||
h=dict(UA); h['Range']=f'bytes={start}-' if end is None else f'bytes={start}-{end}'
|
||||
with urllib.request.urlopen(urllib.request.Request(url,headers=h),timeout=60) as r:
|
||||
return r.read()
|
||||
def tail(url, n):
|
||||
h=dict(UA); h['Range']=f'bytes=-{n}'
|
||||
with urllib.request.urlopen(urllib.request.Request(url,headers=h),timeout=60) as r:
|
||||
return r.read()
|
||||
def list_names(url, size):
|
||||
t=tail(url, min(size, 65557))
|
||||
i=t.rfind(b'PK\x05\x06')
|
||||
if i<0: raise ValueError('no EOCD')
|
||||
cd_size, cd_off = struct.unpack('<II', t[i+12:i+20])
|
||||
base=size-len(t)
|
||||
cd = t[cd_off-base:cd_off-base+cd_size] if cd_off>=base else rng(url, cd_off, cd_off+cd_size-1)
|
||||
names=[]; p=0; entries={}
|
||||
while p+46<=len(cd) and cd[p:p+4]==b'PK\x01\x02':
|
||||
comp,=struct.unpack('<H',cd[p+10:p+12])
|
||||
csize,usize=struct.unpack('<II',cd[p+20:p+28])
|
||||
nl,el,cl=struct.unpack('<HHH',cd[p+28:p+34]); lho,=struct.unpack('<I',cd[p+42:p+46])
|
||||
n=cd[p+46:p+46+nl].decode('utf-8','replace'); names.append(n); entries[n]=(comp,csize,lho)
|
||||
p+=46+nl+el+cl
|
||||
return names, entries, cd_size
|
||||
def read_entry(url, entries, name):
|
||||
comp,csize,lho=entries[name]
|
||||
h=rng(url, lho, lho+29)
|
||||
nl,el=struct.unpack('<HH',h[26:30])
|
||||
data=rng(url, lho+30+nl+el, lho+30+nl+el+csize-1)
|
||||
return zlib.decompress(data,-15) if comp==8 else data
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/deps/
|
||||
@@ -0,0 +1,154 @@
|
||||
// Downloads what the app bundles so users install nothing else: a standalone
|
||||
// Python (python-build-standalone), adb (Android platform-tools) and a CA
|
||||
// bundle. Each goes in build/deps/<os>-<arch>/{python,tools}, which package.json
|
||||
// copies into the app's resources. Also KDE Connect for the Frame (the same arm64
|
||||
// packages for every build), into ../frame/kdeconnect/packages as listed in
|
||||
// ../frame/kdeconnect/packages.json. Everything is pinned by version and SHA-256.
|
||||
// node build/fetch-deps.js mac arm64 | win x64 | linux x64 arm64
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const path = require("path");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const PY = "3.12.14+20260924";
|
||||
const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/"
|
||||
+ `${PY.split("+")[1]}/cpython-${PY}-${triple}-install_only_stripped.tar.gz`;
|
||||
const PYTHON = {
|
||||
"mac-arm64": ["aarch64-apple-darwin", "c2edb321cd32ec2b170df208db0446dccc4398db602ca27cf2079098fb1f7d9d"],
|
||||
"win-x64": ["x86_64-pc-windows-msvc", "c5bf8edfe858c1df9891be498b5bbc8761d383df5b9790658b088fea4870433a"],
|
||||
"linux-x64": ["x86_64-unknown-linux-gnu", "269b2c99e4db15b242bf01832f4fea1e8f1a664f273cff519393f296e9820b41"],
|
||||
"linux-arm64": ["aarch64-unknown-linux-gnu", "c8499b61252c433280f134df954464d19811527b31cb920c35fc6967c1222e35"],
|
||||
};
|
||||
|
||||
// Google publishes no arm64 Linux platform-tools; there the app uses the system adb.
|
||||
const PT = "37.0.1";
|
||||
const PT_URL = (os) => `https://dl.google.com/android/repository/platform-tools_r${PT}-${os}.zip`;
|
||||
const TOOLS = {
|
||||
mac: ["darwin", "ee39ad5967e95c2a07f04dbcbde96b1a0c916ba376096db5d2f498b7727a5d1d", ["adb"]],
|
||||
win: ["win", "45f4d63113e895ebde0c90f194099a4676b6ac653bd28d54314a9e022bbc1a99",
|
||||
["adb.exe", "AdbWinApi.dll", "AdbWinUsbApi.dll", "libwinpthread-1.dll"]],
|
||||
linux: ["linux", "d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1", ["adb"]],
|
||||
};
|
||||
|
||||
// Mozilla's CA list, as curl publishes it: Python on Windows only trusts roots
|
||||
// already in the Windows store (see frame_host.trust_bundled_cas).
|
||||
const CA = "2026-09-25";
|
||||
const CA_SHA256 = "a41b5d356aea97a529fe27e0f7316d2f9d946d75927476cf9cf1b90637d00505";
|
||||
|
||||
// Parts of Python the server never imports (GUI, tests, packaging, headers).
|
||||
const PRUNE = [
|
||||
"include", "share", "Scripts", "libs", "tcl", "lib/pkgconfig", "lib/itcl4", "lib/tcl8", "lib/tcl8.6",
|
||||
"lib/tk8.6", "lib/thread2.8", "bin/idle3", "bin/idle3.12", "bin/pip", "bin/pip3", "bin/pip3.12",
|
||||
"bin/pydoc3", "bin/pydoc3.12", "bin/2to3", "bin/2to3-3.12", "bin/python3-config", "bin/python3.12-config",
|
||||
...["test", "idlelib", "tkinter", "turtledemo", "ensurepip", "lib2to3", "site-packages/pip", "pydoc_data", "venv"]
|
||||
.flatMap((d) => [`lib/python3.12/${d}`, `Lib/${d}`]),
|
||||
];
|
||||
|
||||
function get(url, redirects = 5) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, { timeout: 60000 }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
if (!redirects) return reject(new Error(`${url}: too many redirects`));
|
||||
let next;
|
||||
try { next = new URL(res.headers.location, url).href; }
|
||||
catch { return reject(new Error(`${url}: bad redirect ${res.headers.location}`)); }
|
||||
return resolve(get(next, redirects - 1));
|
||||
}
|
||||
if (res.statusCode !== 200) return reject(new Error(`${url}: HTTP ${res.statusCode}`));
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
}).on("timeout", function () { this.destroy(new Error(`${url}: timed out`)); }).on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function download(url, sha256, file) {
|
||||
const data = await get(url);
|
||||
const sum = crypto.createHash("sha256").update(data).digest("hex");
|
||||
if (sum !== sha256) throw new Error(`checksum mismatch for ${url}: ${sum}`);
|
||||
fs.writeFileSync(file, data);
|
||||
}
|
||||
|
||||
// Windows' own bsdtar: Git's GNU tar, often first on PATH, reads C:\ as a remote host.
|
||||
const TAR = process.platform === "win32" ? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe") : "tar";
|
||||
|
||||
function extract(file, dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
// bsdtar (macOS, Windows 10+) reads zip files; GNU tar doesn't, so fall back to unzip.
|
||||
try { execFileSync(TAR, ["-xf", file, "-C", dir]); }
|
||||
catch (e) {
|
||||
if (!file.endsWith(".zip")) throw e;
|
||||
execFileSync("unzip", ["-q", "-o", file, "-d", dir]);
|
||||
}
|
||||
fs.rmSync(file);
|
||||
}
|
||||
|
||||
async function fetch(os, arch) {
|
||||
const key = `${os}-${arch}`;
|
||||
if (!PYTHON[key]) throw new Error(`no bundle for ${key}`);
|
||||
const out = path.join(__dirname, "deps", key);
|
||||
const stamp = path.join(out, ".version");
|
||||
const version = `python ${PY}, platform-tools ${PT}, CA ${CA}`;
|
||||
if (fs.existsSync(stamp) && fs.readFileSync(stamp, "utf8") === version) {
|
||||
console.log(`${key}: already fetched (${version})`);
|
||||
return;
|
||||
}
|
||||
fs.rmSync(out, { recursive: true, force: true });
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
|
||||
const [triple, pySha] = PYTHON[key];
|
||||
const tgz = path.join(out, "python.tar.gz");
|
||||
await download(PY_URL(triple), pySha, tgz);
|
||||
extract(tgz, out); // unpacks to python/
|
||||
for (const p of PRUNE) fs.rmSync(path.join(out, "python", p), { recursive: true, force: true });
|
||||
const stdlib = path.join(out, "python", "lib", "python3.12"); // macOS and Linux: drop the static libpython
|
||||
if (fs.existsSync(stdlib)) {
|
||||
for (const d of fs.readdirSync(stdlib)) {
|
||||
if (d.startsWith("config-3.12")) fs.rmSync(path.join(stdlib, d), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const tools = path.join(out, "tools");
|
||||
fs.mkdirSync(tools);
|
||||
if (!(os === "linux" && arch === "arm64")) {
|
||||
const [name, ptSha, keep] = TOOLS[os];
|
||||
const zip = path.join(out, "pt.zip");
|
||||
const tmp = path.join(out, "pt");
|
||||
await download(PT_URL(name), ptSha, zip);
|
||||
extract(zip, tmp);
|
||||
for (const f of [...keep, "NOTICE.txt", "source.properties"]) {
|
||||
fs.copyFileSync(path.join(tmp, "platform-tools", f), path.join(tools, f));
|
||||
}
|
||||
if (os !== "win") fs.chmodSync(path.join(tools, "adb"), 0o755);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
await download(`https://curl.se/ca/cacert-${CA}.pem`, CA_SHA256, path.join(tools, "cacert.pem"));
|
||||
fs.writeFileSync(stamp, version);
|
||||
console.log(`${key}: ${version} -> ${out}`);
|
||||
}
|
||||
|
||||
// As frame/kdeconnect/fetch.py does for the iPhone app's bundle.
|
||||
async function fetchKdeConnect() {
|
||||
const dir = path.join(__dirname, "..", "..", "frame", "kdeconnect");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(dir, "packages.json"), "utf8"));
|
||||
const out = path.join(dir, "packages");
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
for (const p of manifest.packages) {
|
||||
const file = path.join(out, p.file);
|
||||
if (fs.existsSync(file) && sha(file) === p.sha256) continue;
|
||||
await download(manifest.release + p.file, p.sha256, file);
|
||||
}
|
||||
const wanted = new Set(manifest.packages.map((p) => p.file));
|
||||
for (const f of fs.readdirSync(out)) if (!wanted.has(f)) fs.rmSync(path.join(out, f), { recursive: true, force: true });
|
||||
console.log(`KDE Connect for the Frame: ${manifest.packages.length} packages -> ${out}`);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const [os, ...archs] = process.argv.slice(2);
|
||||
if (!os || !archs.length) throw new Error("usage: node build/fetch-deps.js <mac|win|linux> <arch>...");
|
||||
for (const arch of archs) await fetch(os, arch);
|
||||
await fetchKdeConnect();
|
||||
})().catch((e) => { console.error(e.message); process.exit(1); });
|
||||
|
After Width: | Height: | Size: 265 KiB |
@@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1a9fff"/>
|
||||
<stop offset="1" stop-color="#6f42c1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="visor" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#dfe8f5"/>
|
||||
</linearGradient>
|
||||
<clipPath id="tile"><rect x="100" y="100" width="824" height="824" rx="185"/></clipPath>
|
||||
<mask id="nose">
|
||||
<rect width="1024" height="1024" fill="#fff"/>
|
||||
<ellipse cx="512" cy="690" rx="78" ry="96" fill="#000"/>
|
||||
</mask>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#0b1020" flood-opacity=".35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<g clip-path="url(#tile)">
|
||||
<rect x="100" y="100" width="824" height="824" fill="url(#bg)"/>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="222" y="350" width="580" height="320" rx="130" fill="url(#visor)" mask="url(#nose)"/>
|
||||
</g>
|
||||
<rect x="300" y="430" width="160" height="124" rx="50" fill="#13233a"/>
|
||||
<rect x="564" y="430" width="160" height="124" rx="50" fill="#13233a"/>
|
||||
<rect x="320" y="448" width="56" height="30" rx="15" fill="#66c0f4" opacity=".9"/>
|
||||
<rect x="584" y="448" width="56" height="30" rx="15" fill="#66c0f4" opacity=".9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,32 @@
|
||||
// Renders build/icon.svg to icon.png (1024px) and icon.icns. Run: npm run icon
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const { execFileSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
app.dock?.hide();
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ width: 1024, height: 1024, show: false, transparent: true, frame: false,
|
||||
useContentSize: true, webPreferences: { offscreen: true } });
|
||||
const svg = fs.readFileSync(path.join(__dirname, "icon.svg"), "utf8");
|
||||
await win.loadURL("data:text/html," + encodeURIComponent(
|
||||
`<body style="margin:0;background:transparent">${svg}</body>`));
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const png = (await win.webContents.capturePage({ x: 0, y: 0, width: 1024, height: 1024 }))
|
||||
.resize({ width: 1024, height: 1024 }).toPNG();
|
||||
fs.writeFileSync(path.join(__dirname, "icon.png"), png);
|
||||
|
||||
const set = fs.mkdtempSync(path.join(os.tmpdir(), "icon-")) + "/icon.iconset";
|
||||
fs.mkdirSync(set);
|
||||
for (const size of [16, 32, 128, 256, 512]) {
|
||||
for (const scale of [1, 2]) {
|
||||
const px = size * scale, name = `icon_${size}x${size}${scale === 2 ? "@2x" : ""}.png`;
|
||||
execFileSync("sips", ["-z", String(px), String(px), path.join(__dirname, "icon.png"),
|
||||
"--out", path.join(set, name)], { stdio: "ignore" });
|
||||
}
|
||||
}
|
||||
execFileSync("iconutil", ["-c", "icns", set, "-o", path.join(__dirname, "icon.icns")]);
|
||||
console.log("wrote build/icon.png and build/icon.icns");
|
||||
app.quit();
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// Parses frame-control://install?manifest=URL and frame-control://install?url=URL
|
||||
// (see docs/web-install.md). Pure, so it runs under plain node for the tests.
|
||||
// This is only a first filter: ui/frame_webinstall.py applies the full URL rules
|
||||
// (HTTPS, no private addresses, redirects) before anything is fetched.
|
||||
const SCHEME = "frame-control";
|
||||
const MAX_LINK = 4096;
|
||||
const MAX_URL = 2048;
|
||||
|
||||
// {kind: "manifest" | "url", target} or null if raw isn't a usable install link.
|
||||
function parseInstallLink(raw) {
|
||||
if (typeof raw !== "string" || raw.length > MAX_LINK || !raw.toLowerCase().startsWith(`${SCHEME}:`)) return null;
|
||||
let link;
|
||||
try { link = new URL(raw); } catch { return null; }
|
||||
// frame-control://install?… puts "install" in the host; accept a trailing slash too.
|
||||
if (link.protocol !== `${SCHEME}:` || link.hostname !== "install" || !["", "/"].includes(link.pathname)) return null;
|
||||
const keys = [...new Set(link.searchParams.keys())];
|
||||
if (keys.length !== 1 || !["manifest", "url"].includes(keys[0])) return null;
|
||||
const values = link.searchParams.getAll(keys[0]);
|
||||
if (values.length !== 1) return null;
|
||||
const target = values[0];
|
||||
if (!target || target.length > MAX_URL) return null;
|
||||
let parsed;
|
||||
try { parsed = new URL(target); } catch { return null; }
|
||||
if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password) return null;
|
||||
return { kind: keys[0], target };
|
||||
}
|
||||
|
||||
// The link among command-line arguments (Windows and Linux pass it there).
|
||||
function linkFromArgv(argv) {
|
||||
return (argv || []).find((a) => typeof a === "string" && a.toLowerCase().startsWith(`${SCHEME}:`)) || null;
|
||||
}
|
||||
|
||||
module.exports = { SCHEME, parseInstallLink, linkFromArgv };
|
||||
@@ -0,0 +1,492 @@
|
||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||
// a free loopback port and shows it in a native window. The server does all the
|
||||
// work over the `frame` SSH alias; this file only hosts it.
|
||||
const { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, shell } = require("electron");
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
const net = require("net");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||
const updater = require("./updater");
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
const IS_MAC = process.platform === "darwin";
|
||||
const IS_WIN = process.platform === "win32";
|
||||
|
||||
// Packaged: <resources>/{ui,scripts,python}. Dev: the repo checkout.
|
||||
const ROOT = app.isPackaged ? process.resourcesPath : path.join(__dirname, "..");
|
||||
const TOOLS = path.join(ROOT, "tools"); // bundled adb and CA certificates
|
||||
const SERVER = path.join(ROOT, "ui", "server.py");
|
||||
const SCRIPTS = path.join(ROOT, "scripts");
|
||||
const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Control")
|
||||
: path.join(app.getPath("userData"), "logs");
|
||||
const LOG = path.join(LOG_DIR, "server.log");
|
||||
const BG = "#0d1117";
|
||||
const FRAME = process.env.FRAME_ALIAS || "frame";
|
||||
|
||||
let server = null;
|
||||
let url = null;
|
||||
let win = null;
|
||||
let quitting = false;
|
||||
let python = null;
|
||||
|
||||
// Apps launched from Finder get PATH=/usr/bin:/bin:/usr/sbin:/sbin, which misses
|
||||
// Homebrew's python3, rsync and adb (desktop launchers on Linux can be as bare).
|
||||
// Take PATH from the login shell instead. Windows has no login shell to ask.
|
||||
// Runs asynchronously so a slow shell profile can't freeze the window.
|
||||
let cachedPath = null;
|
||||
async function loginPath() {
|
||||
if (IS_WIN) return process.env.PATH || "";
|
||||
if (cachedPath) return cachedPath;
|
||||
const shellPath = os.userInfo().shell || process.env.SHELL || (IS_MAC ? "/bin/zsh" : "/bin/sh");
|
||||
const extra = IS_MAC ? ["/opt/homebrew/bin", "/usr/local/bin", path.join(os.homedir(), ".homebrew", "bin")] : [];
|
||||
let fromShell = "";
|
||||
try {
|
||||
const { stdout } = await run(shellPath, ["-ilc", 'printf "\\n__PATH__%s__PATH__" "$PATH"'],
|
||||
{ encoding: "utf8", timeout: 5000 });
|
||||
fromShell = (stdout.match(/__PATH__(.*)__PATH__/) || [])[1] || "";
|
||||
} catch {}
|
||||
const parts = [...fromShell.split(":"), ...(process.env.PATH || "").split(":"), ...extra];
|
||||
const joined = [...new Set(parts.filter(Boolean))].join(":");
|
||||
if (fromShell) cachedPath = joined; // retry next time if the shell didn't answer
|
||||
return joined;
|
||||
}
|
||||
|
||||
// The Windows build bundles Python; elsewhere use the system's python3 (3.8+).
|
||||
// -I ignores PYTHON* variables and user site-packages, so a PYTHONHOME or
|
||||
// PYTHONPATH set for another Python can't break the bundled one. That makes these
|
||||
// flags stand in for PYTHONUNBUFFERED, PYTHONDONTWRITEBYTECODE (no __pycache__
|
||||
// inside the signed app) and PYTHONUTF8.
|
||||
const PY_FLAGS = ["-I", "-u", "-B", "-X", "utf8"];
|
||||
|
||||
async function findPython(env) {
|
||||
const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3"];
|
||||
// The packaged app bundles Python (app/build/fetch-deps.js); a checkout uses PATH.
|
||||
const candidates = [path.join(ROOT, "python", ...(IS_WIN ? ["python.exe"] : ["bin", "python3"]))];
|
||||
for (const dir of env.PATH.split(path.delimiter)) {
|
||||
// The WindowsApps "python.exe" is a stub that opens the Microsoft Store.
|
||||
if (!dir || (IS_WIN && /\\WindowsApps\\?$/i.test(dir))) continue;
|
||||
for (const name of names) candidates.push(path.join(dir, name));
|
||||
}
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
fs.accessSync(p, fs.constants.X_OK);
|
||||
// /usr/bin/python3 on macOS is a stub until the Command Line Tools are installed.
|
||||
await run(p, [...PY_FLAGS, "-c", "import http.server, sys; assert sys.version_info >= (3, 8)"],
|
||||
{ timeout: 10000, env, windowsHide: true });
|
||||
return p;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function hasSsh(env) {
|
||||
try { await run("ssh", ["-V"], { timeout: 5000, env, windowsHide: true }); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
const PYTHON_HELP = app.isPackaged ? "The bundled Python is missing; reinstall Frame Control."
|
||||
: "Install Python 3.8 or later, then reopen the app.";
|
||||
const SSH_HELP = IS_WIN
|
||||
? "Turn on Windows' OpenSSH client: Settings → System → Optional features → Add a feature → OpenSSH Client."
|
||||
: "Install the OpenSSH client (e.g. sudo apt install openssh-client).";
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = net.createServer();
|
||||
s.once("error", reject);
|
||||
s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => resolve(port)); });
|
||||
});
|
||||
}
|
||||
|
||||
// Ready once the port answers with our Server header.
|
||||
function ping(target) {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(target, { timeout: 1000 }, (res) => {
|
||||
res.resume();
|
||||
resolve(/^FrameControl/.test(res.headers.server || ""));
|
||||
});
|
||||
req.on("error", () => resolve(false));
|
||||
req.on("timeout", () => { req.destroy(); resolve(false); });
|
||||
});
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
// The version and whether this is a built app go to ui/frame_telemetry.py, which
|
||||
// sends nothing from a source checkout.
|
||||
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
|
||||
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
|
||||
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
|
||||
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
|
||||
python = await findPython(env);
|
||||
if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
|
||||
if (!await hasSsh(env)) throw new Error(`Frame Control needs the ssh command. ${SSH_HELP}`);
|
||||
const port = await freePort();
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
const log = fs.openSync(LOG, "a");
|
||||
fs.writeSync(log, `\n--- ${new Date().toISOString()} ${python} ${SERVER} --port ${port}\n`);
|
||||
// stdin stays open while the app runs; the server exits cleanly when it closes.
|
||||
const child = spawn(python, [...PY_FLAGS, SERVER, "--port", String(port), "--exit-on-eof"],
|
||||
{ env, stdio: ["pipe", log, log], windowsHide: true });
|
||||
child.stdin.on("error", () => {});
|
||||
fs.closeSync(log);
|
||||
server = child;
|
||||
let exited = null;
|
||||
child.once("error", (err) => {
|
||||
exited = err.message;
|
||||
if (server === child) { server = null; if (!quitting && url) serverDied(err.message); }
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
exited = signal || code;
|
||||
if (server !== child) return; // replaced by Restart Server
|
||||
server = null;
|
||||
if (!quitting && url) serverDied(exited);
|
||||
});
|
||||
|
||||
const target = `http://127.0.0.1:${port}/`;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
|
||||
if (await ping(target)) { url = target; return; }
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (server === child) server = null;
|
||||
endServer(child);
|
||||
throw new Error(`The server didn't start within 10 seconds. See ${LOG}.`);
|
||||
}
|
||||
|
||||
// Closing stdin lets server.py close its SSH connections and exit (the only clean
|
||||
// way on Windows); SIGTERM does the same elsewhere.
|
||||
function endServer(child) {
|
||||
try { child.stdin.end(); } catch {}
|
||||
if (!IS_WIN) child.kill("SIGTERM");
|
||||
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
|
||||
}
|
||||
|
||||
function stopServer() {
|
||||
if (server) endServer(server);
|
||||
}
|
||||
|
||||
function errorPage(message) {
|
||||
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
|
||||
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
|
||||
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
|
||||
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
|
||||
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
|
||||
}
|
||||
|
||||
function serverDied(why) {
|
||||
url = null;
|
||||
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
|
||||
}
|
||||
|
||||
async function restartServer() {
|
||||
const old = server;
|
||||
server = null;
|
||||
url = null;
|
||||
if (old) endServer(old);
|
||||
await load();
|
||||
}
|
||||
|
||||
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
|
||||
const CHROME_CSS = IS_MAC && `
|
||||
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
||||
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
|
||||
`;
|
||||
|
||||
// Restart Server can start a new load while an older one is still waiting for
|
||||
// its server; only the newest load may touch the window.
|
||||
let loadGen = 0;
|
||||
async function load() {
|
||||
const gen = ++loadGen;
|
||||
try {
|
||||
if (!url) await startServer();
|
||||
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
|
||||
} catch (e) {
|
||||
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
// `ssh -G` prints the effective config. An alias nobody configured keeps its
|
||||
// own name as HostName; connect.sh always writes a HostName.
|
||||
async function aliasConfigured(env) {
|
||||
try {
|
||||
const { stdout } = await run("ssh", ["-G", FRAME], { encoding: "utf8", timeout: 5000, env });
|
||||
return (stdout.match(/^hostname (.*)$/m) || [])[1] !== FRAME;
|
||||
} catch {
|
||||
return true; // can't tell; don't nag
|
||||
}
|
||||
}
|
||||
|
||||
let setupOffered = false;
|
||||
async function firstRunCheck() {
|
||||
if (setupOffered || !url) return; // not on the error page, and once per launch
|
||||
if (await aliasConfigured({ ...process.env, PATH: await loginPath() })) return;
|
||||
if (!win || setupOffered) return;
|
||||
setupOffered = true;
|
||||
const { response } = await dialog.showMessageBox(win, {
|
||||
type: "info",
|
||||
message: "Connect to your Steam Frame",
|
||||
detail: `There's no "${FRAME}" SSH alias yet. On the Frame, turn on Steam Settings → System → `
|
||||
+ "Enable Developer Mode, then Developer → Set User Password. Then run the setup: it finds the "
|
||||
+ "headset, creates a key, and asks for that password once in a terminal window.",
|
||||
buttons: ["Set Up Connection…", "Later"],
|
||||
defaultId: 0, cancelId: 1,
|
||||
});
|
||||
if (response === 0) setUpConnection();
|
||||
}
|
||||
|
||||
// IPC only from our own page in our own window.
|
||||
function fromUi(e) {
|
||||
if (!win || e.sender !== win.webContents || !url || !e.senderFrame) return false;
|
||||
try {
|
||||
return new URL(e.senderFrame.url).origin === new URL(url).origin;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
|
||||
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
||||
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
||||
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
|
||||
|
||||
// frame-control://install links from websites (docs/web-install.md). They can
|
||||
// arrive before the window or server exists (macOS open-url on a cold launch),
|
||||
// so they wait here until the page asks for them. The page checks the link with
|
||||
// the server and installs nothing until the user confirms in its dialog.
|
||||
const pendingLinks = [];
|
||||
let linkPage = null; // the webContents whose current page is listening
|
||||
|
||||
function openInstallLink(raw) {
|
||||
const req = parseInstallLink(raw);
|
||||
if (!req) {
|
||||
app.whenReady().then(() => dialog.showErrorBox("Frame Control can't use this link",
|
||||
"Install links look like frame-control://install?manifest=https://… or frame-control://install?url=https://…"));
|
||||
return;
|
||||
}
|
||||
pendingLinks.push(req);
|
||||
if (pendingLinks.length > 5) pendingLinks.shift(); // a page opening links in a loop
|
||||
deliverLinks();
|
||||
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
|
||||
}
|
||||
|
||||
function deliverLinks() {
|
||||
if (!win || !linkPage || linkPage !== win.webContents) return;
|
||||
while (pendingLinks.length) win.webContents.send("install-link", pendingLinks.shift());
|
||||
}
|
||||
|
||||
ipcMain.on("install-link:ready", (e) => {
|
||||
if (!fromUi(e)) return;
|
||||
linkPage = e.sender;
|
||||
deliverLinks();
|
||||
});
|
||||
|
||||
// ---- updates (app/updater.js, docs/releasing.md) ----
|
||||
// Checked shortly after launch and every few hours; the page shows a banner and
|
||||
// the Update button calls installUpdate.
|
||||
const UPDATE_EVERY = 6 * 3600 * 1000;
|
||||
const update = { status: "idle", current: app.getVersion(), latest: null, error: null, progress: 0, how: null };
|
||||
|
||||
function publicUpdate() {
|
||||
const r = update.latest;
|
||||
return { status: update.status, current: update.current, error: update.error, progress: update.progress,
|
||||
latest: r && { version: r.version, notes: r.notes, page: r.page },
|
||||
canInstall: !!update.how && update.how.method !== "manual", why: update.how && update.how.why };
|
||||
}
|
||||
|
||||
function setUpdate(fields) {
|
||||
Object.assign(update, fields);
|
||||
if (win && linkPage === win.webContents) win.webContents.send("update:state", publicUpdate());
|
||||
}
|
||||
|
||||
async function checkForUpdate({ manual = false } = {}) {
|
||||
if (["checking", "downloading", "ready"].includes(update.status)) return;
|
||||
setUpdate({ status: "checking", error: null });
|
||||
try {
|
||||
const latest = await updater.latestRelease();
|
||||
const how = updater.updateMethod({ platform: process.platform, isPackaged: app.isPackaged,
|
||||
execPath: process.execPath, env: process.env,
|
||||
exists: fs.existsSync, writable: updater.writable });
|
||||
if (updater.isNewer(latest.version, update.current)) {
|
||||
setUpdate({ status: "available", latest, how });
|
||||
if (manual) offerUpdateDialog();
|
||||
} else {
|
||||
setUpdate({ status: "none", latest, how });
|
||||
if (manual) dialog.showMessageBox(win, { type: "info", message: "Frame Control is up to date",
|
||||
detail: `You have ${update.current}, the newest version.` });
|
||||
}
|
||||
} catch (e) {
|
||||
// A failed check: nothing to install, and never an older release kept from before.
|
||||
setUpdate({ status: "check-failed", error: e.message, latest: null });
|
||||
if (manual) dialog.showMessageBox(win, { type: "warning", message: "Couldn't check for updates", detail: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function offerUpdateDialog() {
|
||||
const r = update.latest;
|
||||
const { response } = await dialog.showMessageBox(win, {
|
||||
type: "info", message: `Frame Control ${r.version} is available`,
|
||||
detail: `You have ${update.current}.` + (update.how.method === "manual" ? ` Download it from the release page (${update.how.why}).` : ""),
|
||||
buttons: [update.how.method === "manual" ? "Open Release Page" : "Update and Restart", "Later"], defaultId: 0, cancelId: 1,
|
||||
});
|
||||
if (response === 0) installUpdate();
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
// "error" here only ever means an install failed, so trying again is safe.
|
||||
if (update.status !== "available" && update.status !== "error") return;
|
||||
if (!update.latest || !updater.isNewer(update.latest.version, update.current)) return;
|
||||
if (!update.how || update.how.method === "manual") { shell.openExternal(update.latest.page); return; }
|
||||
setUpdate({ status: "downloading", progress: 0, error: null });
|
||||
try {
|
||||
const start = await updater.prepare(update.latest, update.how,
|
||||
(done, total) => { if (total) setUpdate({ progress: done / total }); }, update.current);
|
||||
setUpdate({ status: "ready", progress: 1 });
|
||||
start();
|
||||
quitting = true;
|
||||
app.quit();
|
||||
} catch (e) {
|
||||
setUpdate({ status: "error", error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleUpdateChecks() {
|
||||
if (process.env.FRAME_CONTROL_NO_UPDATE_CHECK === "1") return;
|
||||
setTimeout(checkForUpdate, 8000);
|
||||
setInterval(checkForUpdate, UPDATE_EVERY).unref();
|
||||
}
|
||||
|
||||
function registerScheme() {
|
||||
// A checkout runs as `electron .`, so the OS must be told the script too.
|
||||
// (macOS takes the scheme from Info.plist, which only the built app has.)
|
||||
if (process.defaultApp) {
|
||||
if (process.argv.length >= 2) app.setAsDefaultProtocolClient(SCHEME, process.execPath, [path.resolve(process.argv[1])]);
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(SCHEME);
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
||||
title: "Frame Control", backgroundColor: BG, show: false,
|
||||
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
|
||||
: { icon: path.join(__dirname, "build", "icon.png") }),
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
|
||||
preload: path.join(__dirname, "preload.js") },
|
||||
});
|
||||
win.once("ready-to-show", () => win.show());
|
||||
if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
|
||||
// External links open in the default browser; the app never navigates away.
|
||||
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||
if (/^https?:\/\//.test(target)) shell.openExternal(target);
|
||||
return { action: "deny" };
|
||||
});
|
||||
win.webContents.on("will-navigate", (e, target) => {
|
||||
if (!url || new URL(target).origin !== new URL(url).origin) e.preventDefault();
|
||||
});
|
||||
// A reload or a new page must ask for links again before it gets any.
|
||||
win.webContents.on("did-start-loading", () => { linkPage = null; });
|
||||
win.on("closed", () => { win = null; linkPage = null; });
|
||||
load();
|
||||
}
|
||||
|
||||
// Opens a terminal window (Terminal, a Linux terminal emulator or a console) via
|
||||
// ui/frame_host.py, which the server uses too: setup and power actions ask for the
|
||||
// Developer Mode password there.
|
||||
async function runInTerminal(argv) {
|
||||
try {
|
||||
const env = { ...process.env, PATH: await loginPath() };
|
||||
const py = python || await findPython(env);
|
||||
if (!py) throw new Error(`Python 3.8 or later is needed. ${PYTHON_HELP}`);
|
||||
await run(py, [...PY_FLAGS, path.join(ROOT, "ui", "frame_host.py"), "terminal", "--", ...argv],
|
||||
{ env, timeout: 15000, windowsHide: true });
|
||||
} catch (err) {
|
||||
dialog.showErrorBox("Couldn't open a terminal", String((err.stderr || err.message || err)).trim());
|
||||
}
|
||||
}
|
||||
|
||||
async function setUpConnection() {
|
||||
const alias = `FRAME_ALIAS=${FRAME}`;
|
||||
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
|
||||
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
|
||||
const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
|
||||
// A new console inherits our environment on Windows; Linux terminals may not.
|
||||
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
|
||||
}
|
||||
|
||||
function buildMenu() {
|
||||
const template = [
|
||||
...(IS_MAC ? [{ label: app.name, submenu: [
|
||||
{ role: "about" }, { label: "Check for Updates…", click: () => checkForUpdate({ manual: true }) },
|
||||
{ type: "separator" }, { role: "services" }, { type: "separator" },
|
||||
{ role: "hide" }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, { role: "quit" },
|
||||
] }] : []),
|
||||
{ role: "fileMenu" },
|
||||
{ role: "editMenu" },
|
||||
{
|
||||
label: "Frame",
|
||||
submenu: [
|
||||
{ label: "Set Up Connection…", click: setUpConnection },
|
||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
|
||||
{ type: "separator" },
|
||||
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
||||
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
||||
{ label: "Show Server Log", click: () => shell.openPath(fs.existsSync(LOG) ? LOG : LOG_DIR) },
|
||||
...(IS_WIN ? [] : [{ label: "Reveal Helper Scripts", click: () => shell.openPath(SCRIPTS) }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" }, { role: "forceReload" }, { role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{ role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" },
|
||||
{ type: "separator" }, { role: "togglefullscreen" },
|
||||
],
|
||||
},
|
||||
...(IS_MAC ? [{ role: "windowMenu" }] : []),
|
||||
{
|
||||
role: "help",
|
||||
submenu: [
|
||||
...(IS_MAC ? [] : [{ label: "Check for Updates…", click: () => checkForUpdate({ manual: true }) }]),
|
||||
{ label: "Report a Problem…", click: () => {
|
||||
if (win && url && linkPage === win.webContents) win.webContents.send("report:open");
|
||||
else shell.openExternal("https://frame-control.pages.dev/feedback/"); // the page isn't up
|
||||
} },
|
||||
{ label: "Release Notes", click: () => shell.openExternal(updater.RELEASES) },
|
||||
{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") },
|
||||
],
|
||||
},
|
||||
];
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit();
|
||||
} else {
|
||||
// macOS delivers install links here, even before the app is ready.
|
||||
app.on("open-url", (e, link) => { e.preventDefault(); openInstallLink(link); });
|
||||
// Windows and Linux start a second instance with the link as an argument.
|
||||
app.on("second-instance", (_e, argv) => {
|
||||
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
|
||||
const link = linkFromArgv(argv);
|
||||
if (link) openInstallLink(link);
|
||||
});
|
||||
const firstLink = IS_MAC ? null : linkFromArgv(process.argv);
|
||||
if (firstLink) openInstallLink(firstLink);
|
||||
app.whenReady().then(() => {
|
||||
registerScheme();
|
||||
buildMenu();
|
||||
createWindow();
|
||||
scheduleUpdateChecks();
|
||||
});
|
||||
app.on("activate", () => { if (!win) createWindow(); });
|
||||
app.on("window-all-closed", () => app.quit());
|
||||
app.on("before-quit", () => { quitting = true; stopServer(); });
|
||||
process.on("exit", stopServer);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.0",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||
"dist": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
|
||||
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
|
||||
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
"electron-builder": "^26.15.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.saphid.frame-control",
|
||||
"productName": "Frame Control",
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Frame Control install link",
|
||||
"schemes": [
|
||||
"frame-control"
|
||||
]
|
||||
}
|
||||
],
|
||||
"directories": {
|
||||
"output": "dist",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../ui",
|
||||
"to": "ui",
|
||||
"filter": [
|
||||
"*.py",
|
||||
"*.html",
|
||||
"telemetry.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../scripts",
|
||||
"to": "scripts",
|
||||
"filter": [
|
||||
"*.sh"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../frame/android",
|
||||
"to": "frame/android",
|
||||
"filter": [
|
||||
"*.sh",
|
||||
"*.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../frame/openxr-compat",
|
||||
"to": "frame/openxr-compat",
|
||||
"filter": [
|
||||
"XrApiLayer_FRAME_compat.json",
|
||||
"prebuilt/**/*.so"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../frame/devkit-utils",
|
||||
"to": "frame/devkit-utils",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!**/__pycache__/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../frame/kdeconnect",
|
||||
"to": "frame/kdeconnect",
|
||||
"filter": [
|
||||
"packages.json",
|
||||
"NOTICE.md",
|
||||
"LICENSES/**/*",
|
||||
"packages/*.pkg.tar.zst"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../apk-catalog",
|
||||
"to": "apk-catalog",
|
||||
"filter": [
|
||||
"*.py",
|
||||
"pins.json",
|
||||
"site/apps.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "build/deps/${os}-${arch}/python",
|
||||
"to": "python",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "build/deps/${os}-${arch}/tools",
|
||||
"to": "tools",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../THIRD_PARTY_NOTICES.md",
|
||||
"to": "THIRD_PARTY_NOTICES.md"
|
||||
},
|
||||
{
|
||||
"from": "../LICENSE",
|
||||
"to": "LICENSE"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.utilities",
|
||||
"icon": "build/icon.icns",
|
||||
"identity": "-",
|
||||
"hardenedRuntime": false,
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../mac/bin",
|
||||
"to": "mac/bin",
|
||||
"filter": [
|
||||
"frame-mac-view"
|
||||
]
|
||||
}
|
||||
],
|
||||
"extendInfo": {
|
||||
"NSAppleEventsUsageDescription": "Frame Control opens Terminal for SSH sessions and for power actions that need the Developer Mode password.",
|
||||
"NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network.",
|
||||
"NSScreenCaptureUsageDescription": "Frame Control shows your Mac's windows and screens inside the Steam Frame when you ask it to."
|
||||
},
|
||||
"artifactName": "Frame-Control-mac-${arch}.${ext}"
|
||||
},
|
||||
"dmg": {
|
||||
"title": "Frame Control ${version}"
|
||||
},
|
||||
"electronFuses": {
|
||||
"runAsNode": false,
|
||||
"enableNodeOptionsEnvironmentVariable": false,
|
||||
"enableNodeCliInspectArguments": false
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"category": "Utility",
|
||||
"icon": "build/icon.png",
|
||||
"executableName": "frame-control",
|
||||
"synopsis": "Manage a Valve Steam Frame over SSH",
|
||||
"artifactName": "Frame-Control-linux-${arch}.${ext}",
|
||||
"desktop": {
|
||||
"entry": {
|
||||
"StartupWMClass": "frame-control"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deb": {
|
||||
"depends": [
|
||||
"openssh-client"
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis",
|
||||
"zip"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"artifactName": "Frame-Control-win-${arch}.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"artifactName": "Frame-Control-Setup-${arch}.${ext}"
|
||||
}
|
||||
},
|
||||
"homepage": "https://github.com/saphid/steam-frame",
|
||||
"author": {
|
||||
"name": "saphid",
|
||||
"email": "4596216+saphid@users.noreply.github.com"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Lets the page read this computer's clipboard through Electron, so sending it
|
||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
||||
// as a title without zipping it (the local server reads it from there).
|
||||
// It can open Set Up Connection when the headset can't be reached.
|
||||
// It also receives frame-control://install links (docs/web-install.md): only
|
||||
// what the link asked for, never an install; the page asks the user first.
|
||||
// And it passes update state both ways: see app/updater.js.
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
// While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame.
|
||||
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
|
||||
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
||||
// Updates (app/updater.js): the page shows a banner and an Update button.
|
||||
update: {
|
||||
get: () => ipcRenderer.invoke("update:get"),
|
||||
check: () => ipcRenderer.invoke("update:check"),
|
||||
install: () => ipcRenderer.invoke("update:install"),
|
||||
onState: (cb) => {
|
||||
ipcRenderer.removeAllListeners("update:state");
|
||||
ipcRenderer.on("update:state", (_e, s) => cb(s));
|
||||
},
|
||||
},
|
||||
// Help → Report a Problem… opens the page's report dialog (ui/frame_report.py).
|
||||
onReportProblem: (cb) => {
|
||||
ipcRenderer.removeAllListeners("report:open");
|
||||
ipcRenderer.on("report:open", () => cb());
|
||||
},
|
||||
onInstallLink: (cb) => {
|
||||
ipcRenderer.removeAllListeners("install-link");
|
||||
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
|
||||
ipcRenderer.send("install-link:ready");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// Run: node --test app/test/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { isNewer, assetName, updateMethod, macBundle } = require("../updater");
|
||||
|
||||
test("versions compare numerically, and a release beats its pre-releases", () => {
|
||||
assert.ok(isNewer("0.3.10", "0.3.9"));
|
||||
assert.ok(isNewer("v1.0.0", "0.9.9"));
|
||||
assert.ok(!isNewer("0.3.1", "0.3.1"));
|
||||
assert.ok(!isNewer("0.3.0", "0.3.1"));
|
||||
assert.ok(isNewer("1.0.0", "1.0.0-beta.1"));
|
||||
assert.ok(!isNewer("1.0.0-beta.1", "1.0.0"));
|
||||
assert.ok(!isNewer("garbage", "0.1.0"));
|
||||
});
|
||||
|
||||
test("asset names match what electron-builder publishes", () => {
|
||||
assert.strictEqual(assetName("darwin", "arm64", "mac-zip"), "Frame-Control-mac-arm64.zip");
|
||||
assert.strictEqual(assetName("win32", "x64", "nsis"), "Frame-Control-Setup-x64.exe");
|
||||
assert.strictEqual(assetName("linux", "x64", "appimage"), "Frame-Control-linux-x86_64.AppImage");
|
||||
assert.strictEqual(assetName("linux", "arm64", "appimage"), "Frame-Control-linux-arm64.AppImage");
|
||||
});
|
||||
|
||||
const base = { isPackaged: true, env: {}, exists: () => false, writable: () => true };
|
||||
|
||||
test("macOS updates in place only from a writable, non-translocated location", () => {
|
||||
const exe = "/Applications/Frame Control.app/Contents/MacOS/Frame Control";
|
||||
assert.strictEqual(macBundle(exe), "/Applications/Frame Control.app");
|
||||
assert.deepStrictEqual(updateMethod({ ...base, platform: "darwin", execPath: exe }),
|
||||
{ method: "mac-zip", bundle: "/Applications/Frame Control.app" });
|
||||
const dmg = "/Volumes/Frame Control 0.3.1/Frame Control.app/Contents/MacOS/Frame Control";
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: dmg }).method, "manual");
|
||||
const trans = "/private/var/folders/x/AppTranslocation/ABC/d/Frame Control.app/Contents/MacOS/Frame Control";
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: trans }).method, "manual");
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: exe, writable: () => false }).method, "manual");
|
||||
});
|
||||
|
||||
test("Windows needs the installer's copy; Linux needs an AppImage", () => {
|
||||
const exe = "C:\\Users\\a\\AppData\\Local\\Programs\\Frame Control\\Frame Control.exe";
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "win32", execPath: exe, exists: () => true }).method, "nsis");
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "win32", execPath: exe }).method, "manual");
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "linux", execPath: "/opt/x", env: { APPIMAGE: "/home/a/F.AppImage" } }).method,
|
||||
"appimage");
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "linux", execPath: "/opt/Frame Control/frame-control" }).method, "manual");
|
||||
assert.strictEqual(updateMethod({ ...base, isPackaged: false, platform: "darwin", execPath: "x" }).method, "manual");
|
||||
});
|
||||
|
||||
test("update.json assets always download from this repository's release", () => {
|
||||
const r = require("../updater").fromManifest({ version: "0.4.0", notes: "n", assets: [
|
||||
{ name: "Frame-Control-mac-arm64.zip", url: "https://evil.example/x.zip", digest: "sha256:" + "a".repeat(64) }] });
|
||||
assert.strictEqual(r.assets[0].url, "https://github.com/saphid/frame-control/releases/download/v0.4.0/Frame-Control-mac-arm64.zip");
|
||||
assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
|
||||
});
|
||||
|
||||
test("prepare refuses a release that isn't newer (no downgrades)", async () => {
|
||||
const { prepare } = require("../updater");
|
||||
const release = { version: "0.3.1", assets: [] };
|
||||
await assert.rejects(prepare(release, { method: "appimage", appImage: "/nonexistent/x" }, null, "0.4.0"), /isn't newer/);
|
||||
await assert.rejects(prepare(release, { method: "appimage", appImage: "/nonexistent/x" }, null, "0.3.1"), /isn't newer/);
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
// Update checks and self-update for the desktop app (docs/releasing.md).
|
||||
//
|
||||
// The newest version is GitHub's "latest" release of saphid/frame-control. Drafts
|
||||
// and pre-releases never count, so a build reaches people only when the
|
||||
// maintainer publishes it after testing (scripts/publish-release.sh). That
|
||||
// script attaches update.json (version, notes, each asset's SHA-256), read
|
||||
// through github.com's latest/download link: the REST API allows only 60
|
||||
// unauthenticated requests an hour per IP address, shared by everyone behind
|
||||
// the same router, so it's only the fallback.
|
||||
//
|
||||
// Every download is checked against the SHA-256 digest GitHub records for the
|
||||
// asset before anything is replaced. How the update is applied:
|
||||
// macOS the .zip: unpacked next to the running app, swapped in by a small
|
||||
// script once the app has quit, then reopened.
|
||||
// Windows the NSIS installer, run silently over the current install; it
|
||||
// reopens the app. A copy unpacked from the .zip is updated by hand.
|
||||
// Linux the AppImage replaces itself; .deb installs are updated by hand.
|
||||
// When the app can't update itself it opens the release page instead.
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const REPO = "saphid/frame-control"; // renamed from saphid/steam-frame; GitHub redirects the old name
|
||||
const LATEST = `https://api.github.com/repos/${REPO}/releases/latest`;
|
||||
const MANIFEST = `https://github.com/${REPO}/releases/latest/download/update.json`;
|
||||
const RELEASES = `https://github.com/${REPO}/releases`;
|
||||
|
||||
// "0.3.1" or "v0.3.1" -> [0, 3, 1]; pre-release suffixes sort before the release.
|
||||
function parseVersion(v) {
|
||||
const m = String(v || "").trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
||||
return m ? { nums: [+m[1], +m[2], +m[3]], pre: m[4] || null } : null;
|
||||
}
|
||||
|
||||
function isNewer(candidate, current) {
|
||||
const a = parseVersion(candidate), b = parseVersion(current);
|
||||
if (!a || !b) return false;
|
||||
for (let i = 0; i < 3; i++) if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i];
|
||||
if (a.pre === b.pre) return false;
|
||||
if (!a.pre) return true; // 1.0.0 is newer than 1.0.0-beta
|
||||
if (!b.pre) return false;
|
||||
return a.pre > b.pre;
|
||||
}
|
||||
|
||||
// The asset this copy of the app updates from, by the names electron-builder gives them.
|
||||
function assetName(platform, arch, method) {
|
||||
if (method === "mac-zip") return `Frame-Control-mac-${arch}.zip`;
|
||||
if (method === "nsis") return `Frame-Control-Setup-${arch}.exe`;
|
||||
if (method === "appimage") return `Frame-Control-linux-${arch === "x64" ? "x86_64" : arch}.AppImage`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// How this copy can update itself: mac-zip, nsis, appimage, or manual (with why).
|
||||
function updateMethod({ platform, isPackaged, execPath, env, exists, writable }) {
|
||||
if (!isPackaged) return { method: "manual", why: "running from a source checkout" };
|
||||
if (platform === "darwin") {
|
||||
const bundle = macBundle(execPath);
|
||||
if (!bundle) return { method: "manual", why: "can't find the app bundle" };
|
||||
if (bundle.includes("/AppTranslocation/") || bundle.startsWith("/Volumes/")) {
|
||||
return { method: "manual", why: "move Frame Control to Applications first" };
|
||||
}
|
||||
if (!writable(path.dirname(bundle))) return { method: "manual", why: `${path.dirname(bundle)} isn't writable` };
|
||||
return { method: "mac-zip", bundle };
|
||||
}
|
||||
if (platform === "win32") {
|
||||
// electron-builder's NSIS install puts its uninstaller next to the app.
|
||||
const dir = path.dirname(execPath);
|
||||
if (exists(path.join(dir, "Uninstall Frame Control.exe"))) return { method: "nsis" };
|
||||
return { method: "manual", why: "not installed with the installer" };
|
||||
}
|
||||
if (platform === "linux" && env.APPIMAGE) {
|
||||
if (!writable(path.dirname(env.APPIMAGE))) return { method: "manual", why: "the AppImage's folder isn't writable" };
|
||||
return { method: "appimage", appImage: env.APPIMAGE };
|
||||
}
|
||||
return { method: "manual", why: "installed from a package" };
|
||||
}
|
||||
|
||||
function macBundle(execPath) {
|
||||
const i = execPath.indexOf(".app/Contents/MacOS/");
|
||||
return i < 0 ? null : execPath.slice(0, i + 4);
|
||||
}
|
||||
|
||||
function get(url, { headers = {}, timeout = 20000, redirects = 5 } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, { headers: { "user-agent": "FrameControl-updater", ...headers }, timeout }, (res) => {
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && redirects > 0) {
|
||||
res.resume();
|
||||
const next = new URL(res.headers.location, url);
|
||||
if (next.protocol !== "https:") return reject(new Error("refusing a non-HTTPS redirect"));
|
||||
return resolve(get(next.href, { headers, timeout, redirects: redirects - 1 }));
|
||||
}
|
||||
if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode} from ${new URL(url).host}`)); }
|
||||
resolve(res);
|
||||
});
|
||||
req.on("timeout", () => req.destroy(new Error("timed out")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function getJson(url, headers) {
|
||||
const res = await get(url, { headers });
|
||||
let body = "";
|
||||
for await (const chunk of res) body += chunk;
|
||||
return JSON.parse(body);
|
||||
}
|
||||
|
||||
// update.json and the API's release both become { version, notes, page, assets }.
|
||||
function fromManifest(m) {
|
||||
if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed");
|
||||
const base = `https://github.com/${REPO}/releases/download/v${String(m.version).replace(/^v/i, "")}/`;
|
||||
return { version: String(m.version).replace(/^v/i, ""), notes: String(m.notes || "").slice(0, 4000),
|
||||
page: m.page || RELEASES,
|
||||
// Assets always come from this repository's release, whatever the manifest says.
|
||||
assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)),
|
||||
size: a.size, digest: a.digest || null })) };
|
||||
}
|
||||
|
||||
function fromApi(r) {
|
||||
if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release");
|
||||
return { version: String(r.tag_name || "").replace(/^v/i, ""), notes: String(r.body || "").slice(0, 4000),
|
||||
page: r.html_url || RELEASES,
|
||||
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
|
||||
digest: a.digest || null })) };
|
||||
}
|
||||
|
||||
async function latestRelease() {
|
||||
try {
|
||||
return fromManifest(await getJson(MANIFEST));
|
||||
} catch (e) {
|
||||
if (!/HTTP 404/.test(e.message)) throw e; // releases before update.json existed
|
||||
}
|
||||
return fromApi(await getJson(LATEST, { accept: "application/vnd.github+json" }));
|
||||
}
|
||||
|
||||
async function download(asset, dest, onProgress) {
|
||||
const m = /^sha256:([0-9a-f]{64})$/.exec(asset.digest || "");
|
||||
if (!m) throw new Error(`GitHub has no SHA-256 for ${asset.name}, so it can't be checked`);
|
||||
const res = await get(asset.url, { timeout: 60000 });
|
||||
const total = +res.headers["content-length"] || asset.size || 0;
|
||||
const hash = crypto.createHash("sha256");
|
||||
// "wx": a new file only, never through an existing file or symlink at that path.
|
||||
const out = fs.createWriteStream(dest, { mode: 0o755, flags: "wx" });
|
||||
let done = 0;
|
||||
await new Promise((resolve, reject) => {
|
||||
res.on("data", (chunk) => { hash.update(chunk); done += chunk.length; onProgress && onProgress(done, total); });
|
||||
res.on("error", reject);
|
||||
out.on("error", reject);
|
||||
out.on("finish", resolve);
|
||||
res.pipe(out);
|
||||
});
|
||||
if (hash.digest("hex") !== m[1]) {
|
||||
fs.rmSync(dest, { force: true });
|
||||
throw new Error(`${asset.name} didn't match its SHA-256; nothing was changed`);
|
||||
}
|
||||
}
|
||||
|
||||
const run = (cmd, args) => new Promise((resolve, reject) =>
|
||||
execFile(cmd, args, { timeout: 120000 }, (err, stdout, stderr) => err ? reject(new Error((stderr || err.message).trim())) : resolve(stdout)));
|
||||
|
||||
// Waits for this process to exit, swaps the new bundle in (putting the old one
|
||||
// back if that fails), and reopens the app.
|
||||
const MAC_SWAP = `set -u
|
||||
pid="$1"; app="$2"; new="$3"; stage="$4"
|
||||
while kill -0 "$pid" 2>/dev/null; do sleep 0.2; done
|
||||
old="$stage/old.app"
|
||||
if mv "$app" "$old"; then
|
||||
if mv "$new" "$app"; then rm -rf "$old"; else mv "$old" "$app"; fi
|
||||
fi
|
||||
xattr -dr com.apple.quarantine "$app" 2>/dev/null
|
||||
rm -rf "$stage"
|
||||
open "$app"
|
||||
`;
|
||||
|
||||
async function applyMac(release, bundle, onProgress) {
|
||||
const asset = release.assets.find((a) => a.name === assetName("darwin", process.arch, "mac-zip"));
|
||||
if (!asset) throw new Error(`the release has no ${assetName("darwin", process.arch, "mac-zip")}`);
|
||||
// Staged beside the app, so the final move stays on one volume.
|
||||
const stage = fs.mkdtempSync(path.join(path.dirname(bundle), ".frame-control-update-"));
|
||||
try {
|
||||
const zip = path.join(stage, asset.name);
|
||||
await download(asset, zip, onProgress);
|
||||
await run("/usr/bin/ditto", ["-x", "-k", zip, stage]);
|
||||
fs.rmSync(zip, { force: true });
|
||||
const name = fs.readdirSync(stage).find((n) => n.endsWith(".app"));
|
||||
if (!name) throw new Error("the download has no app in it");
|
||||
const fresh = path.join(stage, name);
|
||||
const version = (await run("/usr/bin/plutil", ["-extract", "CFBundleShortVersionString", "raw",
|
||||
path.join(fresh, "Contents", "Info.plist")])).trim();
|
||||
if (version !== release.version) throw new Error(`the download is version ${version}, not ${release.version}`);
|
||||
const script = path.join(stage, "swap.sh");
|
||||
fs.writeFileSync(script, MAC_SWAP);
|
||||
return () => spawn("/bin/sh", [script, String(process.pid), bundle, fresh, stage],
|
||||
{ detached: true, stdio: "ignore" }).unref();
|
||||
} catch (e) {
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyNsis(release, onProgress) {
|
||||
const asset = release.assets.find((a) => a.name === assetName("win32", process.arch, "nsis"));
|
||||
if (!asset) throw new Error(`the release has no ${assetName("win32", process.arch, "nsis")}`);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-control-update-"));
|
||||
const exe = path.join(dir, asset.name);
|
||||
await download(asset, exe, onProgress);
|
||||
// /S: silent, into the existing install. --force-run: open the app afterwards.
|
||||
return () => spawn(exe, ["--updated", "/S", "--force-run"], { detached: true, stdio: "ignore" }).unref();
|
||||
}
|
||||
|
||||
async function applyAppImage(release, appImage, onProgress) {
|
||||
const asset = release.assets.find((a) => a.name === assetName("linux", process.arch, "appimage"));
|
||||
if (!asset) throw new Error(`the release has no ${assetName("linux", process.arch, "appimage")}`);
|
||||
// A private folder beside the AppImage, so the final rename stays on one filesystem.
|
||||
const stage = fs.mkdtempSync(path.join(path.dirname(appImage), ".frame-control-update-"));
|
||||
try {
|
||||
const next = path.join(stage, asset.name);
|
||||
await download(asset, next, onProgress);
|
||||
fs.chmodSync(next, 0o755);
|
||||
fs.renameSync(next, appImage); // the running copy keeps its open file
|
||||
} finally {
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
// Without FUSE the AppImage runs extracted (--appimage-extract-and-run, which isn't passed
|
||||
// on to the app); a FUSE mount lives under /tmp/.mount_*. Keep the same mode on restart.
|
||||
const extracted = process.env.APPIMAGE_EXTRACT_AND_RUN === "1" || !process.execPath.includes("/.mount_");
|
||||
const env = { ...process.env, APPIMAGE: appImage, ...(extracted ? { APPIMAGE_EXTRACT_AND_RUN: "1" } : {}) };
|
||||
// Started only once this process has exited, or the new copy would lose the single-instance lock.
|
||||
return () => spawn("/bin/sh", ["-c", 'while kill -0 "$1" 2>/dev/null; do sleep 0.2; done; exec "$2"',
|
||||
"sh", String(process.pid), appImage], { detached: true, stdio: "ignore", env }).unref();
|
||||
}
|
||||
|
||||
// Downloads and prepares the update; returns a function that starts the swap,
|
||||
// to be called just before the app quits.
|
||||
async function prepare(release, how, onProgress, current) {
|
||||
if (!isNewer(release.version, current)) throw new Error(`${release.version} isn't newer than ${current}`);
|
||||
if (how.method === "mac-zip") return applyMac(release, how.bundle, onProgress);
|
||||
if (how.method === "nsis") return applyNsis(release, onProgress);
|
||||
if (how.method === "appimage") return applyAppImage(release, how.appImage, onProgress);
|
||||
throw new Error(how.why || "this copy can't update itself");
|
||||
}
|
||||
|
||||
function writable(dir) {
|
||||
try { fs.accessSync(dir, fs.constants.W_OK); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
module.exports = { REPO, RELEASES, parseVersion, isNewer, assetName, updateMethod, macBundle, latestRelease,
|
||||
fromManifest, fromApi,
|
||||
download, prepare, writable };
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<!-- Control experiment, run on the Frame itself: how often does Chromium on
|
||||
gamescope put a canvas animation on screen, with no network or decoding
|
||||
involved? Draws every animation frame for ?s= seconds and writes the
|
||||
frame-gap histogram per second into the title, where xprop can read it. -->
|
||||
<html><head><meta charset="utf-8"><title>fmv-present</title></head>
|
||||
<body style="margin:0;background:#000"><canvas id="c" width="1280" height="720" style="width:100%;height:100%"></canvas>
|
||||
<script>
|
||||
const q = new URLSearchParams(location.search), secs = +q.get("s") || 15, tag = q.get("tag") || "";
|
||||
const ctx = document.getElementById("c").getContext("2d", { alpha: false, desynchronized: true });
|
||||
const perSec = []; let t0 = 0, last = 0, n = 0, gaps = [];
|
||||
function frame(t) {
|
||||
if (!t0) t0 = last = t;
|
||||
ctx.fillStyle = "#123"; ctx.fillRect(0, 0, 1280, 720);
|
||||
ctx.fillStyle = "#fff"; ctx.fillRect((n * 21) % 1280, 0, 20, 720); n++;
|
||||
const s = Math.floor((t - t0) / 1000);
|
||||
(perSec[s] = perSec[s] || []).push(t - last); last = t;
|
||||
if (t - t0 < secs * 1000) return requestAnimationFrame(frame);
|
||||
const out = perSec.map(g => g.length).join(",");
|
||||
document.title = `[${tag}] done fps/s=${out}`;
|
||||
}
|
||||
document.title = `[${tag}] running`;
|
||||
requestAnimationFrame(frame);
|
||||
</script></body></html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<!-- Benchmark content: a long page of text that scrolls itself at a steady
|
||||
speed, so every frame changes the way reading a real page does. -->
|
||||
<html lang="en"><head><meta charset="utf-8"><title>fmv-bench scroll</title>
|
||||
<style>
|
||||
body { font: 17px/1.55 -apple-system, "Helvetica Neue", sans-serif; margin: 0 auto; max-width: 900px; padding: 24px; color: #1d1d1f; background: #fff; }
|
||||
h2 { font-size: 22px; margin: 28px 0 8px; } code { background: #f2f2f5; padding: 1px 4px; border-radius: 3px; }
|
||||
</style></head><body><div id="doc"></div>
|
||||
<script>
|
||||
const words = "latency frame panel stream encoder decoder network display pixel window capture budget quality motion text sharp adaptive controller queue socket clock".split(" ");
|
||||
let seed = 7; const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
|
||||
let html = "";
|
||||
for (let s = 0; s < 120; s++) {
|
||||
html += `<h2>Section ${s + 1}: ${words[s % words.length]} and ${words[(s * 7) % words.length]}</h2>`;
|
||||
for (let p = 0; p < 4; p++) {
|
||||
let para = [];
|
||||
for (let w = 0; w < 70; w++) para.push(rnd() < 0.05 ? `<code>${words[Math.floor(rnd() * words.length)]}()</code>` : words[Math.floor(rnd() * words.length)]);
|
||||
html += `<p>${para.join(" ")}.</p>`;
|
||||
}
|
||||
}
|
||||
document.getElementById("doc").innerHTML = html;
|
||||
// 240 points a second, whatever the display's refresh rate.
|
||||
// The title carries the page's own frame rate, so the source's rate is known.
|
||||
let last = performance.now(), frames = 0, since = last;
|
||||
function step(now) {
|
||||
const dy = (now - last) * 0.24; last = now;
|
||||
if (++frames && now - since >= 1000) { document.title = `fmv-bench scroll ${frames} fps`; frames = 0; since = now; }
|
||||
if (window.scrollY + innerHeight >= document.body.scrollHeight - 2) window.scrollTo(0, 0); else window.scrollBy(0, dy);
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
</script></body></html>
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<!-- Benchmark content: a plain text editor, focused, for typing tests. -->
|
||||
<html lang="en"><head><meta charset="utf-8"><title>fmv-bench type</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #fff; }
|
||||
textarea { box-sizing: border-box; width: 100%; height: 100%; border: 0; padding: 20px; outline: none; resize: none;
|
||||
font: 20px/1.5 ui-monospace, Menlo, monospace; color: #111; caret-color: transparent; } /* a blinking caret would pass for a reply to a key */
|
||||
</style></head><body><textarea id="t" autofocus spellcheck="false"></textarea>
|
||||
<script>document.getElementById("t").focus();</script></body></html>
|
||||
@@ -0,0 +1,677 @@
|
||||
{
|
||||
"label": "usb1",
|
||||
"date": "2026-09-28T21:16:03",
|
||||
"commit": "1d05f57",
|
||||
"config": {
|
||||
"quality": "balanced",
|
||||
"mode": "separate",
|
||||
"net": "none",
|
||||
"delay_ms": 0,
|
||||
"buffer_ms": 250,
|
||||
"host": "10.86.200.233",
|
||||
"ssh_opts": [],
|
||||
"encoder": "",
|
||||
"duration_s": 15.0,
|
||||
"browser_flags": []
|
||||
},
|
||||
"frame_build": "20260925.6191901",
|
||||
"mac": "26.5.2",
|
||||
"headset": "",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "test",
|
||||
"frames_sent": 911,
|
||||
"frames_drawn": 911,
|
||||
"frames_shown": 610,
|
||||
"duration_s": 15.2,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": 0.0,
|
||||
"p95": 0.0,
|
||||
"p99": 0.0,
|
||||
"n": 911
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.0,
|
||||
"p95": 0.1,
|
||||
"p99": 0.1,
|
||||
"n": 911
|
||||
},
|
||||
"encode": {
|
||||
"p50": 4.4,
|
||||
"p95": 5.0,
|
||||
"p99": 5.2,
|
||||
"n": 911
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.2,
|
||||
"n": 911
|
||||
},
|
||||
"network": {
|
||||
"p50": 1.1,
|
||||
"p95": 1.6,
|
||||
"p99": 2.1,
|
||||
"n": 911
|
||||
},
|
||||
"decode": {
|
||||
"p50": 1.2,
|
||||
"p95": 2.6,
|
||||
"p99": 3.9,
|
||||
"n": 911
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.4,
|
||||
"p95": 0.9,
|
||||
"p99": 1.5,
|
||||
"n": 911
|
||||
},
|
||||
"present": {
|
||||
"p50": 5.8,
|
||||
"p95": 13.6,
|
||||
"p99": 16.7,
|
||||
"n": 610
|
||||
},
|
||||
"content": {
|
||||
"p50": 7.3,
|
||||
"p95": 8.8,
|
||||
"p99": 10.0,
|
||||
"n": 911
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 13.6,
|
||||
"p95": 20.4,
|
||||
"p99": 24.9,
|
||||
"n": 610
|
||||
}
|
||||
},
|
||||
"fps": 59.9,
|
||||
"fps_shown": 40.1,
|
||||
"late_pct": 0.22,
|
||||
"stall_max": 32.3,
|
||||
"stalls_over_100ms": 0,
|
||||
"mbps": 0.48,
|
||||
"keyframes": 1,
|
||||
"size": "1280x720",
|
||||
"captured": 912,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 16.6,
|
||||
"p95": 24.3,
|
||||
"p99": 24.5,
|
||||
"n": 30
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 20.8,
|
||||
"p95": 31.8,
|
||||
"p99": 33.8,
|
||||
"n": 22
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 0.7,
|
||||
"p95": 1.0,
|
||||
"p99": 1.0,
|
||||
"n": 30
|
||||
},
|
||||
"mac": {
|
||||
"p50": 9.1,
|
||||
"p95": 15.0,
|
||||
"p99": 15.2,
|
||||
"n": 30
|
||||
},
|
||||
"back": {
|
||||
"p50": 7.2,
|
||||
"p95": 8.5,
|
||||
"p99": 12.7,
|
||||
"n": 30
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 30,
|
||||
"sent": 30,
|
||||
"seen": 30
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 60,
|
||||
"mbps": 0.5,
|
||||
"content_p50": 7.3,
|
||||
"content_p95": 8.6,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 60,
|
||||
"mbps": 0.5,
|
||||
"content_p50": 7.1,
|
||||
"content_p95": 8.9,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 59,
|
||||
"mbps": 0.48,
|
||||
"content_p50": 7.2,
|
||||
"content_p95": 8.6,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 60,
|
||||
"mbps": 0.49,
|
||||
"content_p50": 7.1,
|
||||
"content_p95": 9.0,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 60,
|
||||
"mbps": 0.49,
|
||||
"content_p50": 6.9,
|
||||
"content_p95": 8.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 60,
|
||||
"mbps": 0.58,
|
||||
"content_p50": 7.3,
|
||||
"content_p95": 8.1,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 7.4,
|
||||
"content_p95": 8.6,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 60,
|
||||
"mbps": 0.46,
|
||||
"content_p50": 7.4,
|
||||
"content_p95": 8.8,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 7.3,
|
||||
"content_p95": 8.9,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 60,
|
||||
"mbps": 0.46,
|
||||
"content_p50": 7.3,
|
||||
"content_p95": 8.8,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 60,
|
||||
"mbps": 0.45,
|
||||
"content_p50": 7.3,
|
||||
"content_p95": 8.7,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 11,
|
||||
"fps": 60,
|
||||
"mbps": 0.45,
|
||||
"content_p50": 7.4,
|
||||
"content_p95": 9.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 12,
|
||||
"fps": 60,
|
||||
"mbps": 0.46,
|
||||
"content_p50": 7.4,
|
||||
"content_p95": 8.4,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 13,
|
||||
"fps": 60,
|
||||
"mbps": 0.46,
|
||||
"content_p50": 7.6,
|
||||
"content_p95": 9.7,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 14,
|
||||
"fps": 60,
|
||||
"mbps": 0.46,
|
||||
"content_p50": 7.6,
|
||||
"content_p95": 8.5,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 15,
|
||||
"fps": 12,
|
||||
"mbps": 0.09,
|
||||
"content_p50": 7.5,
|
||||
"content_p95": 9.6,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 7.3,
|
||||
"content_p95": 8.8,
|
||||
"input_p50": 16.6,
|
||||
"input_p95": 24.3,
|
||||
"grades": {
|
||||
"input_replies": "local",
|
||||
"content_p50": "local",
|
||||
"content_p95": "local",
|
||||
"input_p50": "local",
|
||||
"input_p95": "local",
|
||||
"fps": "local",
|
||||
"late_pct": "local",
|
||||
"stall_max": "local"
|
||||
},
|
||||
"controller": {
|
||||
"adapt": true,
|
||||
"baseRtt": 0.917,
|
||||
"ceiling": 5529600,
|
||||
"fps": 60,
|
||||
"inFlight": 1,
|
||||
"scale": 1,
|
||||
"slack": 41.666,
|
||||
"target": 5529600,
|
||||
"tier": 0
|
||||
},
|
||||
"events": [],
|
||||
"source_fps": 60.0,
|
||||
"cpu": {
|
||||
"mac_agent_pct": 16.7,
|
||||
"frame_viewer_pct": 48.5,
|
||||
"frame_tailscaled_pct": 0.5,
|
||||
"frame_sshd_pct": 1.1,
|
||||
"frame_gamescope_pct": 12.0,
|
||||
"frame_total_pct": 39.3
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 137 Hz",
|
||||
"show_s": 1.15,
|
||||
"panel": "valve.steam.desktopgame.2001639889",
|
||||
"src": "test"
|
||||
},
|
||||
{
|
||||
"scenario": "scroll",
|
||||
"frames_sent": 848,
|
||||
"frames_drawn": 848,
|
||||
"frames_shown": 617,
|
||||
"duration_s": 15.1,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": -3.9,
|
||||
"p95": 4.2,
|
||||
"p99": 7.3,
|
||||
"n": 848
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.0,
|
||||
"p95": 0.1,
|
||||
"p99": 0.1,
|
||||
"n": 848
|
||||
},
|
||||
"encode": {
|
||||
"p50": 6.7,
|
||||
"p95": 9.7,
|
||||
"p99": 17.7,
|
||||
"n": 848
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.4,
|
||||
"n": 848
|
||||
},
|
||||
"network": {
|
||||
"p50": 1.7,
|
||||
"p95": 2.7,
|
||||
"p99": 5.7,
|
||||
"n": 848
|
||||
},
|
||||
"decode": {
|
||||
"p50": 5.9,
|
||||
"p95": 10.3,
|
||||
"p99": 13.1,
|
||||
"n": 848
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.7,
|
||||
"p95": 1.6,
|
||||
"p99": 2.2,
|
||||
"n": 848
|
||||
},
|
||||
"present": {
|
||||
"p50": 4.4,
|
||||
"p95": 15.8,
|
||||
"p99": 20.6,
|
||||
"n": 617
|
||||
},
|
||||
"content": {
|
||||
"p50": 15.7,
|
||||
"p95": 23.0,
|
||||
"p99": 39.3,
|
||||
"n": 848
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 21.3,
|
||||
"p95": 34.1,
|
||||
"p99": 45.6,
|
||||
"n": 617
|
||||
}
|
||||
},
|
||||
"fps": 56.1,
|
||||
"fps_shown": 40.7,
|
||||
"late_pct": 3.3,
|
||||
"stall_max": 238.5,
|
||||
"stalls_over_100ms": 1,
|
||||
"mbps": 9.01,
|
||||
"keyframes": 1,
|
||||
"size": "1920x1290",
|
||||
"captured": 857,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"n": 0
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"n": 0
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"n": 0
|
||||
},
|
||||
"mac": {
|
||||
"n": 0
|
||||
},
|
||||
"back": {
|
||||
"n": 0
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 0,
|
||||
"sent": 0,
|
||||
"seen": 0
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 44,
|
||||
"mbps": 8.13,
|
||||
"content_p50": 15.7,
|
||||
"content_p95": 35.7,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 56,
|
||||
"mbps": 7.04,
|
||||
"content_p50": 16.3,
|
||||
"content_p95": 47.3,
|
||||
"bitrate": 7430400,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 57,
|
||||
"mbps": 8.58,
|
||||
"content_p50": 16.0,
|
||||
"content_p95": 22.6,
|
||||
"bitrate": 10055362,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 56,
|
||||
"mbps": 9.29,
|
||||
"content_p50": 16.4,
|
||||
"content_p95": 21.1,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 58,
|
||||
"mbps": 9.01,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 21.7,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 57,
|
||||
"mbps": 9.13,
|
||||
"content_p50": 15.9,
|
||||
"content_p95": 21.7,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 56,
|
||||
"mbps": 11.63,
|
||||
"content_p50": 16.1,
|
||||
"content_p95": 22.1,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 57,
|
||||
"mbps": 8.7,
|
||||
"content_p50": 16.3,
|
||||
"content_p95": 22.2,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 57,
|
||||
"mbps": 9.24,
|
||||
"content_p50": 15.7,
|
||||
"content_p95": 21.2,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 57,
|
||||
"mbps": 9.21,
|
||||
"content_p50": 15.0,
|
||||
"content_p95": 20.5,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 56,
|
||||
"mbps": 8.55,
|
||||
"content_p50": 15.7,
|
||||
"content_p95": 20.6,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 11,
|
||||
"fps": 58,
|
||||
"mbps": 9.51,
|
||||
"content_p50": 14.7,
|
||||
"content_p95": 19.9,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 12,
|
||||
"fps": 57,
|
||||
"mbps": 8.75,
|
||||
"content_p50": 16.0,
|
||||
"content_p95": 20.4,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 13,
|
||||
"fps": 58,
|
||||
"mbps": 9.25,
|
||||
"content_p50": 15.0,
|
||||
"content_p95": 20.7,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 14,
|
||||
"fps": 57,
|
||||
"mbps": 9.13,
|
||||
"content_p50": 15.3,
|
||||
"content_p95": 21.0,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 15,
|
||||
"fps": 7,
|
||||
"mbps": 1.17,
|
||||
"content_p50": 14.2,
|
||||
"content_p95": 15.3,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 15.7,
|
||||
"content_p95": 23.0,
|
||||
"input_p50": null,
|
||||
"input_p95": null,
|
||||
"grades": {
|
||||
"content_p50": "local",
|
||||
"content_p95": "local",
|
||||
"fps": "acceptable",
|
||||
"late_pct": "acceptable",
|
||||
"stall_max": "acceptable"
|
||||
},
|
||||
"controller": {
|
||||
"adapt": true,
|
||||
"baseRtt": 1.25,
|
||||
"ceiling": 14860800,
|
||||
"fps": 60,
|
||||
"inFlight": 0,
|
||||
"scale": 1,
|
||||
"slack": 41.666,
|
||||
"target": 14860800,
|
||||
"tier": 0
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"t": 1.06,
|
||||
"e": "down to 7430 kbit/s: queue 35 ms, held 4, oldest 32 ms, base 1 ms, sent 6282 got 4914 wants 11892"
|
||||
}
|
||||
],
|
||||
"source_fps": 56.8,
|
||||
"cpu": {
|
||||
"mac_agent_pct": 8.8,
|
||||
"frame_viewer_pct": 74.8,
|
||||
"frame_tailscaled_pct": 0.3,
|
||||
"frame_sshd_pct": 1.2,
|
||||
"frame_gamescope_pct": 10.5,
|
||||
"frame_total_pct": 39.0
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 137 Hz",
|
||||
"show_s": 1.16,
|
||||
"panel": "valve.steam.desktopgame.2001968301",
|
||||
"src": "separate:7914"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
{
|
||||
"label": "usb2",
|
||||
"date": "2026-09-28T21:17:48",
|
||||
"commit": "1d05f57",
|
||||
"config": {
|
||||
"quality": "balanced",
|
||||
"mode": "separate",
|
||||
"net": "none",
|
||||
"delay_ms": 0,
|
||||
"buffer_ms": 250,
|
||||
"host": "10.86.200.233",
|
||||
"ssh_opts": [],
|
||||
"encoder": "",
|
||||
"duration_s": 15.0,
|
||||
"browser_flags": []
|
||||
},
|
||||
"frame_build": "20260925.6191901",
|
||||
"mac": "26.5.2",
|
||||
"headset": "",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "test",
|
||||
"frames_sent": 913,
|
||||
"frames_drawn": 913,
|
||||
"frames_shown": 856,
|
||||
"duration_s": 15.2,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": 0.0,
|
||||
"p95": 0.0,
|
||||
"p99": 0.0,
|
||||
"n": 913
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.0,
|
||||
"p95": 0.1,
|
||||
"p99": 0.1,
|
||||
"n": 913
|
||||
},
|
||||
"encode": {
|
||||
"p50": 4.3,
|
||||
"p95": 4.9,
|
||||
"p99": 5.2,
|
||||
"n": 913
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.2,
|
||||
"n": 913
|
||||
},
|
||||
"network": {
|
||||
"p50": 0.9,
|
||||
"p95": 1.2,
|
||||
"p99": 1.7,
|
||||
"n": 913
|
||||
},
|
||||
"decode": {
|
||||
"p50": 1.1,
|
||||
"p95": 2.5,
|
||||
"p99": 3.8,
|
||||
"n": 913
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.3,
|
||||
"p95": 0.8,
|
||||
"p99": 1.2,
|
||||
"n": 913
|
||||
},
|
||||
"present": {
|
||||
"p50": 2.6,
|
||||
"p95": 8.6,
|
||||
"p99": 15.9,
|
||||
"n": 856
|
||||
},
|
||||
"content": {
|
||||
"p50": 6.9,
|
||||
"p95": 8.4,
|
||||
"p99": 9.6,
|
||||
"n": 913
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 9.7,
|
||||
"p95": 15.8,
|
||||
"p99": 23.1,
|
||||
"n": 856
|
||||
}
|
||||
},
|
||||
"fps": 60.0,
|
||||
"fps_shown": 56.3,
|
||||
"late_pct": 0.0,
|
||||
"stall_max": 22.0,
|
||||
"stalls_over_100ms": 0,
|
||||
"mbps": 0.57,
|
||||
"keyframes": 1,
|
||||
"size": "1280x720",
|
||||
"captured": 913,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 16.9,
|
||||
"p95": 24.1,
|
||||
"p99": 26.1,
|
||||
"n": 33
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 19.7,
|
||||
"p95": 26.6,
|
||||
"p99": 29.2,
|
||||
"n": 31
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 0.7,
|
||||
"p95": 2.5,
|
||||
"p99": 5.7,
|
||||
"n": 33
|
||||
},
|
||||
"mac": {
|
||||
"p50": 8.1,
|
||||
"p95": 15.4,
|
||||
"p99": 17.2,
|
||||
"n": 33
|
||||
},
|
||||
"back": {
|
||||
"p50": 7.3,
|
||||
"p95": 8.3,
|
||||
"p99": 8.9,
|
||||
"n": 33
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 30,
|
||||
"sent": 33,
|
||||
"seen": 33
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 60,
|
||||
"mbps": 0.49,
|
||||
"content_p50": 7.1,
|
||||
"content_p95": 8.7,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 7.0,
|
||||
"content_p95": 8.3,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 60,
|
||||
"mbps": 0.48,
|
||||
"content_p50": 6.9,
|
||||
"content_p95": 8.4,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 60,
|
||||
"mbps": 0.48,
|
||||
"content_p50": 7.0,
|
||||
"content_p95": 9.1,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 60,
|
||||
"mbps": 0.5,
|
||||
"content_p50": 6.8,
|
||||
"content_p95": 8.5,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 60,
|
||||
"mbps": 0.67,
|
||||
"content_p50": 7.0,
|
||||
"content_p95": 8.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 60,
|
||||
"mbps": 0.59,
|
||||
"content_p50": 7.0,
|
||||
"content_p95": 8.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 60,
|
||||
"mbps": 0.63,
|
||||
"content_p50": 6.7,
|
||||
"content_p95": 7.5,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 60,
|
||||
"mbps": 0.62,
|
||||
"content_p50": 6.9,
|
||||
"content_p95": 8.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 60,
|
||||
"mbps": 0.64,
|
||||
"content_p50": 6.8,
|
||||
"content_p95": 8.0,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 60,
|
||||
"mbps": 0.67,
|
||||
"content_p50": 6.8,
|
||||
"content_p95": 8.3,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 11,
|
||||
"fps": 60,
|
||||
"mbps": 0.62,
|
||||
"content_p50": 6.7,
|
||||
"content_p95": 7.9,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 12,
|
||||
"fps": 60,
|
||||
"mbps": 0.59,
|
||||
"content_p50": 6.8,
|
||||
"content_p95": 7.9,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 13,
|
||||
"fps": 60,
|
||||
"mbps": 0.57,
|
||||
"content_p50": 6.7,
|
||||
"content_p95": 8.3,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 14,
|
||||
"fps": 60,
|
||||
"mbps": 0.58,
|
||||
"content_p50": 7.0,
|
||||
"content_p95": 8.7,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 15,
|
||||
"fps": 13,
|
||||
"mbps": 0.13,
|
||||
"content_p50": 6.4,
|
||||
"content_p95": 7.3,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 6.9,
|
||||
"content_p95": 8.4,
|
||||
"input_p50": 16.9,
|
||||
"input_p95": 24.1,
|
||||
"grades": {
|
||||
"input_replies": "local",
|
||||
"content_p50": "local",
|
||||
"content_p95": "local",
|
||||
"input_p50": "local",
|
||||
"input_p95": "local",
|
||||
"fps": "local",
|
||||
"late_pct": "local",
|
||||
"stall_max": "local"
|
||||
},
|
||||
"controller": {
|
||||
"adapt": true,
|
||||
"baseRtt": 0.709,
|
||||
"ceiling": 5529600,
|
||||
"fps": 60,
|
||||
"inFlight": 0,
|
||||
"scale": 1,
|
||||
"slack": 41.666,
|
||||
"target": 5529600,
|
||||
"tier": 0
|
||||
},
|
||||
"events": [],
|
||||
"source_fps": 60.1,
|
||||
"cpu": {
|
||||
"mac_agent_pct": 17.9,
|
||||
"frame_viewer_pct": 51.9,
|
||||
"frame_tailscaled_pct": 0.2,
|
||||
"frame_sshd_pct": 1.1,
|
||||
"frame_gamescope_pct": 9.6,
|
||||
"frame_total_pct": 27.9
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 134 Hz",
|
||||
"show_s": 1.15,
|
||||
"panel": "valve.steam.desktopgame.2001639889",
|
||||
"src": "test"
|
||||
},
|
||||
{
|
||||
"scenario": "scroll",
|
||||
"frames_sent": 868,
|
||||
"frames_drawn": 868,
|
||||
"frames_shown": 868,
|
||||
"duration_s": 15.2,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": -4.2,
|
||||
"p95": 0.5,
|
||||
"p99": 6.7,
|
||||
"n": 868
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.0,
|
||||
"p95": 0.1,
|
||||
"p99": 0.2,
|
||||
"n": 868
|
||||
},
|
||||
"encode": {
|
||||
"p50": 6.8,
|
||||
"p95": 10.2,
|
||||
"p99": 17.9,
|
||||
"n": 868
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.4,
|
||||
"n": 868
|
||||
},
|
||||
"network": {
|
||||
"p50": 1.5,
|
||||
"p95": 2.3,
|
||||
"p99": 7.6,
|
||||
"n": 868
|
||||
},
|
||||
"decode": {
|
||||
"p50": 5.9,
|
||||
"p95": 10.2,
|
||||
"p99": 20.3,
|
||||
"n": 868
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.7,
|
||||
"p95": 1.4,
|
||||
"p99": 2.1,
|
||||
"n": 868
|
||||
},
|
||||
"present": {
|
||||
"p50": 0.9,
|
||||
"p95": 6.1,
|
||||
"p99": 6.8,
|
||||
"n": 868
|
||||
},
|
||||
"content": {
|
||||
"p50": 15.3,
|
||||
"p95": 23.5,
|
||||
"p99": 53.1,
|
||||
"n": 868
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 17.7,
|
||||
"p95": 25.6,
|
||||
"p99": 57.5,
|
||||
"n": 868
|
||||
}
|
||||
},
|
||||
"fps": 57.2,
|
||||
"fps_shown": 57.2,
|
||||
"late_pct": 3.23,
|
||||
"stall_max": 108.0,
|
||||
"stalls_over_100ms": 1,
|
||||
"mbps": 9.84,
|
||||
"keyframes": 1,
|
||||
"size": "1920x1290",
|
||||
"captured": 870,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 24.2,
|
||||
"p95": 36.3,
|
||||
"p99": 36.3,
|
||||
"n": 6
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 24.8,
|
||||
"p95": 38.5,
|
||||
"p99": 38.5,
|
||||
"n": 6
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 2.0,
|
||||
"p95": 5.4,
|
||||
"p99": 5.4,
|
||||
"n": 6
|
||||
},
|
||||
"mac": {
|
||||
"p50": 7.5,
|
||||
"p95": 15.8,
|
||||
"p99": 15.8,
|
||||
"n": 6
|
||||
},
|
||||
"back": {
|
||||
"p50": 15.3,
|
||||
"p95": 16.1,
|
||||
"p99": 16.1,
|
||||
"n": 6
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 0,
|
||||
"sent": 6,
|
||||
"seen": 6
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 53,
|
||||
"mbps": 9.21,
|
||||
"content_p50": 16.1,
|
||||
"content_p95": 71.2,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 57,
|
||||
"mbps": 9.64,
|
||||
"content_p50": 15.9,
|
||||
"content_p95": 46.8,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 58,
|
||||
"mbps": 8.99,
|
||||
"content_p50": 14.8,
|
||||
"content_p95": 19.2,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 57,
|
||||
"mbps": 9.72,
|
||||
"content_p50": 15.2,
|
||||
"content_p95": 22.3,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 58,
|
||||
"mbps": 9.64,
|
||||
"content_p50": 16.1,
|
||||
"content_p95": 23.5,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 57,
|
||||
"mbps": 11.27,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 20.9,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 58,
|
||||
"mbps": 10.44,
|
||||
"content_p50": 15.1,
|
||||
"content_p95": 19.1,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 57,
|
||||
"mbps": 9.31,
|
||||
"content_p50": 15.0,
|
||||
"content_p95": 19.9,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 58,
|
||||
"mbps": 11.69,
|
||||
"content_p50": 15.9,
|
||||
"content_p95": 22.0,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 58,
|
||||
"mbps": 10.31,
|
||||
"content_p50": 14.5,
|
||||
"content_p95": 20.7,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 57,
|
||||
"mbps": 10.47,
|
||||
"content_p50": 15.2,
|
||||
"content_p95": 20.6,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 11,
|
||||
"fps": 58,
|
||||
"mbps": 9.44,
|
||||
"content_p50": 15.3,
|
||||
"content_p95": 20.5,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 12,
|
||||
"fps": 57,
|
||||
"mbps": 9.17,
|
||||
"content_p50": 15.9,
|
||||
"content_p95": 18.7,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 13,
|
||||
"fps": 58,
|
||||
"mbps": 8.77,
|
||||
"content_p50": 15.1,
|
||||
"content_p95": 18.6,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 14,
|
||||
"fps": 58,
|
||||
"mbps": 9.72,
|
||||
"content_p50": 15.2,
|
||||
"content_p95": 19.8,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 15,
|
||||
"fps": 9,
|
||||
"mbps": 1.33,
|
||||
"content_p50": 14.8,
|
||||
"content_p95": 17.7,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 15.3,
|
||||
"content_p95": 23.5,
|
||||
"input_p50": 24.2,
|
||||
"input_p95": 36.3,
|
||||
"grades": {
|
||||
"input_replies": "local",
|
||||
"content_p50": "local",
|
||||
"content_p95": "local",
|
||||
"input_p50": "local",
|
||||
"input_p95": "local",
|
||||
"fps": "acceptable",
|
||||
"late_pct": "acceptable",
|
||||
"stall_max": "acceptable"
|
||||
},
|
||||
"controller": {
|
||||
"adapt": true,
|
||||
"baseRtt": 1.375,
|
||||
"ceiling": 14860800,
|
||||
"fps": 60,
|
||||
"inFlight": 1,
|
||||
"scale": 1,
|
||||
"slack": 41.666,
|
||||
"target": 14860800,
|
||||
"tier": 0
|
||||
},
|
||||
"events": [],
|
||||
"source_fps": 57.2,
|
||||
"cpu": {
|
||||
"mac_agent_pct": 11.4,
|
||||
"frame_viewer_pct": 83.5,
|
||||
"frame_tailscaled_pct": 0.3,
|
||||
"frame_sshd_pct": 1.5,
|
||||
"frame_gamescope_pct": 9.7,
|
||||
"frame_total_pct": 32.5
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
|
||||
"show_s": 1.15,
|
||||
"panel": "valve.steam.desktopgame.2001786096",
|
||||
"src": "separate:8187"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
{
|
||||
"label": "wifi1",
|
||||
"date": "2026-09-28T21:16:56",
|
||||
"commit": "1d05f57",
|
||||
"config": {
|
||||
"quality": "balanced",
|
||||
"mode": "separate",
|
||||
"net": "none",
|
||||
"delay_ms": 0,
|
||||
"buffer_ms": 250,
|
||||
"host": "frame",
|
||||
"ssh_opts": [],
|
||||
"encoder": "",
|
||||
"duration_s": 15.0,
|
||||
"browser_flags": []
|
||||
},
|
||||
"frame_build": "20260925.6191901",
|
||||
"mac": "26.5.2",
|
||||
"headset": "",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "test",
|
||||
"frames_sent": 912,
|
||||
"frames_drawn": 912,
|
||||
"frames_shown": 611,
|
||||
"duration_s": 15.2,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": 0.0,
|
||||
"p95": 0.0,
|
||||
"p99": 0.0,
|
||||
"n": 912
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.0,
|
||||
"p95": 0.1,
|
||||
"p99": 0.1,
|
||||
"n": 912
|
||||
},
|
||||
"encode": {
|
||||
"p50": 4.3,
|
||||
"p95": 4.9,
|
||||
"p99": 5.1,
|
||||
"n": 912
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.2,
|
||||
"n": 912
|
||||
},
|
||||
"network": {
|
||||
"p50": 4.0,
|
||||
"p95": 5.3,
|
||||
"p99": 7.8,
|
||||
"n": 912
|
||||
},
|
||||
"decode": {
|
||||
"p50": 1.1,
|
||||
"p95": 2.8,
|
||||
"p99": 4.0,
|
||||
"n": 912
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.4,
|
||||
"p95": 0.8,
|
||||
"p99": 1.2,
|
||||
"n": 912
|
||||
},
|
||||
"present": {
|
||||
"p50": 5.9,
|
||||
"p95": 14.0,
|
||||
"p99": 18.5,
|
||||
"n": 611
|
||||
},
|
||||
"content": {
|
||||
"p50": 10.1,
|
||||
"p95": 12.3,
|
||||
"p99": 13.9,
|
||||
"n": 912
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 16.7,
|
||||
"p95": 23.6,
|
||||
"p99": 28.3,
|
||||
"n": 611
|
||||
}
|
||||
},
|
||||
"fps": 60.0,
|
||||
"fps_shown": 40.1,
|
||||
"late_pct": 0.11,
|
||||
"stall_max": 25.8,
|
||||
"stalls_over_100ms": 0,
|
||||
"mbps": 0.48,
|
||||
"keyframes": 1,
|
||||
"size": "1280x720",
|
||||
"captured": 912,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 26.4,
|
||||
"p95": 34.2,
|
||||
"p99": 53.5,
|
||||
"n": 30
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 28.6,
|
||||
"p95": 41.5,
|
||||
"p99": 55.9,
|
||||
"n": 18
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 8.9,
|
||||
"p95": 20.4,
|
||||
"p99": 28.0,
|
||||
"n": 30
|
||||
},
|
||||
"mac": {
|
||||
"p50": 5.1,
|
||||
"p95": 14.5,
|
||||
"p99": 16.2,
|
||||
"n": 30
|
||||
},
|
||||
"back": {
|
||||
"p50": 10.1,
|
||||
"p95": 13.7,
|
||||
"p99": 13.8,
|
||||
"n": 30
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 30,
|
||||
"sent": 30,
|
||||
"seen": 30
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 60,
|
||||
"mbps": 0.51,
|
||||
"content_p50": 10.3,
|
||||
"content_p95": 11.9,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 10.5,
|
||||
"content_p95": 12.1,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 60,
|
||||
"mbps": 0.49,
|
||||
"content_p50": 10.1,
|
||||
"content_p95": 12.8,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 60,
|
||||
"mbps": 0.48,
|
||||
"content_p50": 9.9,
|
||||
"content_p95": 11.6,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 9.7,
|
||||
"content_p95": 11.8,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 60,
|
||||
"mbps": 0.59,
|
||||
"content_p50": 10.2,
|
||||
"content_p95": 11.7,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 10.2,
|
||||
"content_p95": 12.3,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 9.7,
|
||||
"content_p95": 12.1,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 10.4,
|
||||
"content_p95": 13.1,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 9.9,
|
||||
"content_p95": 12.7,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 9.8,
|
||||
"content_p95": 12.4,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 11,
|
||||
"fps": 60,
|
||||
"mbps": 0.46,
|
||||
"content_p50": 10.3,
|
||||
"content_p95": 12.7,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 12,
|
||||
"fps": 60,
|
||||
"mbps": 0.49,
|
||||
"content_p50": 10.2,
|
||||
"content_p95": 12.0,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 13,
|
||||
"fps": 60,
|
||||
"mbps": 0.48,
|
||||
"content_p50": 10.0,
|
||||
"content_p95": 11.3,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 14,
|
||||
"fps": 60,
|
||||
"mbps": 0.46,
|
||||
"content_p50": 10.2,
|
||||
"content_p95": 12.3,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 15,
|
||||
"fps": 12,
|
||||
"mbps": 0.1,
|
||||
"content_p50": 10.7,
|
||||
"content_p95": 11.4,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 10.1,
|
||||
"content_p95": 12.3,
|
||||
"input_p50": 26.4,
|
||||
"input_p95": 34.2,
|
||||
"grades": {
|
||||
"input_replies": "local",
|
||||
"content_p50": "local",
|
||||
"content_p95": "local",
|
||||
"input_p50": "local",
|
||||
"input_p95": "local",
|
||||
"fps": "local",
|
||||
"late_pct": "local",
|
||||
"stall_max": "local"
|
||||
},
|
||||
"controller": {
|
||||
"adapt": true,
|
||||
"baseRtt": 4.958,
|
||||
"ceiling": 5529600,
|
||||
"fps": 60,
|
||||
"inFlight": 1,
|
||||
"scale": 1,
|
||||
"slack": 47.417,
|
||||
"target": 2764800,
|
||||
"tier": 0
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"t": 16.58,
|
||||
"e": "down to 2764 kbit/s: queue 19 ms, held 6, oldest 210 ms, base 4 ms, sent 322 got 265 wants 459"
|
||||
}
|
||||
],
|
||||
"source_fps": 60.0,
|
||||
"cpu": {
|
||||
"mac_agent_pct": 15.7,
|
||||
"frame_viewer_pct": 42.5,
|
||||
"frame_tailscaled_pct": 5.5,
|
||||
"frame_sshd_pct": 0.7,
|
||||
"frame_gamescope_pct": 8.7,
|
||||
"frame_total_pct": 26.1
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 136 Hz",
|
||||
"show_s": 1.26,
|
||||
"panel": "valve.steam.desktopgame.2001639889",
|
||||
"src": "test"
|
||||
},
|
||||
{
|
||||
"scenario": "scroll",
|
||||
"frames_sent": 852,
|
||||
"frames_drawn": 852,
|
||||
"frames_shown": 614,
|
||||
"duration_s": 15.2,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": -4.4,
|
||||
"p95": 2.6,
|
||||
"p99": 4.2,
|
||||
"n": 852
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.0,
|
||||
"p95": 0.1,
|
||||
"p99": 0.1,
|
||||
"n": 852
|
||||
},
|
||||
"encode": {
|
||||
"p50": 6.7,
|
||||
"p95": 9.8,
|
||||
"p99": 16.7,
|
||||
"n": 852
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.2,
|
||||
"n": 852
|
||||
},
|
||||
"network": {
|
||||
"p50": 5.3,
|
||||
"p95": 13.1,
|
||||
"p99": 28.2,
|
||||
"n": 852
|
||||
},
|
||||
"decode": {
|
||||
"p50": 6.0,
|
||||
"p95": 10.9,
|
||||
"p99": 19.0,
|
||||
"n": 852
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.7,
|
||||
"p95": 1.4,
|
||||
"p99": 1.8,
|
||||
"n": 852
|
||||
},
|
||||
"present": {
|
||||
"p50": 4.1,
|
||||
"p95": 16.2,
|
||||
"p99": 20.6,
|
||||
"n": 614
|
||||
},
|
||||
"content": {
|
||||
"p50": 19.5,
|
||||
"p95": 31.4,
|
||||
"p99": 64.5,
|
||||
"n": 852
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 25.4,
|
||||
"p95": 38.8,
|
||||
"p99": 76.7,
|
||||
"n": 614
|
||||
}
|
||||
},
|
||||
"fps": 56.2,
|
||||
"fps_shown": 40.5,
|
||||
"late_pct": 4.69,
|
||||
"stall_max": 253.7,
|
||||
"stalls_over_100ms": 2,
|
||||
"mbps": 9.02,
|
||||
"keyframes": 1,
|
||||
"size": "1920x1290",
|
||||
"captured": 866,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"n": 0
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"n": 0
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"n": 0
|
||||
},
|
||||
"mac": {
|
||||
"n": 0
|
||||
},
|
||||
"back": {
|
||||
"n": 0
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 0,
|
||||
"sent": 0,
|
||||
"seen": 0
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 45,
|
||||
"mbps": 7.77,
|
||||
"content_p50": 19.0,
|
||||
"content_p95": 163.1,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 51,
|
||||
"mbps": 6.54,
|
||||
"content_p50": 19.4,
|
||||
"content_p95": 107.0,
|
||||
"bitrate": 7430400,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 57,
|
||||
"mbps": 8.64,
|
||||
"content_p50": 19.9,
|
||||
"content_p95": 31.8,
|
||||
"bitrate": 10055362,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 58,
|
||||
"mbps": 9.46,
|
||||
"content_p50": 19.1,
|
||||
"content_p95": 24.9,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 58,
|
||||
"mbps": 9.11,
|
||||
"content_p50": 19.4,
|
||||
"content_p95": 22.9,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 57,
|
||||
"mbps": 9.18,
|
||||
"content_p50": 19.9,
|
||||
"content_p95": 25.3,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 58,
|
||||
"mbps": 11.64,
|
||||
"content_p50": 19.8,
|
||||
"content_p95": 49.6,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 57,
|
||||
"mbps": 9.12,
|
||||
"content_p50": 20.5,
|
||||
"content_p95": 31.1,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 58,
|
||||
"mbps": 9.29,
|
||||
"content_p50": 19.5,
|
||||
"content_p95": 25.7,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 57,
|
||||
"mbps": 9.43,
|
||||
"content_p50": 19.8,
|
||||
"content_p95": 49.6,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 58,
|
||||
"mbps": 8.59,
|
||||
"content_p50": 19.2,
|
||||
"content_p95": 24.8,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 11,
|
||||
"fps": 57,
|
||||
"mbps": 9.42,
|
||||
"content_p50": 19.9,
|
||||
"content_p95": 27.1,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 12,
|
||||
"fps": 58,
|
||||
"mbps": 9.0,
|
||||
"content_p50": 20.6,
|
||||
"content_p95": 31.2,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 13,
|
||||
"fps": 57,
|
||||
"mbps": 8.84,
|
||||
"content_p50": 19.6,
|
||||
"content_p95": 25.3,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 14,
|
||||
"fps": 57,
|
||||
"mbps": 9.17,
|
||||
"content_p50": 19.3,
|
||||
"content_p95": 27.8,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 15,
|
||||
"fps": 9,
|
||||
"mbps": 1.52,
|
||||
"content_p50": 19.3,
|
||||
"content_p95": 30.0,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 19.5,
|
||||
"content_p95": 31.4,
|
||||
"input_p50": null,
|
||||
"input_p95": null,
|
||||
"grades": {
|
||||
"content_p50": "local",
|
||||
"content_p95": "local",
|
||||
"fps": "acceptable",
|
||||
"late_pct": "acceptable",
|
||||
"stall_max": "bad"
|
||||
},
|
||||
"controller": {
|
||||
"adapt": true,
|
||||
"baseRtt": 6.959,
|
||||
"ceiling": 14860800,
|
||||
"fps": 60,
|
||||
"inFlight": 2,
|
||||
"scale": 1,
|
||||
"slack": 43.54,
|
||||
"target": 14860800,
|
||||
"tier": 0
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"t": 1.09,
|
||||
"e": "down to 7430 kbit/s: queue 131 ms, held 3, oldest 231 ms, base 6 ms, sent 4272 got 3724 wants 9321"
|
||||
}
|
||||
],
|
||||
"source_fps": 57.0,
|
||||
"cpu": {
|
||||
"mac_agent_pct": 8.5,
|
||||
"frame_viewer_pct": 71.7,
|
||||
"frame_tailscaled_pct": 8.4,
|
||||
"frame_sshd_pct": 0.9,
|
||||
"frame_gamescope_pct": 8.4,
|
||||
"frame_total_pct": 30.1
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
|
||||
"show_s": 1.25,
|
||||
"panel": "valve.steam.desktopgame.2001195625",
|
||||
"src": "separate:8050"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
{
|
||||
"label": "wifi2",
|
||||
"date": "2026-09-28T21:18:40",
|
||||
"commit": "1d05f57",
|
||||
"config": {
|
||||
"quality": "balanced",
|
||||
"mode": "separate",
|
||||
"net": "none",
|
||||
"delay_ms": 0,
|
||||
"buffer_ms": 250,
|
||||
"host": "frame",
|
||||
"ssh_opts": [],
|
||||
"encoder": "",
|
||||
"duration_s": 15.0,
|
||||
"browser_flags": []
|
||||
},
|
||||
"frame_build": "20260925.6191901",
|
||||
"mac": "26.5.2",
|
||||
"headset": "",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "test",
|
||||
"frames_sent": 902,
|
||||
"frames_drawn": 902,
|
||||
"frames_shown": 604,
|
||||
"duration_s": 15.2,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": 0.0,
|
||||
"p95": 0.0,
|
||||
"p99": 0.0,
|
||||
"n": 902
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.0,
|
||||
"p95": 0.1,
|
||||
"p99": 0.1,
|
||||
"n": 902
|
||||
},
|
||||
"encode": {
|
||||
"p50": 4.3,
|
||||
"p95": 5.0,
|
||||
"p99": 5.3,
|
||||
"n": 902
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.3,
|
||||
"n": 902
|
||||
},
|
||||
"network": {
|
||||
"p50": 3.8,
|
||||
"p95": 5.3,
|
||||
"p99": 8.0,
|
||||
"n": 902
|
||||
},
|
||||
"decode": {
|
||||
"p50": 1.1,
|
||||
"p95": 2.8,
|
||||
"p99": 3.8,
|
||||
"n": 902
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.3,
|
||||
"p95": 0.8,
|
||||
"p99": 1.2,
|
||||
"n": 902
|
||||
},
|
||||
"present": {
|
||||
"p50": 5.9,
|
||||
"p95": 17.0,
|
||||
"p99": 18.6,
|
||||
"n": 604
|
||||
},
|
||||
"content": {
|
||||
"p50": 9.8,
|
||||
"p95": 12.1,
|
||||
"p99": 14.4,
|
||||
"n": 902
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 15.2,
|
||||
"p95": 27.0,
|
||||
"p99": 28.4,
|
||||
"n": 604
|
||||
}
|
||||
},
|
||||
"fps": 59.3,
|
||||
"fps_shown": 39.7,
|
||||
"late_pct": 0.55,
|
||||
"stall_max": 187.5,
|
||||
"stalls_over_100ms": 1,
|
||||
"mbps": 0.47,
|
||||
"keyframes": 1,
|
||||
"size": "1280x720",
|
||||
"captured": 913,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 27.8,
|
||||
"p95": 34.9,
|
||||
"p99": 206.9,
|
||||
"n": 30
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 35.1,
|
||||
"p95": 52.6,
|
||||
"p99": 207.2,
|
||||
"n": 24
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 9.8,
|
||||
"p95": 22.5,
|
||||
"p99": 184.8,
|
||||
"n": 30
|
||||
},
|
||||
"mac": {
|
||||
"p50": 5.3,
|
||||
"p95": 14.4,
|
||||
"p99": 19.6,
|
||||
"n": 30
|
||||
},
|
||||
"back": {
|
||||
"p50": 9.8,
|
||||
"p95": 13.4,
|
||||
"p99": 13.9,
|
||||
"n": 30
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 30,
|
||||
"sent": 30,
|
||||
"seen": 30
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 60,
|
||||
"mbps": 0.48,
|
||||
"content_p50": 9.3,
|
||||
"content_p95": 11.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 50,
|
||||
"mbps": 0.39,
|
||||
"content_p50": 9.1,
|
||||
"content_p95": 14.4,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 60,
|
||||
"mbps": 0.48,
|
||||
"content_p50": 9.2,
|
||||
"content_p95": 11.8,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 60,
|
||||
"mbps": 0.48,
|
||||
"content_p50": 9.4,
|
||||
"content_p95": 10.5,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 60,
|
||||
"mbps": 0.48,
|
||||
"content_p50": 9.5,
|
||||
"content_p95": 12.5,
|
||||
"bitrate": 3091280,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 10.0,
|
||||
"content_p95": 12.8,
|
||||
"bitrate": 4757991,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 60,
|
||||
"mbps": 0.57,
|
||||
"content_p50": 9.5,
|
||||
"content_p95": 11.7,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 9.8,
|
||||
"content_p95": 11.3,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 10.2,
|
||||
"content_p95": 12.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 10.0,
|
||||
"content_p95": 13.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 10.2,
|
||||
"content_p95": 12.5,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 11,
|
||||
"fps": 60,
|
||||
"mbps": 0.45,
|
||||
"content_p50": 9.9,
|
||||
"content_p95": 11.6,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 12,
|
||||
"fps": 60,
|
||||
"mbps": 0.46,
|
||||
"content_p50": 10.0,
|
||||
"content_p95": 11.6,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 13,
|
||||
"fps": 60,
|
||||
"mbps": 0.47,
|
||||
"content_p50": 10.3,
|
||||
"content_p95": 12.3,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 14,
|
||||
"fps": 60,
|
||||
"mbps": 0.48,
|
||||
"content_p50": 9.8,
|
||||
"content_p95": 11.6,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 15,
|
||||
"fps": 12,
|
||||
"mbps": 0.1,
|
||||
"content_p50": 9.3,
|
||||
"content_p95": 11.5,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 9.8,
|
||||
"content_p95": 12.1,
|
||||
"input_p50": 27.8,
|
||||
"input_p95": 34.9,
|
||||
"grades": {
|
||||
"input_replies": "local",
|
||||
"content_p50": "local",
|
||||
"content_p95": "local",
|
||||
"input_p50": "local",
|
||||
"input_p95": "local",
|
||||
"fps": "local",
|
||||
"late_pct": "local",
|
||||
"stall_max": "acceptable"
|
||||
},
|
||||
"controller": {
|
||||
"adapt": true,
|
||||
"baseRtt": 5.084,
|
||||
"ceiling": 5529600,
|
||||
"fps": 60,
|
||||
"inFlight": 1,
|
||||
"scale": 1,
|
||||
"slack": 49.603,
|
||||
"target": 5529600,
|
||||
"tier": 0
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"t": 1.77,
|
||||
"e": "down to 2764 kbit/s: queue 23 ms, held 5, oldest 0 ms, base 5 ms, sent 315 got 315 wants 472"
|
||||
}
|
||||
],
|
||||
"source_fps": 60.1,
|
||||
"cpu": {
|
||||
"mac_agent_pct": 16.1,
|
||||
"frame_viewer_pct": 40.9,
|
||||
"frame_tailscaled_pct": 5.2,
|
||||
"frame_sshd_pct": 0.7,
|
||||
"frame_gamescope_pct": 8.4,
|
||||
"frame_total_pct": 25.2
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 136 Hz",
|
||||
"show_s": 1.27,
|
||||
"panel": "valve.steam.desktopgame.2001639889",
|
||||
"src": "test"
|
||||
},
|
||||
{
|
||||
"scenario": "scroll",
|
||||
"frames_sent": 832,
|
||||
"frames_drawn": 832,
|
||||
"frames_shown": 602,
|
||||
"duration_s": 15.1,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": -3.9,
|
||||
"p95": 0.8,
|
||||
"p99": 6.8,
|
||||
"n": 832
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.0,
|
||||
"p95": 0.1,
|
||||
"p99": 1.9,
|
||||
"n": 832
|
||||
},
|
||||
"encode": {
|
||||
"p50": 6.8,
|
||||
"p95": 10.5,
|
||||
"p99": 18.8,
|
||||
"n": 832
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.5,
|
||||
"n": 832
|
||||
},
|
||||
"network": {
|
||||
"p50": 5.8,
|
||||
"p95": 18.7,
|
||||
"p99": 56.7,
|
||||
"n": 832
|
||||
},
|
||||
"decode": {
|
||||
"p50": 5.9,
|
||||
"p95": 11.7,
|
||||
"p99": 26.0,
|
||||
"n": 832
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.7,
|
||||
"p95": 1.4,
|
||||
"p99": 1.7,
|
||||
"n": 832
|
||||
},
|
||||
"present": {
|
||||
"p50": 4.4,
|
||||
"p95": 18.5,
|
||||
"p99": 25.0,
|
||||
"n": 602
|
||||
},
|
||||
"content": {
|
||||
"p50": 20.2,
|
||||
"p95": 36.9,
|
||||
"p99": 90.7,
|
||||
"n": 832
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 26.4,
|
||||
"p95": 45.4,
|
||||
"p99": 101.0,
|
||||
"n": 602
|
||||
}
|
||||
},
|
||||
"fps": 54.9,
|
||||
"fps_shown": 39.7,
|
||||
"late_pct": 6.96,
|
||||
"stall_max": 204.9,
|
||||
"stalls_over_100ms": 2,
|
||||
"mbps": 9.04,
|
||||
"keyframes": 1,
|
||||
"size": "1920x1290",
|
||||
"captured": 852,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"n": 0
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"n": 0
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"n": 0
|
||||
},
|
||||
"mac": {
|
||||
"n": 0
|
||||
},
|
||||
"back": {
|
||||
"n": 0
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 0,
|
||||
"sent": 0,
|
||||
"seen": 0
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 49,
|
||||
"mbps": 8.68,
|
||||
"content_p50": 19.8,
|
||||
"content_p95": 119.1,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 47,
|
||||
"mbps": 6.06,
|
||||
"content_p50": 19.8,
|
||||
"content_p95": 88.6,
|
||||
"bitrate": 7430400,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 52,
|
||||
"mbps": 8.07,
|
||||
"content_p50": 19.4,
|
||||
"content_p95": 25.5,
|
||||
"bitrate": 10055362,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 56,
|
||||
"mbps": 9.79,
|
||||
"content_p50": 21.4,
|
||||
"content_p95": 29.1,
|
||||
"bitrate": 13549185,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 57,
|
||||
"mbps": 8.93,
|
||||
"content_p50": 21.5,
|
||||
"content_p95": 35.1,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 57,
|
||||
"mbps": 10.59,
|
||||
"content_p50": 21.1,
|
||||
"content_p95": 35.4,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 56,
|
||||
"mbps": 11.55,
|
||||
"content_p50": 19.7,
|
||||
"content_p95": 66.9,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 57,
|
||||
"mbps": 8.8,
|
||||
"content_p50": 18.8,
|
||||
"content_p95": 24.8,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 57,
|
||||
"mbps": 9.17,
|
||||
"content_p50": 19.5,
|
||||
"content_p95": 24.4,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 57,
|
||||
"mbps": 9.39,
|
||||
"content_p50": 19.4,
|
||||
"content_p95": 23.1,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 57,
|
||||
"mbps": 8.53,
|
||||
"content_p50": 19.5,
|
||||
"content_p95": 38.1,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 11,
|
||||
"fps": 56,
|
||||
"mbps": 9.23,
|
||||
"content_p50": 20.7,
|
||||
"content_p95": 30.9,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 12,
|
||||
"fps": 53,
|
||||
"mbps": 8.87,
|
||||
"content_p50": 20.6,
|
||||
"content_p95": 35.4,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 13,
|
||||
"fps": 57,
|
||||
"mbps": 8.94,
|
||||
"content_p50": 20.4,
|
||||
"content_p95": 28.2,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 14,
|
||||
"fps": 57,
|
||||
"mbps": 9.14,
|
||||
"content_p50": 21.0,
|
||||
"content_p95": 35.5,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 15,
|
||||
"fps": 7,
|
||||
"mbps": 1.2,
|
||||
"content_p50": 21.8,
|
||||
"content_p95": 23.1,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 20.2,
|
||||
"content_p95": 36.9,
|
||||
"input_p50": null,
|
||||
"input_p95": null,
|
||||
"grades": {
|
||||
"content_p50": "local",
|
||||
"content_p95": "local",
|
||||
"fps": "acceptable",
|
||||
"late_pct": "bad",
|
||||
"stall_max": "acceptable"
|
||||
},
|
||||
"controller": {
|
||||
"adapt": true,
|
||||
"baseRtt": 6.833,
|
||||
"ceiling": 14860800,
|
||||
"fps": 60,
|
||||
"inFlight": 2,
|
||||
"scale": 1,
|
||||
"slack": 44.791,
|
||||
"target": 14860800,
|
||||
"tier": 0
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"t": 1.22,
|
||||
"e": "down to 7430 kbit/s: queue 74 ms, held 7, oldest 235 ms, base 6 ms, sent 5436 got 4487 wants 11082"
|
||||
}
|
||||
],
|
||||
"source_fps": 56.4,
|
||||
"cpu": {
|
||||
"mac_agent_pct": 8.8,
|
||||
"frame_viewer_pct": 69.5,
|
||||
"frame_tailscaled_pct": 9.5,
|
||||
"frame_sshd_pct": 0.8,
|
||||
"frame_gamescope_pct": 8.4,
|
||||
"frame_total_pct": 31.8
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
|
||||
"show_s": 1.25,
|
||||
"panel": "valve.steam.desktopgame.2001116820",
|
||||
"src": "separate:8327"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
{
|
||||
"label": "adapt-clean",
|
||||
"date": "2026-09-28T17:13:59",
|
||||
"commit": "a3c6e5c+dirty",
|
||||
"config": {
|
||||
"quality": "balanced",
|
||||
"mode": "separate",
|
||||
"net": "none",
|
||||
"delay_ms": 0,
|
||||
"buffer_ms": 250,
|
||||
"host": "frame",
|
||||
"ssh_opts": [],
|
||||
"encoder": "",
|
||||
"duration_s": 20.0,
|
||||
"browser_flags": []
|
||||
},
|
||||
"frame_build": "20260925.6191901",
|
||||
"mac": "26.5.2",
|
||||
"headset": ":39.639829 [Info] - 0 - entering standby",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "test",
|
||||
"frames_sent": 653,
|
||||
"frames_drawn": 653,
|
||||
"frames_shown": 649,
|
||||
"duration_s": 20.7,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": 0.0,
|
||||
"p95": 0.0,
|
||||
"p99": 0.0,
|
||||
"n": 653
|
||||
},
|
||||
"queue": {
|
||||
"p50": 11.2,
|
||||
"p95": 16.1,
|
||||
"p99": 17.1,
|
||||
"n": 653
|
||||
},
|
||||
"encode": {
|
||||
"p50": 3.5,
|
||||
"p95": 4.2,
|
||||
"p99": 4.4,
|
||||
"n": 653
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.2,
|
||||
"n": 653
|
||||
},
|
||||
"network": {
|
||||
"p50": 2.9,
|
||||
"p95": 7.0,
|
||||
"p99": 12.0,
|
||||
"n": 653
|
||||
},
|
||||
"decode": {
|
||||
"p50": 1.0,
|
||||
"p95": 2.4,
|
||||
"p99": 2.8,
|
||||
"n": 653
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.3,
|
||||
"p95": 0.6,
|
||||
"p99": 0.9,
|
||||
"n": 653
|
||||
},
|
||||
"present": {
|
||||
"p50": 0.5,
|
||||
"p95": 0.9,
|
||||
"p99": 5.9,
|
||||
"n": 649
|
||||
},
|
||||
"content": {
|
||||
"p50": 19.8,
|
||||
"p95": 26.2,
|
||||
"p99": 30.1,
|
||||
"n": 653
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 20.3,
|
||||
"p95": 26.9,
|
||||
"p99": 31.1,
|
||||
"n": 649
|
||||
}
|
||||
},
|
||||
"fps": 31.5,
|
||||
"fps_shown": 31.3,
|
||||
"late_pct": 98.01,
|
||||
"stall_max": 144.0,
|
||||
"stalls_over_100ms": 2,
|
||||
"mbps": 0.13,
|
||||
"keyframes": 2,
|
||||
"size": "960x540",
|
||||
"captured": 1336,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 43.5,
|
||||
"p95": 61.8,
|
||||
"p99": 63.4,
|
||||
"n": 40
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 44.3,
|
||||
"p95": 62.3,
|
||||
"p99": 63.9,
|
||||
"n": 40
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 11.1,
|
||||
"p95": 29.2,
|
||||
"p99": 36.1,
|
||||
"n": 40
|
||||
},
|
||||
"mac": {
|
||||
"p50": 10.4,
|
||||
"p95": 28.2,
|
||||
"p99": 32.4,
|
||||
"n": 40
|
||||
},
|
||||
"back": {
|
||||
"p50": 17.5,
|
||||
"p95": 27.9,
|
||||
"p99": 28.8,
|
||||
"n": 40
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"sent": 40,
|
||||
"seen": 40
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 31,
|
||||
"mbps": 0.13,
|
||||
"content_p50": 21.5,
|
||||
"content_p95": 27.1,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 31,
|
||||
"mbps": 0.12,
|
||||
"content_p50": 22.4,
|
||||
"content_p95": 26.3,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 32,
|
||||
"mbps": 0.13,
|
||||
"content_p50": 18.9,
|
||||
"content_p95": 24.8,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 31,
|
||||
"mbps": 0.12,
|
||||
"content_p50": 18.2,
|
||||
"content_p95": 24.6,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 32,
|
||||
"mbps": 0.13,
|
||||
"content_p50": 21.0,
|
||||
"content_p95": 25.6,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 32,
|
||||
"mbps": 0.12,
|
||||
"content_p50": 19.5,
|
||||
"content_p95": 24.3,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 31,
|
||||
"mbps": 0.12,
|
||||
"content_p50": 22.2,
|
||||
"content_p95": 25.6,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 32,
|
||||
"mbps": 0.13,
|
||||
"content_p50": 19.9,
|
||||
"content_p95": 24.0,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 31,
|
||||
"mbps": 0.21,
|
||||
"content_p50": 22.7,
|
||||
"content_p95": 26.9,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 31,
|
||||
"mbps": 0.12,
|
||||
"content_p50": 20.2,
|
||||
"content_p95": 26.1,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 32,
|
||||
"mbps": 0.12,
|
||||
"content_p50": 18.2,
|
||||
"content_p95": 23.2,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 11,
|
||||
"fps": 31,
|
||||
"mbps": 0.12,
|
||||
"content_p50": 19.8,
|
||||
"content_p95": 24.2,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 12,
|
||||
"fps": 32,
|
||||
"mbps": 0.13,
|
||||
"content_p50": 19.2,
|
||||
"content_p95": 24.8,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 13,
|
||||
"fps": 32,
|
||||
"mbps": 0.12,
|
||||
"content_p50": 21.0,
|
||||
"content_p95": 24.3,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 14,
|
||||
"fps": 30,
|
||||
"mbps": 0.13,
|
||||
"content_p50": 20.4,
|
||||
"content_p95": 96.7,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 15,
|
||||
"fps": 32,
|
||||
"mbps": 0.13,
|
||||
"content_p50": 18.2,
|
||||
"content_p95": 24.4,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 16,
|
||||
"fps": 31,
|
||||
"mbps": 0.12,
|
||||
"content_p50": 18.4,
|
||||
"content_p95": 25.1,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 17,
|
||||
"fps": 32,
|
||||
"mbps": 0.13,
|
||||
"content_p50": 17.2,
|
||||
"content_p95": 23.5,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 18,
|
||||
"fps": 32,
|
||||
"mbps": 0.19,
|
||||
"content_p50": 19.4,
|
||||
"content_p95": 25.2,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 19,
|
||||
"fps": 31,
|
||||
"mbps": 0.14,
|
||||
"content_p50": 21.1,
|
||||
"content_p95": 84.3,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 20,
|
||||
"fps": 24,
|
||||
"mbps": 0.08,
|
||||
"content_p50": 20.8,
|
||||
"content_p95": 25.1,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 19.8,
|
||||
"content_p95": 26.2,
|
||||
"input_p50": 43.5,
|
||||
"input_p95": 61.8,
|
||||
"grades": {
|
||||
"content_p50": "local",
|
||||
"content_p95": "local",
|
||||
"input_p50": "local",
|
||||
"input_p95": "local",
|
||||
"fps": "bad",
|
||||
"late_pct": "bad",
|
||||
"stall_max": "acceptable"
|
||||
},
|
||||
"source_fps": 64.5,
|
||||
"cpu": {
|
||||
"mac_agent_pct": 11.3,
|
||||
"frame_viewer_pct": 35.9,
|
||||
"frame_tailscaled_pct": 3.5,
|
||||
"frame_sshd_pct": 0.6,
|
||||
"frame_gamescope_pct": 7.5,
|
||||
"frame_total_pct": 16.1
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 141 Hz",
|
||||
"show_s": 4.68,
|
||||
"panel": "valve.steam.desktopgame.2001639889",
|
||||
"src": "test"
|
||||
},
|
||||
{
|
||||
"scenario": "scroll",
|
||||
"frames_sent": 937,
|
||||
"frames_drawn": 937,
|
||||
"frames_shown": 720,
|
||||
"duration_s": 20.7,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": -3.9,
|
||||
"p95": 0.9,
|
||||
"p99": 8.2,
|
||||
"n": 937
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.0,
|
||||
"p95": 16.3,
|
||||
"p99": 20.1,
|
||||
"n": 937
|
||||
},
|
||||
"encode": {
|
||||
"p50": 6.4,
|
||||
"p95": 7.2,
|
||||
"p99": 9.3,
|
||||
"n": 937
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.2,
|
||||
"n": 937
|
||||
},
|
||||
"network": {
|
||||
"p50": 7.3,
|
||||
"p95": 13.0,
|
||||
"p99": 22.8,
|
||||
"n": 937
|
||||
},
|
||||
"decode": {
|
||||
"p50": 6.2,
|
||||
"p95": 12.7,
|
||||
"p99": 18.6,
|
||||
"n": 937
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.6,
|
||||
"p95": 1.1,
|
||||
"p99": 1.9,
|
||||
"n": 937
|
||||
},
|
||||
"present": {
|
||||
"p50": 2.9,
|
||||
"p95": 18.5,
|
||||
"p99": 24.5,
|
||||
"n": 720
|
||||
},
|
||||
"content": {
|
||||
"p50": 19.9,
|
||||
"p95": 37.2,
|
||||
"p99": 50.2,
|
||||
"n": 937
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 27.0,
|
||||
"p95": 45.7,
|
||||
"p99": 55.0,
|
||||
"n": 720
|
||||
}
|
||||
},
|
||||
"fps": 45.3,
|
||||
"fps_shown": 34.8,
|
||||
"late_pct": 28.63,
|
||||
"stall_max": 99.3,
|
||||
"stalls_over_100ms": 0,
|
||||
"mbps": 7.59,
|
||||
"keyframes": 2,
|
||||
"size": "1920x1290",
|
||||
"captured": 1215,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"n": 0
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"n": 0
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"n": 0
|
||||
},
|
||||
"mac": {
|
||||
"n": 0
|
||||
},
|
||||
"back": {
|
||||
"n": 0
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"sent": 0,
|
||||
"seen": 0
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 30,
|
||||
"mbps": 4.01,
|
||||
"content_p50": 31.9,
|
||||
"content_p95": 52.3,
|
||||
"bitrate": 4867140,
|
||||
"tier": 2,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 30,
|
||||
"mbps": 4.12,
|
||||
"content_p50": 29.9,
|
||||
"content_p95": 45.3,
|
||||
"bitrate": 3807054,
|
||||
"tier": 2,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 30,
|
||||
"mbps": 3.26,
|
||||
"content_p50": 31.8,
|
||||
"content_p95": 52.5,
|
||||
"bitrate": 3235995,
|
||||
"tier": 2,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 30,
|
||||
"mbps": 3.72,
|
||||
"content_p50": 24.5,
|
||||
"content_p95": 37.7,
|
||||
"bitrate": 4238740,
|
||||
"tier": 2,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 30,
|
||||
"mbps": 5.09,
|
||||
"content_p50": 25.2,
|
||||
"content_p95": 39.6,
|
||||
"bitrate": 5992063,
|
||||
"tier": 2,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 31,
|
||||
"mbps": 7.29,
|
||||
"content_p50": 29.1,
|
||||
"content_p95": 53.5,
|
||||
"bitrate": 5526067,
|
||||
"tier": 2,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 35,
|
||||
"mbps": 4.58,
|
||||
"content_p50": 25.3,
|
||||
"content_p95": 32.4,
|
||||
"bitrate": 6018152,
|
||||
"tier": 1,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 46,
|
||||
"mbps": 7.12,
|
||||
"content_p50": 25.1,
|
||||
"content_p95": 34.3,
|
||||
"bitrate": 8412933,
|
||||
"tier": 1,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 46,
|
||||
"mbps": 8.25,
|
||||
"content_p50": 27.1,
|
||||
"content_p95": 40.3,
|
||||
"bitrate": 10760191,
|
||||
"tier": 1,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 47,
|
||||
"mbps": 9.3,
|
||||
"content_p50": 27.8,
|
||||
"content_p95": 41.2,
|
||||
"bitrate": 13717060,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 55,
|
||||
"mbps": 8.98,
|
||||
"content_p50": 16.5,
|
||||
"content_p95": 21.7,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 11,
|
||||
"fps": 51,
|
||||
"mbps": 9.32,
|
||||
"content_p50": 18.6,
|
||||
"content_p95": 35.4,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 12,
|
||||
"fps": 56,
|
||||
"mbps": 9.53,
|
||||
"content_p50": 15.4,
|
||||
"content_p95": 26.2,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 13,
|
||||
"fps": 55,
|
||||
"mbps": 8.46,
|
||||
"content_p50": 18.6,
|
||||
"content_p95": 23.2,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 14,
|
||||
"fps": 54,
|
||||
"mbps": 9.82,
|
||||
"content_p50": 15.9,
|
||||
"content_p95": 28.4,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 15,
|
||||
"fps": 54,
|
||||
"mbps": 11.11,
|
||||
"content_p50": 15.5,
|
||||
"content_p95": 26.1,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 16,
|
||||
"fps": 54,
|
||||
"mbps": 9.79,
|
||||
"content_p50": 17.4,
|
||||
"content_p95": 25.3,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 17,
|
||||
"fps": 57,
|
||||
"mbps": 9.3,
|
||||
"content_p50": 16.3,
|
||||
"content_p95": 23.5,
|
||||
"bitrate": 14860800,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 18,
|
||||
"fps": 55,
|
||||
"mbps": 8.3,
|
||||
"content_p50": 19.5,
|
||||
"content_p95": 31.0,
|
||||
"bitrate": 7984791,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 19,
|
||||
"fps": 51,
|
||||
"mbps": 8.56,
|
||||
"content_p50": 16.4,
|
||||
"content_p95": 29.1,
|
||||
"bitrate": 10220855,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 20,
|
||||
"fps": 40,
|
||||
"mbps": 7.13,
|
||||
"content_p50": 18.3,
|
||||
"content_p95": 23.9,
|
||||
"bitrate": 12025604,
|
||||
"tier": 0,
|
||||
"w": 1920,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 19.9,
|
||||
"content_p95": 37.2,
|
||||
"input_p50": null,
|
||||
"input_p95": null,
|
||||
"grades": {
|
||||
"content_p50": "local",
|
||||
"content_p95": "local",
|
||||
"fps": "acceptable",
|
||||
"late_pct": "bad",
|
||||
"stall_max": "local"
|
||||
},
|
||||
"source_fps": 58.7,
|
||||
"cpu": {
|
||||
"mac_agent_pct": 6.6,
|
||||
"frame_viewer_pct": 72.4,
|
||||
"frame_tailscaled_pct": 8.1,
|
||||
"frame_sshd_pct": 0.9,
|
||||
"frame_gamescope_pct": 10.0,
|
||||
"frame_total_pct": 24.2
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 141 Hz",
|
||||
"show_s": 6.43,
|
||||
"panel": "valve.steam.desktopgame.2001414593",
|
||||
"src": "separate:9384"
|
||||
},
|
||||
{
|
||||
"scenario": "type",
|
||||
"frames_sent": 25,
|
||||
"frames_drawn": 25,
|
||||
"frames_shown": 25,
|
||||
"duration_s": 14.7,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": -0.3,
|
||||
"p95": 0.9,
|
||||
"p99": 1.1,
|
||||
"n": 25
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.0,
|
||||
"p95": 0.0,
|
||||
"p99": 0.0,
|
||||
"n": 25
|
||||
},
|
||||
"encode": {
|
||||
"p50": 5.0,
|
||||
"p95": 33.4,
|
||||
"p99": 33.6,
|
||||
"n": 25
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.1,
|
||||
"p99": 0.2,
|
||||
"n": 25
|
||||
},
|
||||
"network": {
|
||||
"p50": 6.1,
|
||||
"p95": 14.9,
|
||||
"p99": 24.4,
|
||||
"n": 25
|
||||
},
|
||||
"decode": {
|
||||
"p50": 3.0,
|
||||
"p95": 10.5,
|
||||
"p99": 11.4,
|
||||
"n": 25
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.6,
|
||||
"p95": 1.0,
|
||||
"p99": 1.1,
|
||||
"n": 25
|
||||
},
|
||||
"present": {
|
||||
"p50": 0.8,
|
||||
"p95": 1.4,
|
||||
"p99": 1.7,
|
||||
"n": 25
|
||||
},
|
||||
"content": {
|
||||
"p50": 12.6,
|
||||
"p95": 41.0,
|
||||
"p99": 46.3,
|
||||
"n": 25
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 13.9,
|
||||
"p95": 41.8,
|
||||
"p99": 46.6,
|
||||
"n": 25
|
||||
}
|
||||
},
|
||||
"fps": 1.7,
|
||||
"fps_shown": 1.7,
|
||||
"late_pct": 100.0,
|
||||
"stall_max": 3099.2,
|
||||
"stalls_over_100ms": 21,
|
||||
"mbps": 0.04,
|
||||
"keyframes": 4,
|
||||
"size": "960x646",
|
||||
"captured": 79,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 41.3,
|
||||
"p95": 60.3,
|
||||
"p99": 68.6,
|
||||
"n": 18
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 43.0,
|
||||
"p95": 61.1,
|
||||
"p99": 69.6,
|
||||
"n": 18
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 5.3,
|
||||
"p95": 11.6,
|
||||
"p99": 14.2,
|
||||
"n": 18
|
||||
},
|
||||
"mac": {
|
||||
"p50": 23.1,
|
||||
"p95": 35.0,
|
||||
"p99": 42.6,
|
||||
"n": 18
|
||||
},
|
||||
"back": {
|
||||
"p50": 11.8,
|
||||
"p95": 19.2,
|
||||
"p99": 22.7,
|
||||
"n": 18
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"sent": 66,
|
||||
"seen": 18
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 1,
|
||||
"mbps": 0.01,
|
||||
"content_p50": 11.8,
|
||||
"content_p95": 11.8,
|
||||
"bitrate": 1224075,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 1,
|
||||
"mbps": 0.02,
|
||||
"content_p50": 14.7,
|
||||
"content_p95": 14.7,
|
||||
"bitrate": 2091896,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 4,
|
||||
"mbps": 0.1,
|
||||
"content_p50": 18.6,
|
||||
"content_p95": 37.4,
|
||||
"bitrate": 3071304,
|
||||
"tier": 3,
|
||||
"w": 1440,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 5,
|
||||
"mbps": 0.13,
|
||||
"content_p50": 4.2,
|
||||
"content_p95": 19.2,
|
||||
"bitrate": 4031277,
|
||||
"tier": 3,
|
||||
"w": 1440,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 6,
|
||||
"mbps": 0.04,
|
||||
"content_p50": 12.0,
|
||||
"content_p95": 46.3,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 1,
|
||||
"mbps": 0.01,
|
||||
"content_p50": 12.2,
|
||||
"content_p95": 12.2,
|
||||
"bitrate": 300000,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 12,
|
||||
"fps": 1,
|
||||
"mbps": 0.02,
|
||||
"content_p50": -1.2,
|
||||
"content_p95": -1.2,
|
||||
"bitrate": 842857,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 14,
|
||||
"fps": 1,
|
||||
"mbps": 0.02,
|
||||
"content_p50": 14.4,
|
||||
"content_p95": 14.4,
|
||||
"bitrate": 2091896,
|
||||
"tier": 4,
|
||||
"w": 960,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 12.6,
|
||||
"content_p95": 41.0,
|
||||
"input_p50": 41.3,
|
||||
"input_p95": 60.3,
|
||||
"grades": {
|
||||
"content_p50": "local",
|
||||
"content_p95": "acceptable",
|
||||
"input_p50": "local",
|
||||
"input_p95": "local"
|
||||
},
|
||||
"source_fps": 5.4,
|
||||
"cpu": {
|
||||
"mac_agent_pct": 1.2,
|
||||
"frame_viewer_pct": 5.0,
|
||||
"frame_tailscaled_pct": 1.5,
|
||||
"frame_sshd_pct": 0.2,
|
||||
"frame_gamescope_pct": 4.7,
|
||||
"frame_total_pct": 8.5
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 140 Hz",
|
||||
"show_s": 5.12,
|
||||
"panel": "valve.steam.desktopgame.2001910179",
|
||||
"src": "separate:9510"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.lakebed/
|
||||
.env.lakebed.server
|
||||
@@ -0,0 +1,88 @@
|
||||
# Lakebed app instructions
|
||||
|
||||
Treat this capsule directory as the whole app. Use Lakebed's built-in APIs and CLI.
|
||||
|
||||
## Limits to check first
|
||||
|
||||
- The public alpha is not production-ready.
|
||||
- App code cannot use arbitrary npm packages or Node built-ins. Do not install app dependencies.
|
||||
- Database fields support `string()`, `boolean()`, `number()`, `id(...)`, and `userId()`. Chain `.optional()` or `.default(value)` on any field.
|
||||
- Local database data and uploaded files reset when the dev server restarts.
|
||||
- Hosted server secrets and outbound server-side `fetch` require a claimed deploy.
|
||||
- Unclaimed deploys expire. Use the expiry printed by the CLI. Claimed deploys do not expire.
|
||||
|
||||
## App structure and APIs
|
||||
|
||||
- `server/index.ts` exports the default `capsule()` definition. Put server code in `server/`. Import from `lakebed/server` or relative server and shared files.
|
||||
- `client/index.tsx` exports `App`. Put client code in `client/`. Import from `lakebed/client`, `preact`, `preact/hooks`, `preact/jsx-runtime`, `preact/jsx-dev-runtime`, or relative client and shared files.
|
||||
- Keep `shared/` pure TypeScript. Do not import DOM APIs, Node built-ins, env values, or Lakebed runtimes there.
|
||||
- In client code, use `import type app from "../server/index"` and `createClient<typeof app>()` for typed queries, mutations, and actions. Query hooks return `undefined` until the first result arrives.
|
||||
- Database calls are async. Await or return every database operation. Declare indexes with `.index(name, fields)` and query with `withIndex`. Use `by_creation` for unfiltered creation-order queries. Do not use legacy `where`, `orderBy`, `limit`, or `all`.
|
||||
- Queries and actions cannot write to the database. Use mutations or endpoints for writes. Filter user-owned data by the caller's `userId` and check ownership again before updates or deletes.
|
||||
- Guests get protected browser sessions without setup. Use `ctx.auth` on the server and `useAuth()` on the client. A user ID is not a credential. Do not invent guest IDs or check their prefixes.
|
||||
- Use `ctx.auth.requireIdentity()` for data that belongs to a guest or signed-in user. Use `ctx.auth.requireSignedIn()` for account-only operations. `isGuest` and `isSignedIn` are separate checks. Neither is true without a session.
|
||||
- Set `auth: { requireSignIn: true }` in `capsule()` to block all app data operations until sign-in. Client UI checks alone do not protect data. On the client, gate data components on `canAccessApp()` from `lakebed/client`.
|
||||
- If `auth.error` blocks access, show `retryAuth()` and Google sign-in. Retry cannot renew an expired or revoked token for a pending guest upgrade. Keep data components unmounted until auth recovers.
|
||||
- Declare Lakebed user fields with `userId()` from `lakebed/server`, never `string()`. When a guest signs in, declared `userId()` fields follow them to their account. `userId()` does not grant access. Keep owner filters and ownership checks. Make shared data intentional with a shared query, not a fake global user.
|
||||
- Use `auth.onGuestUpgrade` only for app-specific merge rules. It runs before automatic reference transfer in the same transaction. Plain strings, profile text, and external data do not transfer automatically.
|
||||
- Add Google sign-in with `SignInWithGoogle` or `signInWithGoogle()` from `lakebed/client`. For custom endpoints, send the identity token from `getIdentity().token` in the `X-Lakebed-Token` header. `Authorization` belongs to the app. Same-origin guest cookies work without that header.
|
||||
- Read server secrets through `ctx.env`, with values in `.env.lakebed.server`. They are not available at build time. Never put secrets in client or shared code. Deploy sync replaces hosted env with the file contents after the deploy is claimed.
|
||||
- Use complete Tailwind class names in JSX. Lakebed compiles CSS automatically from client files and their imports. Use inline styles for values loaded at runtime. Do not add CSS files, CSS modules, PostCSS, or a separate Tailwind build step.
|
||||
- Use the router from `lakebed/client` for pages. There is no file-based routing. Use `endpoint({ method, path }, handler)` from `lakebed/server` for webhooks and external HTTP clients. Request helpers include `headers.get(name)`, `query`, `json()`, `text()`, and `bytes()`.
|
||||
- Static capsule assets are limited to the favicon. Use `favicon.svg`, `favicon.ico`, or the `favicon` option in `capsule()`. Use `client.storage` for user uploads.
|
||||
|
||||
## External data and dashboards
|
||||
|
||||
Use global `fetch(url, options)` inside a handler, not `ctx.fetch`. Queries, mutations, actions, and endpoints can fetch locally and on claimed deploys. A mutation or writable endpoint can fetch external data and write rows in the same call. Data does not need to pass through the browser. Fetch shares the handler time budget and can hold up other writes, so ingest one small batch per call.
|
||||
|
||||
Lakebed has no built-in scheduler or durable continuation queue yet. For periodic ingest, use an external scheduler to call a protected `POST` endpoint. Return a cursor for the caller to advance across separate requests. Keep `auth.requireSignIn` off for public reads and check an app secret in the ingest endpoint. CLI deploy tokens do not authenticate app endpoint callers.
|
||||
|
||||
Database read budgets apply to the whole handler. A loop over `paginate()` does not bypass them. For totals larger than one handler can read, maintain summary rows during ingest. Store timestamps with `number()` as epoch milliseconds. See the [handler capability table](https://docs.lakebed.dev/capsule-api/index.md#handler-capabilities), [dashboard ingest example](https://docs.lakebed.dev/database/index.md#dashboard-counts), and [resource limits](https://docs.lakebed.dev/limits/index.md) before planning a backfill.
|
||||
|
||||
## Run and verify
|
||||
|
||||
Run commands from this capsule directory with `npx lakebed`.
|
||||
|
||||
Start dev in a terminal session that can stay open:
|
||||
|
||||
```sh
|
||||
npx lakebed dev
|
||||
```
|
||||
|
||||
Keep that process running. Edit the starter to build the requested app, then test its behavior at the URL printed by dev. Use another terminal to inspect logs and data:
|
||||
|
||||
```sh
|
||||
npx lakebed logs --port 3000
|
||||
npx lakebed db dump --port 3000
|
||||
```
|
||||
|
||||
Use the dev server's port if it differs from 3000. Fix compile errors and runtime errors before deploying. Check user-owned data with separate browser profiles or the `?lakebed_guest=<name>` local test override when the app stores private data. Named overrides are local test identities and cannot upgrade to an account.
|
||||
|
||||
## Deploy and verify
|
||||
|
||||
After local checks pass, deploy from another terminal:
|
||||
|
||||
```sh
|
||||
npx lakebed deploy
|
||||
```
|
||||
|
||||
If the CLI requires a claim for server secrets or outbound fetch, follow its claim instructions and deploy again. A claim-required preview is not a working app.
|
||||
|
||||
Open the returned URL and test the requested behavior. Inspect the deployed app from this capsule directory, using its returned ID or URL:
|
||||
|
||||
```sh
|
||||
npx lakebed inspect <deploy-id-or-url>
|
||||
npx lakebed logs <deploy-id-or-url>
|
||||
```
|
||||
|
||||
Hosted inspection is private by default. The CLI uses saved credentials. Report the working URL, the checks you ran, and the expiry if the deploy is unclaimed. Default app URLs use `lakebed.app` subdomains.
|
||||
|
||||
## Read when needed
|
||||
|
||||
- For server and client API details, read the [capsule API](https://docs.lakebed.dev/capsule-api/index.md).
|
||||
- For indexes and queries, read the [database guide](https://docs.lakebed.dev/database/index.md).
|
||||
- For Google sign-in and identity, read the [auth guide](https://docs.lakebed.dev/auth/index.md).
|
||||
- For user uploads, read the [storage guide](https://docs.lakebed.dev/storage/index.md).
|
||||
- For claiming, domains, and other CLI commands, read the [reference](https://docs.lakebed.dev/reference/index.md).
|
||||
- For an older capsule using synchronous database calls, read the [migration guide](https://docs.lakebed.dev/database-migration/index.md).
|
||||
- For anything else, read the [docs index](https://docs.lakebed.dev/llms.txt). It lists every page and section so you can fetch only the one you need.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,81 @@
|
||||
# compat-db: Frame Control's compatibility database
|
||||
|
||||
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
|
||||
reports for Android apps on the Steam Frame. Only the maintainer's copy of
|
||||
Frame Control has the key to read or write it (see `shared()` in
|
||||
`ui/frame_compat_db.py`). Everyone else's reports stay on their computer
|
||||
unless they turn on **Share compatibility results**. Then the reports also go
|
||||
to PostHog as `compat_report` events, and the maintainer syncs them in (below).
|
||||
|
||||
## Community reports
|
||||
|
||||
```sh
|
||||
python3 ui/frame_compat_db.py sync --dry-run # what would be added
|
||||
python3 ui/frame_compat_db.py sync # add them
|
||||
```
|
||||
|
||||
`sync` reads `compat_report` events through PostHog's query API and adds
|
||||
them with `via` set to `community`, `community-probe` or `community-install`.
|
||||
It skips invalid reports and anything over 30 per reporter per day. Each run
|
||||
re-reads the last 30 days, because an offline copy sends its reports late,
|
||||
with the time they were made. `posthog-sync.json`, next to the outbox,
|
||||
remembers which reports it has handled and each reporter's daily count, so
|
||||
nothing is added twice and the cap holds across runs.
|
||||
|
||||
It needs:
|
||||
|
||||
- the PostHog project id: `"project"` in `ui/telemetry.json`
|
||||
- a personal API key with `query:read`: `POSTHOG_PERSONAL_API_KEY`, or in the
|
||||
Keychain (service `frame-control-posthog`, account `personal-api-key`)
|
||||
|
||||
- Live: `https://frame-compat.lakebed.app` (deploy `dep_dDmcsosVSiFirpW6`,
|
||||
claimed, so it doesn't expire). The browser page only says it's private.
|
||||
- Access: `GET /v1/reports?since=<createdAt>` and `POST /v1/reports` with
|
||||
`{"reports": [...]}`. Both need the `x-frame-control-key` header. There are
|
||||
no Lakebed queries or mutations, so nothing else can reach the rows.
|
||||
- Key: `FRAME_CONTROL_KEY` in `.env.lakebed.server` (git-ignored, synced on
|
||||
deploy) and in the Mac's login Keychain (service `frame-control-compat-db`,
|
||||
account `app-key`), where `ui/frame_compat_db.py` reads it.
|
||||
- Duplicates: each report carries a `clientId`, and a report already stored is
|
||||
skipped, so retries and restores are safe to repeat.
|
||||
- Free-plan limits: 1 MiB of data and 16,384 rows per deploy, 1,000 writes a
|
||||
day. A report is about 300 bytes, so roughly 3,000 reports fit.
|
||||
|
||||
## Backups
|
||||
|
||||
`scripts/compat-db-backup.sh` exports every report through the app key and
|
||||
keeps dated copies in
|
||||
`~/Library/Application Support/Frame Control/compat-db/backups` (newest 60).
|
||||
When the data has changed, it also uploads them with `gog` to the Google
|
||||
Drive folder named by `DRIVE_FOLDER_ID` (set it in the LaunchAgent's
|
||||
`EnvironmentVariables`). A LaunchAgent runs it daily at 03:40 and logs to
|
||||
`~/Library/Logs/frame-compat-backup.log`. If an export has fewer reports than
|
||||
the last good backup (`backups/.last-good`), it's kept as `refused-*.json`,
|
||||
nothing is uploaded, and every later run refuses too until you rerun with
|
||||
`--accept-shrink`.
|
||||
|
||||
Reports that can't be sent (unreadable outbox lines, or ones the server
|
||||
rejects, which it lists by `clientId`) are never dropped: they move to
|
||||
`~/Library/Application Support/Frame Control/compat-db/compat-outbox.jsonl.rejected`,
|
||||
with the reason.
|
||||
|
||||
Restore (to this deploy or a new one):
|
||||
|
||||
```sh
|
||||
python3 ui/frame_compat_db.py import BACKUP.json # duplicates are skipped
|
||||
python3 ui/frame_compat_db.py count
|
||||
```
|
||||
|
||||
`npx lakebed db export dep_dDmcsosVSiFirpW6 --out full.json` is a second,
|
||||
owner-only export path through the Lakebed CLI.
|
||||
|
||||
## Change and deploy
|
||||
|
||||
```sh
|
||||
cd compat-db
|
||||
npx lakebed dev --port 3917 # local; data resets on restart
|
||||
npx lakebed deploy # updates frame-compat.lakebed.app
|
||||
```
|
||||
|
||||
To rotate the key: generate a new one, update the Keychain item and
|
||||
`.env.lakebed.server`, then deploy.
|
||||
@@ -0,0 +1,9 @@
|
||||
// No browser access to the data: Frame Control reads and writes it through the
|
||||
// key-protected /v1 endpoints only.
|
||||
export function App() {
|
||||
return (
|
||||
<main className="min-h-screen grid place-items-center bg-slate-900 text-slate-300 p-8">
|
||||
<p>Frame compatibility database. Private: only Frame Control can use it.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="lakebed-favicon-gradient" x1="12" y1="8" x2="52" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="hsl(157 84% 58%)" />
|
||||
<stop offset="1" stop-color="hsl(193 82% 44%)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="16" fill="url(#lakebed-favicon-gradient)" />
|
||||
<circle cx="48" cy="16" r="18" fill="#fff" opacity=".16" />
|
||||
<text x="32" y="39" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="32" font-weight="800" fill="#fff">C</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 658 B |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"deployId": "dep_dDmcsosVSiFirpW6"
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { capsule, endpoint, json, string, table, text } from "lakebed/server";
|
||||
|
||||
// Compatibility reports for Android apps on the Steam Frame, written and read
|
||||
// only by Frame Control. There are no queries or mutations, so browsers and
|
||||
// Lakebed clients can't reach the data; the two endpoints require the app key
|
||||
// (FRAME_CONTROL_KEY in .env.lakebed.server, kept in the Mac's Keychain).
|
||||
|
||||
const RESULTS = ["runs", "crashes", "install_failed", "instance_failed"];
|
||||
const RATINGS = ["works", "issues", "broken"];
|
||||
const PAGE = 500;
|
||||
|
||||
type Incoming = Record<string, unknown>;
|
||||
|
||||
function field(r: Incoming, key: string, max = 200): string | undefined {
|
||||
const v = r[key];
|
||||
if (v === undefined || v === null || v === "") return undefined;
|
||||
return String(v).slice(0, max);
|
||||
}
|
||||
|
||||
function authorised(ctx: { env: Record<string, string | undefined> }, key: string | null): boolean {
|
||||
const expected = ctx.env.FRAME_CONTROL_KEY;
|
||||
if (!expected || !key) return false;
|
||||
// Compare every position of the longer string so timing doesn't reveal the key length.
|
||||
const n = Math.max(key.length, expected.length);
|
||||
let diff = key.length ^ expected.length;
|
||||
for (let i = 0; i < n; i++) diff |= (key.charCodeAt(i) || 0) ^ (expected.charCodeAt(i) || 0);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
export default capsule({
|
||||
name: "frame-compat",
|
||||
|
||||
auth: { requireSignIn: false },
|
||||
|
||||
schema: {
|
||||
reports: table({
|
||||
package: string(),
|
||||
version: string().optional(),
|
||||
result: string().optional(),
|
||||
rating: string().optional(),
|
||||
notes: string().optional(),
|
||||
via: string().optional(),
|
||||
reportedAt: string(),
|
||||
steamos: string().optional(),
|
||||
lepton: string().optional(),
|
||||
runtime: string().optional(),
|
||||
label: string().optional(),
|
||||
source: string().optional(),
|
||||
clientId: string()
|
||||
}).index("by_package", ["package"]).index("by_client", ["clientId"])
|
||||
},
|
||||
|
||||
endpoints: {
|
||||
// GET /v1/reports?since=<createdAt> -> { reports: [...], next: <createdAt> | null }
|
||||
// Pass `next` back as `since` until it's null; rows at the boundary repeat, so dedupe by id.
|
||||
list: endpoint({ method: "GET", path: "/v1/reports" }, async (ctx, req) => {
|
||||
if (!authorised(ctx, req.headers.get("x-frame-control-key"))) return text("unauthorized", { status: 401 });
|
||||
const since = req.query.get("since") ?? "";
|
||||
const rows = await ctx.db.reports
|
||||
.withIndex("by_creation", (q) => q.gte("createdAt", since))
|
||||
.take(PAGE);
|
||||
return json({ reports: rows, next: rows.length === PAGE ? rows[rows.length - 1].createdAt : null });
|
||||
}),
|
||||
|
||||
// POST /v1/reports body: { reports: [ {...}, ... ] } (max 100 per call)
|
||||
// clientId makes retries idempotent: a report already stored is skipped.
|
||||
// Invalid reports are listed in `rejected` (by clientId) so the app can keep them.
|
||||
add: endpoint({ method: "POST", path: "/v1/reports" }, async (ctx, req) => {
|
||||
if (!authorised(ctx, req.headers.get("x-frame-control-key"))) return text("unauthorized", { status: 401 });
|
||||
const body = await req.json<{ reports?: Incoming[] }>();
|
||||
const incoming = Array.isArray(body?.reports) ? body.reports.slice(0, 100) : [];
|
||||
let inserted = 0;
|
||||
const rejected: string[] = [];
|
||||
for (const r of incoming) {
|
||||
const pkg = field(r, "package");
|
||||
const clientId = field(r, "clientId", 80);
|
||||
const reportedAt = field(r, "date", 40);
|
||||
const result = field(r, "result");
|
||||
const rating = field(r, "rating");
|
||||
if (!pkg || !clientId || !reportedAt || (result && !RESULTS.includes(result)) ||
|
||||
(rating && !RATINGS.includes(rating))) {
|
||||
if (clientId) rejected.push(clientId);
|
||||
continue;
|
||||
}
|
||||
const dup = await ctx.db.reports.withIndex("by_client", (q) => q.eq("clientId", clientId)).first();
|
||||
if (dup) continue;
|
||||
await ctx.db.reports.insert({
|
||||
package: pkg, version: field(r, "version", 80), result, rating,
|
||||
notes: field(r, "notes", 1000), via: field(r, "via", 20), reportedAt,
|
||||
steamos: field(r, "steamos", 40), lepton: field(r, "lepton", 40),
|
||||
runtime: field(r, "runtime", 20), label: field(r, "label", 120),
|
||||
source: field(r, "source", 300), clientId
|
||||
});
|
||||
inserted++;
|
||||
}
|
||||
return json({ inserted, rejected, received: incoming.length });
|
||||
}),
|
||||
|
||||
status: endpoint({ method: "GET", path: "/v1/status" }, () => text("ok"))
|
||||
}
|
||||
});
|
||||
|
Before Width: | Height: | Size: 328 KiB |
|
Before Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 490 KiB |
|
Before Width: | Height: | Size: 196 KiB |
@@ -0,0 +1,185 @@
|
||||
# Frame Control for AI agents
|
||||
|
||||
**Documented interface:** Frame Control's own stdlib Python MCP adapter wraps
|
||||
its loopback HTTP API. No API key, hosted service, model SDK or third-party
|
||||
helper app is needed. The assistant is our HTML/Python implementation hosted
|
||||
in the platform Chromium browser. Its optional LLM endpoint is user configuration.
|
||||
Installing other apps is an optional management action, never a prerequisite.
|
||||
|
||||
## Connect an MCP client
|
||||
|
||||
The default MCP command starts a private HTTP backend on a free loopback port,
|
||||
with a fresh local access key. It stops that backend when the MCP client closes
|
||||
stdin or sends SIGTERM. It uses its own SSH control socket, so closing it does
|
||||
not close the desktop app's connection. No manually started server is needed.
|
||||
|
||||
Add this stdio server to your MCP client (use absolute paths):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"frame-control": {
|
||||
"command": "python3",
|
||||
"args": ["/absolute/path/frame-control/ui/frame_mcp.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For Codex, the equivalent registration is:
|
||||
|
||||
```sh
|
||||
codex mcp add frame-control -- python3 /absolute/path/frame-control/ui/frame_mcp.py
|
||||
```
|
||||
|
||||
New agent sessions load the entry. An already running session may need its MCP
|
||||
connections reloaded; registration does not retroactively add tools to its
|
||||
initial tool inventory. Keep the checkout at that path while it is registered.
|
||||
Use `codex mcp remove frame-control` to remove only this registration.
|
||||
|
||||
To reuse a running server instead, pass `--url http://127.0.0.1:47810`.
|
||||
The desktop app uses a random port; use that port with `--url`, or run the
|
||||
checkout server above. If the HTTP server uses `FRAME_UI_KEY`, pass the same
|
||||
value in the MCP process environment. This is local access control, not an LLM
|
||||
API key. The adapter only accepts loopback HTTP servers, refuses redirects and
|
||||
ignores environment proxies. Stdout contains newline-delimited JSON-RPC only.
|
||||
It supports MCP initialization, ping, tool listing and tool calls; no sampling,
|
||||
resources, prompts or streaming transport.
|
||||
|
||||
| Tool | Arguments | Effect |
|
||||
|---|---|---|
|
||||
| `computer_state` | none | Read-only gamescope window IDs/focus and bounded AT-SPI tree; reports incomplete observations |
|
||||
| `status` | none | Battery, services, installed games and Flatpaks |
|
||||
| `screenshot` | `view`: `headset` (default) or `desktop` | Returns PNG image content to the MCP client |
|
||||
| `job` | `id` | Background install status; poll until `done`, inspect `error` |
|
||||
| `launch` | `appid` | Launch an installed Steam app |
|
||||
| `install` / `uninstall` | `id` | Install from Flathub / remove a user Flatpak |
|
||||
| `send_text` | `text` | Frame desktop clipboard; desktop must be open |
|
||||
| `send_file` | `path` | File on the HTTP server computer, up to 16 MiB, copied to Frame `~/Downloads` |
|
||||
| `panel` | `id` | Launch an installed Flatpak as a panel using the existing launcher |
|
||||
| `power` | `action`: `suspend`, `reboot`, `poweroff` | Open a terminal for the user to enter the sudo password |
|
||||
| `keep_awake` | `action`: `on`, `off`, `status` | Optional keep-awake script interface |
|
||||
|
||||
Only install free software with its developer's consent. There is no purchase,
|
||||
entitlement bypass or arbitrary shell tool. `install` returns a background job
|
||||
ID; it does not claim the installation has finished. APK and sideloaded title
|
||||
installs remain in the main UI for now.
|
||||
|
||||
### Approval is a separate human action
|
||||
|
||||
Every mutation first returns an `approvalUrl`, exact action and `confirmation`
|
||||
token. Ask the user to open that URL and choose **Approve this action** or
|
||||
**Reject**. Then repeat the same tool and arguments with the token in
|
||||
`confirmation`. The server refuses execution before approval, changed arguments,
|
||||
expired tokens and reuse. A file approval binds the content hash as well as the
|
||||
path. Approvals last five minutes and disappear when the HTTP server restarts.
|
||||
A failed execution also consumes the approval; review a fresh request to retry.
|
||||
The panel does not execute an action merely because it was approved.
|
||||
|
||||
MCP has no approval tool. This is protection against accidental model tool
|
||||
calls, not a sandbox against a client with independent shell/HTTP access to your
|
||||
computer. Grant the MCP client only the access you intend. Status, captures and computer-state observations
|
||||
are returned directly to that client, which may forward them to its configured
|
||||
model. The assistant's separate opt-in does not govern an external MCP client.
|
||||
|
||||
Power still requires the existing password prompt in a local terminal. MCP
|
||||
never receives passwords. Power via `FRAME_LOCAL=1` is unsupported: use the main
|
||||
UI. The panel launcher and keep-awake adapter require zsh on the computer.
|
||||
|
||||
[PR #16](https://github.com/saphid/frame-control/pull/16) owns
|
||||
`scripts/keep-awake.sh on|off|status`. This branch does not copy or change it.
|
||||
Until that script is present, the tool reports it unavailable. Keep-awake is
|
||||
never automatic: `on` changes the shared idle timers; explicitly approve `off`
|
||||
to restore them after work. It is not a per-agent lease; coordinate with other
|
||||
users. No changes are made to the analytics/update interfaces in
|
||||
[PR #17](https://github.com/saphid/frame-control/pull/17). Prompts, keys, model
|
||||
replies, screenshots and approval payloads are not sent to analytics.
|
||||
|
||||
## Assistant panel
|
||||
|
||||
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
|
||||
To put the same page in the headset, with the HTTP server still running:
|
||||
|
||||
```sh
|
||||
python3 scripts/assistant-on-frame.py --port 47810
|
||||
```
|
||||
|
||||
This starts an SSH reverse forward bound to Frame loopback (port 47812 by
|
||||
default), then a dedicated Chromium profile tagged as a SteamVR panel. Keep the
|
||||
command running. Ctrl-C closes this browser profile and the tunnel; it leaves
|
||||
other Chromium windows and the existing HTTP server alone. A failed cleanup
|
||||
prints the temporary profile path so it can be removed when the Frame returns.
|
||||
Use `--frame-port` if the default is busy. Chromium must already be available as
|
||||
`org.chromium.Chromium`; the launcher never installs anything automatically.
|
||||
Place the panel with SteamVR's normal docking controls.
|
||||
|
||||
Enter your full **chat-completions endpoint**, model name and optional key.
|
||||
An OpenAI-compatible local server works without a key; no OpenAI account is
|
||||
required. HTTP is allowed only on loopback; other endpoints require HTTPS.
|
||||
Loopback refers to the computer running the HTTP server, even in the headset.
|
||||
Endpoints with embedded credentials, query strings or redirects are refused.
|
||||
|
||||
Check the message consent box and press **Send message**. Screenshot context is
|
||||
a separate unchecked box and sends one fresh capture with that request. Both
|
||||
boxes reset after sending, and changing endpoint/model revokes consent. Nothing
|
||||
is sent when opening the page or entering configuration. There is no model
|
||||
list fetch, saved history, automatic screenshot capture or assistant telemetry.
|
||||
Each send is independent: previous messages and replies are not included.
|
||||
|
||||
Configuration, credentials and chat remain in page memory; close/reload the page
|
||||
or choose **Clear everything** to clear them. A request already sent cannot be
|
||||
recalled. Only the chosen endpoint gets the request; proxy environment variables
|
||||
and redirects are disabled. Its privacy and retention policy still applies.
|
||||
Replies are plain text and cannot call tools or operate the Frame. A model must
|
||||
support image inputs to accept screenshot context.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
**Verified 2026-09-28, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** loopback HTTP
|
||||
status through an SSH reverse tunnel; platform Chromium created a separate
|
||||
SteamVR panel (confirmed in `GAMESCOPE_FOCUSABLE_APPS`); headset capture returned
|
||||
a PNG. These checks preceded the UI implementation. No power or global settings
|
||||
were changed.
|
||||
|
||||
**Inferred:** visual comfort and controller keyboard usability while wearing
|
||||
the headset; panel creation in gamescope alone does not establish these.
|
||||
Windows/Linux launcher support, live third-party model endpoints, installs,
|
||||
uninstalls, power and keep-awake changes are not covered by that feasibility
|
||||
check. See the PR for the final unit and end-to-end results.
|
||||
|
||||
**Verified end to end on the same Frame/build (2026-09-28):** a stdio MCP client
|
||||
initialized, read status, retrieved a headset PNG, and transferred a test file
|
||||
only after approval through the Chromium page. Remote file bytes matched;
|
||||
reusing the confirmation was rejected. The actual headset Chromium page sent
|
||||
text and then separately opted-in image context to a local test endpoint and
|
||||
displayed its replies. Without consent there were zero endpoint requests.
|
||||
The test endpoint returned canned replies: model inference and a live external
|
||||
provider remain **unverified**. The launcher’s Ctrl-C cleanup was checked;
|
||||
profiles, SSH tunnels and the test file were removed. No installs, removals,
|
||||
launches of user games, power operations or keep-awake changes were performed.
|
||||
|
||||
**Verified locally:** unit coverage includes the stdio subprocess, approval
|
||||
binding/expiry/replay/concurrency, file-change rejection, and a real local HTTP
|
||||
endpoint for opt-in, text/image payloads and redirect refusal. Fake-Frame
|
||||
regressions are in `tests/e2e/test_agents.py`; local Docker execution was blocked
|
||||
because the Docker daemon was unavailable. The ARM64 fake-Frame CI job passed
|
||||
on this branch (run 36421345682).
|
||||
|
||||
**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
|
||||
browser profile and SSH tunnel and remove the profile and panel log.
|
||||
|
||||

|
||||
|
||||
## Computer-use coverage
|
||||
|
||||
MCP is the tool transport, not a limit on what an agent can do. A screenshot,
|
||||
accessibility snapshot, click or keystroke can all be MCP tools when we have a
|
||||
reliable underlying implementation. See [the investigation](computer-use.md)
|
||||
for the verified boundaries. `computer_state` adds observation, not an input
|
||||
channel: it cannot click an approval button or send keyboard/mouse events.
|
||||
|
||||
**Verified 2026-09-29, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** the command saved
|
||||
by `codex mcp add` launched without a prestarted server, negotiated MCP, listed
|
||||
12 tools, read live Frame status and returned X11 window state plus AT-SPI
|
||||
observations. It exited 0 at EOF. Steam's accessibility tree had inaccessible
|
||||
children, reported as `incomplete: true`; this is not a complete actionable UI.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Announcing changes
|
||||
|
||||
How we tell people about Frame Control features and fixes as they merge. The
|
||||
same few sentences feed the X post, the release notes and the website, so they
|
||||
are written once, in the pull request, while the change is fresh.
|
||||
|
||||
## What gets announced
|
||||
|
||||
| Kind | Announce? | Example |
|
||||
|---|---|---|
|
||||
| **New** — something you can now do | Yes, its own post | Stream Mac windows into the Frame as panels |
|
||||
| **Better** — something existing got noticeably easier, faster or wider | Yes, its own post or a roundup | APKs install without the Android SDK |
|
||||
| **Fixed** — something broken that users hit | Yes if people reported it or it blocked a flow; otherwise the next roundup | Mac mirror showed a zoomed-in corner |
|
||||
| **Release** — a tagged build | Always, one post linking the release | Frame Control 0.3.1 |
|
||||
| Tests, refactors, CI, docs-only, website polish | No | Fake Frame tests, screenshot crop |
|
||||
|
||||
If a change isn't worth a sentence to someone who owns a Frame, it isn't
|
||||
announced.
|
||||
|
||||
## The voice
|
||||
|
||||
Write it the way the README and release notes already read.
|
||||
|
||||
- **Lead with what the person can now do**, in their words: "Install older
|
||||
versions of an app when the newest won't run on the Frame", not "Add APK
|
||||
version fallback resolver".
|
||||
- **Plain and specific.** Name the thing, give the number: "about 30 fps",
|
||||
"4,500 apps", "up to 8 older versions". No "blazing", "game-changing",
|
||||
"excited to announce", "huge", or exclamation marks.
|
||||
- **Say where it works.** Platforms and what it was tested on, briefly:
|
||||
"Tested on a real Frame from macOS 27." Don't claim what wasn't tested.
|
||||
- **Say the catch.** If it needs a setup step, an unsigned build, or only works
|
||||
on one OS, say so in the same post.
|
||||
- **Sentence case**, full sentences, British spelling to match the docs.
|
||||
Contractions are fine.
|
||||
- **No emoji in the text.** One image, GIF or short clip carries the tone
|
||||
instead. The only symbol is the kind label below.
|
||||
- **Unofficial, always.** Never imply Valve made or endorses it. Say "Steam
|
||||
Frame" for the headset and "Frame Control" for the app.
|
||||
- **Credit people.** If a user reported the bug or suggested the feature and is
|
||||
happy to be named, thank them by handle.
|
||||
|
||||
## The formats
|
||||
|
||||
Every announceable PR ends with an `## Announcement` section holding these.
|
||||
The reviewer checks it like code.
|
||||
|
||||
### 1. The post (X, and any other social account)
|
||||
|
||||
```
|
||||
<Kind>: <what you can do now, one sentence>
|
||||
|
||||
<one or two sentences: how it works, the catch, or what it was tested on>
|
||||
|
||||
<link>
|
||||
```
|
||||
|
||||
- `<Kind>` is `New`, `Better` or `Fixed`.
|
||||
- 280 characters maximum including the link (X counts any link as 23).
|
||||
- One link: the release if it has shipped, otherwise the PR.
|
||||
- One visual when the change is visible: a screenshot from the app, a GIF, or a
|
||||
short clip from the headset. Alt text describes what it shows.
|
||||
- No hashtags, except `#SteamFrame` on releases and on posts about something
|
||||
new, because people search for it.
|
||||
|
||||
### 2. The release-note line
|
||||
|
||||
One bullet under **New in x.y.z**, same as the current release notes: the
|
||||
first half of the post's first sentence, no kind label, no link.
|
||||
|
||||
### 3. Release post
|
||||
|
||||
```
|
||||
Frame Control <version>: <the headline change>
|
||||
|
||||
<one sentence on the headline change>. Also: <two or three short items>.
|
||||
|
||||
Windows, macOS and Linux: <release link>
|
||||
#SteamFrame
|
||||
```
|
||||
|
||||
The release title on GitHub uses the same `Frame Control <version>: <headline>`
|
||||
line, as 0.3.0 and 0.3.1 already do.
|
||||
|
||||
### Roundups
|
||||
|
||||
Small fixes that don't earn their own post wait for a roundup, posted with the
|
||||
next release or when three or more have piled up:
|
||||
|
||||
```
|
||||
Fixed in Frame Control this week:
|
||||
- <fix>
|
||||
- <fix>
|
||||
- <fix>
|
||||
|
||||
<link>
|
||||
```
|
||||
|
||||
## Examples from what has already merged
|
||||
|
||||
**#11, older APK versions**
|
||||
|
||||
```
|
||||
New: when an Android app is too new for the Frame, Frame Control now offers
|
||||
older versions that will install.
|
||||
|
||||
It checks F-Droid, its archive and IzzyOnDroid, and verifies each download
|
||||
before it goes on the headset.
|
||||
|
||||
https://github.com/saphid/steam-frame/pull/11
|
||||
```
|
||||
|
||||
**#8, Mac mirror fixes**
|
||||
|
||||
```
|
||||
Fixed: mirroring your Mac into the Steam Frame now fits the whole desktop in
|
||||
the panel, asks for the right password, and shows the cursor.
|
||||
|
||||
Tested end to end on a real Frame from macOS 27.
|
||||
|
||||
https://github.com/saphid/steam-frame/pull/8
|
||||
```
|
||||
|
||||
**v0.3.1**
|
||||
|
||||
```
|
||||
Frame Control 0.3.1: install APKs without the Android SDK
|
||||
|
||||
Frame Control now reads APK files itself, so there's nothing extra to install.
|
||||
Also: Linux and Windows game sideloading, and one-click install links.
|
||||
|
||||
Windows, macOS and Linux: https://github.com/saphid/steam-frame/releases/tag/v0.3.1
|
||||
#SteamFrame
|
||||
```
|
||||
|
||||
## Posting
|
||||
|
||||
Nothing is posted without a person approving it. The flow is:
|
||||
|
||||
1. The PR carries its `## Announcement` section.
|
||||
2. On merge, the post is drafted from that section (manually for now).
|
||||
3. Alex approves or edits it, then it's posted from the project account.
|
||||
4. Replies and questions that turn out to be bugs become GitHub issues labelled
|
||||
`feedback`, same as the website form.
|
||||
@@ -0,0 +1,333 @@
|
||||
# Installing APKs (Lepton)
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md). Android apps run
|
||||
in **Lepton**, Valve's Waydroid-based container. Lepton is built for games,
|
||||
not general Android use
|
||||
([GamingOnLinux](https://www.gamingonlinux.com/2026/09/lepton-from-valve-to-run-android-games-on-linux-is-now-open-source/)).
|
||||
|
||||
**VR streaming clients:** WiVRn 26.9 and ALVR 20.14.1 install, but both fail
|
||||
OpenXR instance creation on the checked Frame because its Android runtime lacks
|
||||
`XR_KHR_convert_timespec_time` (**verified** 2026-09-28, SteamOS 0.4.1,
|
||||
BUILD_ID 20260925.6191901). They are not Frame Control dependencies. See
|
||||
[the feasibility results and options](linux-vr-streaming.md).
|
||||
|
||||
## Install from the Mac: one app, one Lepton instance (verified 2026-09-25)
|
||||
|
||||
Use Frame Control's **Android apps** section (search, Install, Test, Report), drop
|
||||
an `.apk` on **Send to Frame**, or:
|
||||
|
||||
```sh
|
||||
./scripts/install-apk.sh some-app.apk # own instance, Steam shortcut
|
||||
python3 ui/frame_android.py list|launch|stop|remove|probe <package>
|
||||
```
|
||||
|
||||
Each APK becomes its own app, the way T3 Code is set up (see the instance
|
||||
section below), instead of going into Lepton Development:
|
||||
|
||||
1. `ui/frame_apk.py` reads the package, label, version, ABIs and icon
|
||||
(a stdlib parser of the binary manifest and resource table, so no Android SDK). APKs that need
|
||||
API > 30 or have no `arm64-v8a` build are refused.
|
||||
2. The APK, `frame/android/lepton-app.sh` (as `launch.sh`), `instance.id`,
|
||||
`meta.json`, the icon and the `lepton-show-flatscreen` marker go to
|
||||
`~/Applications/Android/<package>/` on the Frame.
|
||||
3. A non-Steam shortcut is added through Steam's CEF debug port
|
||||
(`frame/android/steam_shortcuts.py`), with no Steam restart.
|
||||
4. Launching the shortcut runs Lepton directly with `SteamAppId` set to the
|
||||
instance id (`2800000000 + crc32(package) % 70000000`). That's a
|
||||
"steamlaunch" context, so app data in `compatdata/<id>/internal` survives
|
||||
restarts and updates, and each app gets its own SteamVR panel. Several can
|
||||
run at once alongside Lepton Development, each in its own container
|
||||
(`lepton-steamlaunch-<id>`, ADB on 5556, 5557, …).
|
||||
|
||||
Verified with AntennaPod and Tabletop Tools: installed in about 7 s, launched
|
||||
from the shortcut, stopped, and relaunched with their data intact. ADB and
|
||||
Lepton Development aren't involved.
|
||||
|
||||
`--dev` keeps the old path: ADB into Lepton Development over an SSH tunnel
|
||||
(first free Mac port from 15555), which starts Lepton Development if needed.
|
||||
Apps installed that way are deleted when it exits (see below). No pairing or
|
||||
"Allow debugging?" prompt is needed for either path.
|
||||
|
||||
Lepton Development must be installed once. Over SSH,
|
||||
`ssh frame 'steam steam://install/3056000'` queues it, but the install still
|
||||
needs to be confirmed or started in the headset.
|
||||
|
||||
## When an app needs a newer Android
|
||||
|
||||
Lepton is Android 11 (API 30), with arm64-v8a only. If Frame Control refuses
|
||||
an APK, it shows compatible versions from F-Droid's main and archive repos and
|
||||
IzzyOnDroid. It shows at most eight version names, newest first, preferring an
|
||||
arm64-only build, and says how many compatible builds it found in total.
|
||||
Each index is reduced to its compatible builds once a day and cached (about
|
||||
16 MB). The first lookup takes about 30 s and 100 MB of memory; later ones are
|
||||
instant.
|
||||
Choose **Install** to download a listed version, verify its SHA-256 against
|
||||
the index, and install it as its own app.
|
||||
|
||||
You can also inspect a file or look up a package from the command line:
|
||||
|
||||
```sh
|
||||
python3 ui/frame_android.py info some-app.apk
|
||||
python3 ui/frame_android.py versions some-app.apk
|
||||
python3 ui/frame_android.py versions org.example.app
|
||||
```
|
||||
|
||||
The search links open APKMirror, APKPure, Uptodown, F-Droid and GitHub. Pick a
|
||||
version whose minimum is Android 11 or lower and that has an arm64-v8a build
|
||||
(or no native code). Frame Control does not fetch APKs from those search sites.
|
||||
Older versions may lack fixes, and being installable does not guarantee an
|
||||
app will run: see the missing services below. Android may refuse a downgrade
|
||||
or an update signed by a different publisher; removing the app deletes its data.
|
||||
|
||||
## Installed apps disappear when Lepton Development closes (verified 2026-09-25)
|
||||
|
||||
Lepton Development runs in a throwaway "dev" context. When it exits for any
|
||||
reason (you close it, or it crashes), the launcher script
|
||||
`~/.local/share/Steam/steamapps/common/Lepton/lepton` calls
|
||||
`clear_baked_app_data "non steamlaunch container"` and **deletes every app
|
||||
installed over ADB**. The journal shows `Clearing baked app data due to non
|
||||
steamlaunch container`, and `pm list packages -3` is empty afterwards.
|
||||
|
||||
The script skips the wipe when `LEPTON_NO_CLEANUP` is set
|
||||
(`liblepton/liblepton.sh`, `clear_baked_app_data`). To keep your apps, set
|
||||
Lepton Development's Steam launch options to:
|
||||
|
||||
```
|
||||
LEPTON_NO_CLEANUP=1 %command%
|
||||
```
|
||||
|
||||
(Steam → Library → Lepton Development → Properties → Launch Options.) Inferred
|
||||
from the script, not yet tested across a restart.
|
||||
|
||||
## Which APKs work (verified 2026-09-25, SteamOS build 20260922.6101926)
|
||||
|
||||
Lepton is LineageOS 18.1 (`lepton_arm64_only`): Android 11, API 30,
|
||||
`abilist=arm64-v8a` only, Mesa (Turnip, Adreno 750) with GLES 3.2 and
|
||||
Vulkan 1.4. About 30 F-Droid apps were installed and opened on the Frame to
|
||||
check each rule. The results are in the compatibility database (see compat-db/README.md).
|
||||
|
||||
**Won't install** (the installer refuses):
|
||||
|
||||
| Rule | Seen on device |
|
||||
|---|---|
|
||||
| `minSdkVersion` > 30 | `INSTALL_FAILED_OLDER_SDK: Requires newer sdk version #33 (current version is #30)` |
|
||||
| Native code without `arm64-v8a` (32-bit ARM or x86 only) | `INSTALL_FAILED_NO_MATCHING_ABIS` |
|
||||
|
||||
**Crash on launch.** Lepton has no `clipboard` system service, so
|
||||
`getSystemService(CLIPBOARD_SERVICE)` returns null:
|
||||
|
||||
| What | Result |
|
||||
|---|---|
|
||||
| **Jetpack Compose UI < 1.11** | Crashes as soon as a Compose screen appears: `null cannot be cast to non-null type android.content.ClipboardManager` in `AndroidComposeView`. Seen with 1.5, 1.6, 1.7, 1.8 and 1.10 apps. |
|
||||
| Jetpack Compose UI 1.11, 1.12, 1.13 | **Works.** Six apps opened fine, including Aurora Store and NewPipe. |
|
||||
| Old Compose, but the first screen uses classic Views | Opens (FoCal, Compose 1.3), and crashes only on Compose screens |
|
||||
| SDL2 apps, including Kivy | Crash: SDL calls `ClipboardManager.addPrimaryClipChangedListener` at start-up |
|
||||
| Godot 4.3 | Crashes (clipboard cast). Godot 4.6.1 works. |
|
||||
|
||||
**Works:** classic Android Views apps, Flutter (2 of 2), libGDX (2 of 2),
|
||||
Compose 1.11+, Firebase-using apps. React Native: 2 of 3 opened; one
|
||||
(controlloid) died with SIGSEGV on the Hermes JS thread. A Qt 6 app
|
||||
(AusweisApp) failed on a missing libc++ symbol.
|
||||
|
||||
**Missing pieces:** an app may open but fail when you use one of these:
|
||||
|
||||
- No Google Play Services.
|
||||
- No activity for `VIEW` of web links, `OPEN_DOCUMENT`/`GET_CONTENT` (no file
|
||||
picker), `IMAGE_CAPTURE`, or text-to-speech. WebView (Chromium 152) is there.
|
||||
- No Downloads or Contacts providers.
|
||||
- Missing system services also include `accessibility`, `vibrator`, `phone`,
|
||||
`print`, `usb`, `nfc` and `autofill`. The declared features lack
|
||||
`touchscreen.multitouch`, `bluetooth_le` and `telephony`.
|
||||
- No on-screen keyboard (IME) is installed. How text entry reaches Android
|
||||
apps in the headset hasn't been checked.
|
||||
|
||||
**Lepton itself can crash.** Three times during testing, the graphics HAL
|
||||
(`android.hardware.graphics.composer@2.1-service`) aborted right after an app
|
||||
crashed. SurfaceFlinger died, the whole `lepton-dev` container exited, and the
|
||||
installed apps were wiped (see above). A retry of the same app worked, so it's
|
||||
intermittent rather than app-specific.
|
||||
|
||||
**How good are the predictions?** In a random sample of 12 apps rated "Should
|
||||
work", all 12 installed, opened and were still running 12 s later (two needed
|
||||
a retry because Lepton crashed mid-install). "Opened" isn't the same as fully
|
||||
working: see the missing pieces above.
|
||||
|
||||
## Catalogue and compatibility reports
|
||||
|
||||
Frame Control's **Android apps** section lists every F-Droid app with a
|
||||
verdict: Works on Frame, Should work, Might work, Probably crashes, or Won't
|
||||
work, with the reasons. As of 2026-09-25 that's 30 working, 3,323 should work,
|
||||
223 might work, 723 probably crash (mostly Compose < 1.11) and 156 won't
|
||||
install. Each app is rated on its newest version that Lepton can install,
|
||||
because F-Droid often publishes separate per-ABI builds and the newest is
|
||||
frequently x86_64. **Install** downloads the APK (SHA-256 checked against the
|
||||
F-Droid index) and sets it up as its own instance.
|
||||
|
||||
No ProtonDB-style database for sideloaded Android apps on the Frame existed
|
||||
as of 2026-09-25. [Steam Frame Hub](https://verified.steamframehub.com/)
|
||||
collects community reports for Steam games only and has no public API, and
|
||||
Valve's "Great on Frame" badges and each Steam app's `recommended_runtime`
|
||||
(for example `lepton-stable`) also cover Steam games only
|
||||
([VR.org](https://vr.org/articles/steam-frame-lepton-android-runtime-52-of-130-certified-2026)).
|
||||
So Frame Control keeps its own. Your reports are saved on your Mac; the
|
||||
maintainer's copy also syncs them to a private Lakebed database.
|
||||
**Test** records whether the app stays up in its own instance, and **Report**
|
||||
(for any APK, F-Droid or not) records whether it worked, how it was run, where it came from, and notes, each with the SteamOS and Lepton build ids.
|
||||
See
|
||||
[compat-db/README.md](../compat-db/README.md) and
|
||||
[apk-catalog/README.md](../apk-catalog/README.md).
|
||||
|
||||
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
|
||||
|
||||
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
|
||||
newest archived build without Compose, runs, loads the full catalogue (the
|
||||
first repo update takes about 90s), and can install apps. F-Droid 2.0 uses
|
||||
Compose 1.12, so it should work, but it hasn't been tried. The catalogue's
|
||||
Install button for F-Droid installs 1.17.2. To let F-Droid install apps without
|
||||
a settings prompt, with the tunnel open:
|
||||
|
||||
```sh
|
||||
adb -s $S shell appops set org.fdroid.fdroid REQUEST_INSTALL_PACKAGES allow
|
||||
```
|
||||
|
||||
## Handy commands
|
||||
|
||||
Open a tunnel by hand (use any free local port):
|
||||
|
||||
```sh
|
||||
ssh -f -N -M -S /tmp/frame-adb.sock -L 127.0.0.1:15555:127.0.0.1:5555 frame
|
||||
adb connect 127.0.0.1:15555
|
||||
S=127.0.0.1:15555
|
||||
# when done: adb disconnect $S; ssh -S /tmp/frame-adb.sock -O exit frame
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```sh
|
||||
adb -s $S shell pm list packages -3 # installed third-party apps
|
||||
adb -s $S shell monkey -p <pkg> -c android.intent.category.LAUNCHER 1
|
||||
# If monkey exits with -5 (it did for T3 Code), start the activity directly:
|
||||
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)"
|
||||
adb -s $S logcat -d -b crash # why an app died
|
||||
adb -s $S uninstall <pkg>
|
||||
adb -s $S exec-out screencap -p > shot.png # the Lepton window
|
||||
```
|
||||
|
||||
## Reaching a Mac service from Lepton (T3 Code v2, verified 2026-09-25)
|
||||
|
||||
Lepton runs in podman with `pasta` networking. It has **its own loopback**, so
|
||||
a port on the Frame's `127.0.0.1` isn't visible as `127.0.0.1` inside Android.
|
||||
But pasta runs with `--map-gw`, so the **gateway address inside Lepton
|
||||
(`192.168.1.1` on the home network) maps to the Frame host's loopback**.
|
||||
|
||||
T3 Code v2 on the Mac listens only on `127.0.0.1:3873`. To reach it:
|
||||
|
||||
1. Keep `ssh -N -R 127.0.0.1:3873:127.0.0.1:3873 frame` running on the Mac,
|
||||
for example from a LaunchAgent with `KeepAlive`, so launchd restarts it if
|
||||
it drops.
|
||||
2. In the app on the Frame, the environment host is `192.168.1.1:3873`.
|
||||
|
||||
The app on the Frame was built from the T3 Code v2 nightly source with `expo prebuild` and `gradlew assembleRelease
|
||||
-PreactNativeArchitectures=arm64-v8a`, using Homebrew `openjdk@17` and the
|
||||
`android-commandlinetools` SDK. It's signed with the debug key.
|
||||
|
||||
To pair again, issue a one-time code on the Mac and type it into
|
||||
**Add environment**:
|
||||
|
||||
```sh
|
||||
A="/Applications/T3 Code (V2 Preview).app"
|
||||
ELECTRON_RUN_AS_NODE=1 "$A/Contents/MacOS/T3 Code (Alpha)" \
|
||||
"$A/Contents/Resources/app.asar/apps/server/dist/bin.mjs" \
|
||||
auth pairing create --base-dir "$HOME/.t3-v2" --ttl 15m --label "Steam Frame"
|
||||
```
|
||||
|
||||
The app is deleted whenever Lepton Development closes (see above), so reinstall
|
||||
it afterwards or set `LEPTON_NO_CLEANUP=1`. The gateway address comes from the Frame's network when Lepton starts. On a
|
||||
different network, check it with `adb shell ip route` and edit the host.
|
||||
|
||||
## Lepton Development forgets apps; give an app its own instance (verified 2026-09-25)
|
||||
|
||||
**Lepton Development wipes every installed app when it exits.** Its launcher
|
||||
logs `Clearing baked app data due to non steamlaunch container`, unless
|
||||
`LEPTON_NO_CLEANUP` is set. A Steam-style launch (with `SteamAppId` set) is a
|
||||
"steamlaunch" context and keeps its data:
|
||||
|
||||
- App data lives in `STEAM_COMPAT_DATA_PATH/internal/<package>` (symlinked to
|
||||
`/data/data/<package>`) and survives everything, including APK updates.
|
||||
- `STEAM_COMPAT_DATA_PATH/baked` is Lepton's Android snapshot. It's rebuilt when
|
||||
the APK changes, or when the app exits within 30 seconds of starting.
|
||||
- `STEAM_COMPAT_DATA_PATH` must be under `~/.local/share/Steam` (use
|
||||
`steamapps/compatdata/<id>`). Only that tree is mounted in the container. Put
|
||||
it anywhere else and the symlinks dangle, so the app crashes with
|
||||
`ENOENT` on its first file write.
|
||||
- Lepton runs apps headless unless an empty `lepton-show-flatscreen` file sits
|
||||
next to the APK (`STEAM_COMPAT_INSTALL_PATH`).
|
||||
- Several instances can run at once. Each gets ADB on `5555 + offset`
|
||||
(`podman ps --format "{{.Names}} {{.Labels.adb_port}}"`).
|
||||
- Outside Steam, Lepton's `setpgid --foreground` re-exec fails with no
|
||||
terminal. Set `IS_PARENT=true` and start it with `setsid --wait`.
|
||||
|
||||
[`frame/t3code/launch.sh`](../frame/t3code/launch.sh) does all this for T3
|
||||
Code (context `steamlaunch-2873873873`; ADB is the first free `5555 + n`, e.g. 5557). It needs the Steam client
|
||||
running (it mounts `~/.steam/steam.pipe`).
|
||||
|
||||
**T3 Code in the Steam library (verified 2026-09-25).** The wrapper lives on
|
||||
the Frame at `~/Applications/T3Code/launch.sh`, with `t3code.apk` and the
|
||||
flatscreen marker next to it. It's a non-Steam shortcut called "T3 Code"
|
||||
(shortcut app id `3130509679`). Launching it from Steam gets its own SteamVR
|
||||
panel, `valve.steam.desktopgame.3130509679`, and opens already paired.
|
||||
|
||||
- The shortcut was added without restarting Steam, through Steam's CEF debug
|
||||
port (`127.0.0.1:8080` on the Frame, target `SharedJSContext`):
|
||||
`SteamClient.Apps.AddShortcut(name, exe, "", "")`, then `SetShortcutName`
|
||||
and `SetShortcutStartDir`. `steam steam://addnonsteamgame/<path>` only logged
|
||||
the URL and added nothing.
|
||||
- To launch it over SSH: `steam steam://rungameid/13445436691150012416`, which
|
||||
is `(3130509679 << 32) | 0x02000000`.
|
||||
- Steam sets `STEAM_FOSSILIZE_DUMP_PATH` for shortcut launches but not
|
||||
`STEAM_COMPAT_SHADER_PATH`. Lepton then dies with "unbound variable", so the
|
||||
wrapper sets both.
|
||||
- To update T3, replace `t3code.apk`. Lepton rebuilds its snapshot on the next
|
||||
launch, and the pairing survives.
|
||||
|
||||
## Crashing apps can take down the headset session (verified 2026-09-25)
|
||||
|
||||
Some apps crash Android's graphics composer HAL, which kills the Lepton
|
||||
container. On 2026-09-25 a batch crash-test also coincided with `steamvr.service`
|
||||
restarting "on client request", which stops and SIGKILLs `gamescope-session`.
|
||||
After one of those kills, gamescope crash-looped about once a second on
|
||||
`rendervulkan.cpp:2181 ... Assertion '!modifiers.empty()'` because it kept
|
||||
attaching to the SteamVR processes orphaned from the dead session. The fix
|
||||
without sudo was to `for p in vrdashboard vrcompositor vrserver; do pkill -TERM -x $p; done` (pkill takes one pattern). The
|
||||
next session then started SteamVR fresh and recovered within a minute.
|
||||
|
||||
## Android display: resolution, UI scale, text size (verified 2026-09-25, SteamOS 0.3.0, build 20260922.6101926)
|
||||
|
||||
Each running Lepton instance has its own ADB port on the Frame, assigned at
|
||||
launch: 5555 is Lepton Development, and own-instance apps take the next free
|
||||
port (T3 Code was on 5557). Find them with `ss -ltn` (5555–5599) and identify
|
||||
each with `pm list packages -3`. Both instances reported `Physical size:
|
||||
1920x1080`. Their densities were 180 dpi (Lepton Development) and 213 dpi
|
||||
(T3 Code), and `settings get system font_scale` returned `null` (1.0).
|
||||
|
||||
These all apply immediately and read back as set. Tested on Lepton Development
|
||||
only:
|
||||
|
||||
```sh
|
||||
adb -s $S shell wm size 2560x1440 # or: wm size reset
|
||||
adb -s $S shell wm density 240 # or: wm density reset
|
||||
adb -s $S shell settings put system font_scale 1.15
|
||||
adb -s $S shell settings delete system font_scale
|
||||
```
|
||||
|
||||
After a `wm` reset, Android writes `font_scale=1.0` back asynchronously, so a
|
||||
single delete that follows one reads back `1.0`. A second delete a second later
|
||||
leaves it `null`. Frame Control's **Android display** card does this for you
|
||||
(`/api/android/display`).
|
||||
|
||||
**Inferred, not yet checked in the headset:** a bigger Android resolution with
|
||||
density scaled to match (2560×1440 at 4/3 of the density) gives sharper text,
|
||||
because gamescope scales Lepton's surface to fit the same panel. Also unverified:
|
||||
whether the settings survive the app or its Lepton instance relaunching.
|
||||
Lepton Development rebuilds its Android data on exit, so there they probably
|
||||
don't.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Computer use through Frame Control MCP
|
||||
|
||||
The MCP transport can carry semantic actions or visual computer-use actions.
|
||||
The limits are the Frame's underlying interfaces, permissions and whether an
|
||||
action can be targeted and verified. A stereoscopic headset screenshot alone
|
||||
is not a reliable coordinate system for clicking a particular app window.
|
||||
|
||||
## What exists, and the right route
|
||||
|
||||
| Surface | Evidence and route | Remaining work or boundary |
|
||||
|---|---|---|
|
||||
| Frame management | **Verified:** existing SSH/HTTP operations for status, capture and file transfer work through MCP. Typed install/launch/power tools wrap the existing API. | Extend typed operations before adding generic mouse automation. Preserve explicit approval for consequential changes. |
|
||||
| App/window observation | **Verified 2026-09-29:** `computer_state` reads gamescope X11 window/app/process triples, focused app and the installed AT-SPI library. | Bounded to 96 accessible nodes and six levels. Trees may be truncated, stale, hidden or incomplete. Snapshot paths and XIDs are observations, never durable action permissions. |
|
||||
| Chromium page content | **Verified previously:** the assistant rendered and could be exercised through CDP in an isolated Frame Chromium profile. | A shipped click/type surface needs exact owned browser/target binding, fresh element references, lifecycle cleanup, consent and post-action readback. Do not expose unrestricted JavaScript or attach to arbitrary existing profiles automatically. |
|
||||
| Steam UI | **Verified 2026-09-29:** the AT-SPI service listed the Steam client's Chromium process and frame nodes, but child traversal was incomplete. Existing `frame_steam.py` uses Steam's loopback CDP endpoint for specific operations. | Prefer those narrow Steam interfaces. Presence of AT-SPI does not prove controls are actionable, and generic pointer injection is not proved for VR menus. |
|
||||
| Other Linux apps | **Verified 2026-09-29:** Frame ships libX11, libXtst and libatspi; `/dev/uinput` is writable by the current user. | Library presence and access permissions do not prove that a game accepts input. Global virtual input can affect whichever app has focus. Do not ship a blind keyboard/mouse tool on this evidence alone. |
|
||||
| Panel focus and layouts | **Documented in [#41](https://github.com/saphid/frame-control/pull/41):** `POST /api/panels` accepts `list`, `focus` and `open`. Focus was verified there. | Reuse that owned interface after integration. Its tested gamescope-owned overlay transform setters return `PermissionDenied`; no reliable saved spatial-layout interface was established. Do not duplicate its implementation here. |
|
||||
| Shared keyboard/trackpad | **Documented in [#19](https://github.com/saphid/frame-control/pull/19):** `/api/input` supplies state/start and event submission, implemented with a bundled KDE Connect daemon. | This branch does not import, launch or depend on that daemon. The user's own-implementation rule remains authoritative. A first-party input implementation or permitted bundled-library route needs its own delivery evidence before MCP integration. |
|
||||
| Physical/device boundaries | **Documented:** an asleep Frame may be off the network; power authorization can require the user's password; physical pairing and headset fit/comfort require the user. | MCP cannot bypass offline hardware, consent, compositor permissions or physical verification. Keep explicit human handoffs. |
|
||||
|
||||
## Reusing the existing computer-use work
|
||||
|
||||
**Documented:** the installed `cua-driver` skill has the right control pattern:
|
||||
observe an exact window, use a semantic target if available, fall back to pixels
|
||||
from that same snapshot, then read back the result. Its browser route requires
|
||||
an exact process/window/target binding and session-scoped element references.
|
||||
Those are useful design rules for Frame tools.
|
||||
|
||||
**Verified locally 2026-09-29:** `cua-driver describe get_window_state` describes
|
||||
host-local process/window IDs and macOS AX inspection. It does not establish an
|
||||
SSH Frame target. The installed skill's advertised Linux companion file is
|
||||
missing. A native ARM64 Frame backend, its dependencies and remote transport
|
||||
have not been verified. We therefore do not claim that the existing Mac driver
|
||||
can control the Frame by passing it a Frame PID or screenshot, and we do not
|
||||
make the feature depend on installing that application.
|
||||
|
||||
Frame Control's `computer_state` is our own Python implementation over installed
|
||||
platform libraries. It sends the probe over SSH stdin, writes no helper to disk,
|
||||
and exits after one observation. Missing displays/libraries return explicit
|
||||
errors; a 15-second process deadline prevents a stalled accessibility call from
|
||||
leaving a probe behind. Window names and accessibility text are untrusted app
|
||||
content, never instructions to an agent.
|
||||
|
||||
**Recommended next implementation:** an isolated Chromium session with typed
|
||||
snapshot/click/type/scroll tools and exact fresh target binding, then individually
|
||||
verified native app actions. Use the headset capture to judge appearance, not to
|
||||
invent a screen-to-window coordinate transform. Direct tool calls must retain
|
||||
approval rules; a generic computer-use tool must not become a route around the
|
||||
MCP approval panel, install confirmation or power confirmation.
|
||||
|
||||
## Isolated browser input proof
|
||||
|
||||
**Verified 2026-09-29, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** a temporary
|
||||
Frame Chromium profile loaded a local test page through an SSH reverse tunnel.
|
||||
CDP `Input.insertText` entered the test string in its own input. A CDP
|
||||
`Input.dispatchMouseEvent` press/release on its own button copied that string
|
||||
to the page's result; DOM readback matched exactly. The browser profile,
|
||||
loopback forwards and panel log were removed afterward. No user app was typed
|
||||
into, no global settings were changed and no third-party helper app was used.
|
||||
|
||||
AT-SPI did **not** expose the test page's controls in that same probe, even with
|
||||
Chromium's renderer-accessibility flag. It returned the partial Steam-client
|
||||
tree instead. The reason remains **unverified**; this is an evidence gap, not
|
||||
proof that Frame accessibility cannot work. For a first implementation,
|
||||
Chromium's proven page-specific CDP route is stronger than assuming complete
|
||||
AT-SPI coverage. This proof does not ship unrestricted click/type tools or
|
||||
establish input delivery to SteamVR's menus.
|
||||
@@ -0,0 +1,47 @@
|
||||
Frame client feasibility, 2026-09-28
|
||||
SteamOS VERSION_ID=0.4.1 BUILD_ID=20260925.6191901; uname -m=aarch64
|
||||
SteamVR: vrserver log reports 2.18.1; process 2256 remained alive across checks.
|
||||
Base: dcf9689f6459e576d35fc507eb702ca6b2bf4dad (main).
|
||||
Unmodified upstream release APKs; installed with ui/frame_android.py install APK --vr.
|
||||
No Linux host attached. No pairing, streamed video, input, audio or worn-headset checks.
|
||||
Selected logcat lines only; timestamps in Android logs are UTC.
|
||||
|
||||
WiVRn-release.apk
|
||||
https://github.com/WiVRn/WiVRn/releases/tag/v26.9
|
||||
sha256=1df6649ec77224fcc821af0ab4897222bdf3d7eb6ce6ad636461336724111331
|
||||
E/OpenXR-Loader( 1140): Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||
I/WiVRn ( 1140): [2026-09-28 12:07:58.987] [WiVRn] [info] Failed to create OpenXR instance version 1.1.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||
E/OpenXR-Loader( 1140): Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||
I/WiVRn ( 1140): [2026-09-28 12:07:59.034] [WiVRn] [info] Failed to create OpenXR instance version 1.0.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||
E/WiVRn ( 1140): [2026-09-28 12:07:59.035] [WiVRn] [error] Error during initialization: Failed to create OpenXR instance: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||
|
||||
alvr_client_android.apk
|
||||
https://github.com/alvr-org/ALVR/releases/tag/v20.14.1
|
||||
sha256=be68feeb02665e3d69f1cdbcabf38ea4d15c42868a7ec6b5e698dbefee4e4e36
|
||||
E/OpenXR-Loader( 1139): Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||
I/RustStdoutStderr( 1139): Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||
E/[ALVR NATIVE-RUST]( 1139): ALVR panicked: What happened:
|
||||
E/[ALVR NATIVE-RUST]( 1139): panicked at alvr/client_openxr/src/lib.rs:220:10:
|
||||
E/[ALVR NATIVE-RUST]( 1139): called `Result::unwrap()` on an `Err` value: ERROR_EXTENSION_NOT_PRESENT
|
||||
|
||||
Cleanup verified: both app directories, compatdata directories and Steam shortcuts absent.
|
||||
Both test containers stopped and removed. No Steam/SteamVR restart or global setting changes.
|
||||
|
||||
Valve release notes fetched from ISteamNews/GetNewsForApp/v2 (appid=250820).
|
||||
|
||||
SteamVR Beta Updated - 2.18.1
|
||||
https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1844751498219787
|
||||
Added tethered Quest support over USB (must be used with Steam Link Beta)
|
||||
|
||||
Introducing SteamVR 2.17
|
||||
https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1843481262693486
|
||||
Adds initial support for USB streaming. Note: Requires new Steam Client Beta.
|
||||
Fix crash using Steam Link on Linux when games submit invalid textures.
|
||||
Improve streaming recovery when using Steam Link on Linux.
|
||||
|
||||
SteamVR Beta Updated - 2.17.8
|
||||
https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1842212951314598
|
||||
Fix crash using Steam Link on Linux when games submit invalid textures.
|
||||
Improve streaming recovery when using Steam Link on Linux.
|
||||
Adds initial support for USB streaming.
|
||||
USB streaming can be used without WiFi by opting into the Steam Client Beta.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Mod feasibility test, 2026-09-28
|
||||
|
||||
**Verified observations**, with limits below. Device: `aarch64`, SteamOS
|
||||
`VERSION_ID=0.4.1`, `BUILD_ID=20260925.6191901`; Proton version file:
|
||||
`1788505046 proton-11.0-2c-arm64`; `vrcmd --stats`: SteamVR `2.18.1`.
|
||||
Times below are the Frame's AEST clock.
|
||||
|
||||
## Inventory and allowed content
|
||||
|
||||
`ssh frame 'python3 - owned' < ui/frame_steam.py` returned 868 games before
|
||||
the test. Beat Saber (620980) and Skyrim VR (611670) were absent. Half-Life 2:
|
||||
VR Mod – Episode One (2177750) was installed. Hogwarts Legacy (990080),
|
||||
Horizon Zero Dawn (1151640) and Horizon Forbidden West (2420110) were listed
|
||||
but not installed. The full personal library is not committed.
|
||||
|
||||
**Documented, owner report after these tests:** Alex has Beat Saber on a
|
||||
Quest 2, which is charging. That copy was not accessed or inspected during
|
||||
this test. Its version, transfer path and Frame compatibility remain unknown;
|
||||
Steam ownership is still not established.
|
||||
|
||||
Gravitas's public Steam metadata reported `is_free: true`, developer Galaxy
|
||||
Shark Studios, Windows only. Frame Control's existing Steam helper requested
|
||||
its install. Steam first returned free-license state 3, then config state 7,
|
||||
then queued the download. The helper did not accept state 3; a later read
|
||||
found state 7. The source of that transition was not observed. A later
|
||||
`install 1067310` returned `state: installed`. No purchase occurred.
|
||||
|
||||
UEVR was downloaded from the author's [1.05 release](https://github.com/praydog/UEVR/releases/tag/1.05),
|
||||
not a mirror. `UEVR.zip` was 7,399,455 bytes. Its SHA-256 matched the author's
|
||||
`UEVR.zip.sha256` (a UTF-16 text file):
|
||||
|
||||
```text
|
||||
af4f2f91306802d7ee4e8497d483a547ac8e9a3067dbafb81324100524215d3c
|
||||
```
|
||||
|
||||
Microsoft's Windows x64 .NET runtime and Windows Desktop runtime 6.0.36 ZIPs
|
||||
were downloaded using URLs in the official release metadata. Both SHA-512
|
||||
hashes matched that metadata. They were extracted into a test-only user
|
||||
directory, not installed globally. No other mod manager was used.
|
||||
|
||||
## Half-Life 2 VR: visible setup, not gameplay
|
||||
|
||||
Launched the already-installed Episode One using
|
||||
`steam steam://rungameid/2177750`. The existing manifest recorded build
|
||||
25413453 and a shared base depot from app 658920, build 25413418.
|
||||
|
||||
Selected fresh Steam / SteamVR log lines:
|
||||
|
||||
```text
|
||||
22:07:50 proton waitforexitandrun .../Half-Life 2 VR/ep1vr.exe
|
||||
22:08:03 SetApplicationPid: Setting app steam.app.2177750 PID to 27990
|
||||
22:08:03 Successfully loaded binding file '.../hlvr/cfg/steamvr/bindings_frame.json' for app 'steam.app.2177750'.
|
||||
```
|
||||
|
||||
The game's `episodicvr/console.log` reached `Creating VR hand HUD...`,
|
||||
`Creating VR weapon HUD...` and `Calibrating VR base position`. It also
|
||||
contained missing material/weapon warnings. SteamVR logged a missing
|
||||
`frame_hmd` binding as well as the successful controller binding load;
|
||||
controller input was not exercised.
|
||||
|
||||
`ui/frame_vrshot.py` produced a stereo capture showing the mod's
|
||||
“First time setup” and “Dominant hand” dialog. This establishes visible
|
||||
startup, not a played level or comfortable performance. The capture includes
|
||||
room passthrough and is deliberately not published. The test's `hl2.exe`
|
||||
process was terminated; the pre-existing game installation was preserved.
|
||||
|
||||
## Gravitas and UEVR: injection unverified
|
||||
|
||||
The game was launched through Steam. The injector was then started in the
|
||||
same `steamapps/compatdata/1067310` prefix using the shipped
|
||||
`SteamLinuxRuntime_4-arm64/_v2-entry-point` and Proton 11 ARM64. The first
|
||||
attempt reported:
|
||||
|
||||
```text
|
||||
Application: UEVRInjector.exe
|
||||
Message: You must install .NET to run this application.
|
||||
Architecture: x64
|
||||
App host version: 6.0.35
|
||||
.NET location: Not found
|
||||
```
|
||||
|
||||
With `DOTNET_ROOT` and `DOTNET_ROOT_X64` pointing at the test-only Windows
|
||||
runtime directory, Proton loaded `Microsoft.NETCore.App/6.0.36` and
|
||||
`Microsoft.WindowsDesktop.App/6.0.36`. A 25-second launcher timeout was too
|
||||
short to establish whether the UI worked. A longer attempt produced a
|
||||
625×372 `UEVR` X11 window on display `:1`. This is window creation evidence,
|
||||
not a successful injection. That launcher returned exit 0; this was not
|
||||
treated as proof that a mod worked.
|
||||
|
||||
A combined launch set `PROTON_REMOTE_DEBUG_CMD` to `UEVRInjector.exe` and
|
||||
ran Gravitas's `Drop.exe` in the same runtime/prefix. The process
|
||||
`SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe` and a 1600×900 window titled
|
||||
`SkyArk (64-bit, PCD3D_SM5)` appeared. The launcher exited **1** before an
|
||||
injection or stereo game scene could be verified. Output included:
|
||||
|
||||
```text
|
||||
Proton: Error while copying to ".../windows/system32/amdxcffx64.dll": No such file or directory
|
||||
Error [GENERAL | xrCreateInstance | OpenXR-Loader] : xrCreateInstance failed
|
||||
X Error of failed request: BadWindow (invalid Window parameter)
|
||||
Major opcode of failed request: 10 (X_UnmapWindow)
|
||||
X Error of failed request: XI_BadDevice (invalid Device parameter)
|
||||
Minor opcode of failed request: 28 (X_GetDeviceButtonMapping)
|
||||
```
|
||||
|
||||
These errors do **not** establish an ARM64/FEX incompatibility. Other work
|
||||
was launching apps on the shared Frame. SteamVR's PIDs changed during the
|
||||
experiment; `ps` recorded the replacement `vrserver` and `vrcompositor`
|
||||
starting at **22:13:03**, corroborated by `steamvr.service` journal startup
|
||||
lines. This test did not request a Steam/SteamVR restart. The combined
|
||||
attempt ran at 22:14:17–22:14:27, after that restart. A stable, coordinated
|
||||
session is needed to distinguish launcher/environment problems from game or
|
||||
mod incompatibility.
|
||||
|
||||
## Cleanup and remaining checks
|
||||
|
||||
- No game executable or OpenVR DLL was replaced. No global runtime or power
|
||||
setting was changed by this test. No R.E.A.L., OpenComposite or Beat Saber
|
||||
payload was installed.
|
||||
- Removed the test-only UEVR/.NET directory and downloaded ZIPs from the
|
||||
Frame. Final process checks found no `UEVRInjector.exe`,
|
||||
`Drop-Win64-Shipping.exe`, `ep1vr.exe` or `hl2.exe`. SteamVR was running.
|
||||
- Gravitas and its Steam-created prefix remain installed for a repeat test;
|
||||
the pre-existing HL2 VR install remains. Steam may retain normal shader
|
||||
caches, logs and prefix temporary files.
|
||||
- Still unverified: UEVR injection and removal, R.E.A.L. releases and
|
||||
permissions, OpenComposite, Beat Saber playback on either build, and our
|
||||
own manager's end-to-end install/uninstall. No installer UI is justified
|
||||
by these results. See the [support table and next checks](../mods.md).
|
||||
@@ -0,0 +1,38 @@
|
||||
# File transfer and clipboard
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md). Everything here
|
||||
depends on SSH working through the `frame` alias from `scripts/connect.sh`.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Command | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| **scp / rsync over SSH** | `./scripts/push.sh file-or-dir [dest]`, or `rsync -a --progress x frame:Downloads/` | **Inferred.** SSH is confirmed. Valve recommends WinSCP (SFTP) for Windows ([debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging)), which means SFTP is enabled. | Recommended. The Mac ships `rsync` (newer macOS uses `openrsync`, which supports the flags used here). `rsync` must also exist on the Frame. It's in SteamOS on Deck; if it's missing on the Frame, `push.sh` falls back to `scp`. |
|
||||
| SFTP GUI | Finder can't do SFTP. Use Cyberduck / Transmit / ForkLift with `sftp://steamos@frame.local` | Inferred | Good for browsing. |
|
||||
| `adb push` | `adb push x /sdcard/Download/` (Lepton) | Confirmed that ADB exists ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)) | Only reaches the Android container's storage. |
|
||||
| SteamOS Devkit Client | "Title Upload" | Confirmed (Frame) ([loadgames](https://partner.steamgames.com/doc/steamhardware/steamframe/loadgames)) | For deploying apps and games, not general files. macOS support for the Devkit Client wasn't confirmed. |
|
||||
| Syncthing | A Syncthing Flatpak on the Frame (`./scripts/install-apps.sh <flathub-app-id>`), app on the Mac | Guess (which Syncthing Flatpak, and whether it has an aarch64 build, not checked) | Good for an ongoing shared folder. |
|
||||
| KDE Connect | KDE Connect on both | Guess | There's a macOS build of KDE Connect, but whether it's present or installable on the Frame wasn't confirmed. It would give you clipboard sync, file send, and remote input. Worth checking on-device. |
|
||||
| microSD | Physical card | Confirmed that the slot exists ([Wikipedia](https://en.wikipedia.org/wiki/Steam_Frame)) | Offline fallback. |
|
||||
|
||||
## Clipboard
|
||||
|
||||
`scripts/paste-to-frame.sh` sends the Mac clipboard (or stdin) to the
|
||||
headset's desktop clipboard. You can then paste in the headset with the
|
||||
virtual keyboard's paste key or a right-click → Paste.
|
||||
|
||||
```sh
|
||||
./scripts/paste-to-frame.sh # sends pbpaste
|
||||
echo "https://example.com" | ./scripts/paste-to-frame.sh -
|
||||
```
|
||||
|
||||
How it works (verified 2026-09-25). The headset's desktop is a Plasma Wayland
|
||||
session nested inside gamescope, with its own runtime dir
|
||||
(`/run/user/1000/nested_plasma`) and its own D-Bus bus. `wl-copy` and `xclip`
|
||||
aren't installed. The script reads the bus address from `plasmashell`'s
|
||||
environment and calls Klipper's `setClipboardContents` with `qdbus6`. The
|
||||
desktop has to be running in the headset. It's text only, and pastes over about
|
||||
100 KB hit the argument limit, so send big things with `push.sh`.
|
||||
|
||||
A simpler fallback: `ssh frame 'cat > ~/clip.txt'` < file, then open it in the
|
||||
headset.
|
||||
@@ -0,0 +1,172 @@
|
||||
# Frame Control in detail
|
||||
|
||||
What each part of the app does, how it works, and what has been checked on a
|
||||
real Frame. For installing it, see the [README](../README.md#install).
|
||||
|
||||
As of 2026-09-25 no other desktop app manages the Frame end to end.
|
||||
[Stream Frame](https://streamframe.app/) (macOS 14+, free) records and screenshots
|
||||
the headset over SSH. [FrameDrop](https://framedropvr.com) sideloads but is
|
||||
Windows-only. Steam Link views the headset.
|
||||
|
||||
You can also run the same UI in a browser without the app, from a checkout:
|
||||
|
||||
```sh
|
||||
./scripts/frame-ui.sh # macOS: opens http://127.0.0.1:47810 in its own window
|
||||
python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
The window has four tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports) and **Tools** (sending files and text,
|
||||
Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
|
||||
anywhere in the window. When the Frame can't be reached, one banner says why in
|
||||
plain words and the app retries every few seconds, filling everything in once it
|
||||
answers. Flatpak and Android installs run in the background; the bottom bar
|
||||
counts them while they run.
|
||||
|
||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
||||
floating panels, dashboard and controllers). Shows the left eye, like pointing
|
||||
a camera into one lens, or both eyes, as a single shot; saves as PNG. **Live**
|
||||
is 720p video at about 30 fps: `ffmpeg` on the Frame encodes SteamVR's
|
||||
headset-view device (`/dev/video99`) to H.264 over SSH, and the page decodes
|
||||
it with WebCodecs. Live video is one eye; Capture still gets both. The viewer
|
||||
fits the whole frame; zoom with − / + (or scroll, or double-click), drag to
|
||||
pan, `0` to fit, `F` for full screen. Capture uses OpenVR's `IVRScreenshots`
|
||||
API through Python `ctypes` (`ui/frame_vrshot.py`). Nothing extra is
|
||||
installed on the Frame (SteamOS ships `ffmpeg`). **Desktop panel** captures
|
||||
gamescope's flat layer instead.
|
||||
- **Screenshots** you take in the headset with Steam's shortcut: browse them and
|
||||
save them to `~/Pictures/SteamFrame`.
|
||||
- **Battery** with charging state: charge rate in watts, time to full or empty,
|
||||
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
|
||||
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
|
||||
the desktop, Lepton and xrdp are running.
|
||||
- **Library** shelf with Steam cover art and a Play button (`steam://rungameid`).
|
||||
- **Get games**: every game you own with its Steam Frame rating (Verified,
|
||||
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
|
||||
with live progress. Search the Steam store with prices and Frame ratings; Buy
|
||||
opens the store page in your browser, or Store on Frame opens it in the
|
||||
headset. It drives the Frame's own Steam client through its DevTools port;
|
||||
see [steam-games.md](steam-games.md).
|
||||
- **Volume** and mute (`wpctl`).
|
||||
- **Android apps**: search about 4,500 F-Droid apps rated for the Frame, install
|
||||
one with a click as its own Lepton instance (it keeps its data and shows in the
|
||||
Steam library), then launch, stop, test or remove it. **Report an APK** records
|
||||
whether any APK worked (F-Droid or not: pick a file, type a package, or use an
|
||||
installed app). Your reports are saved on your computer and change the verdicts
|
||||
you see. With **Share compatibility results** on (Privacy & updates), they also
|
||||
go to the shared database ([privacy.md](privacy.md),
|
||||
[compat-db/README.md](../compat-db/README.md)). A failed install records
|
||||
itself when the APK was the problem, and after an install the app offers a
|
||||
20-second test. Uses the app's bundled `adb`, or yours if you have one.
|
||||
- **Android display**: pick a running Lepton instance (by the app in it) and set
|
||||
its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to
|
||||
match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size
|
||||
(0.85–1.3×) over ADB (`wm size`, `wm density`, `font_scale`). Reset puts all
|
||||
three back. Whether the settings survive the app relaunching is untested.
|
||||
- **Transfer**: drag and drop files to `~/Downloads`; `.apk` files install as
|
||||
their own Android app. A game's `.zip`, folder or `.exe` becomes a title in
|
||||
the Steam library (Valve's Devkit Game path, with Proton or the Steam Linux
|
||||
Runtime picked from the program's header), listed under **Sideloaded titles**
|
||||
with Launch and Remove; see [sideloading.md](sideloading.md). Send typed text, or your computer's clipboard, to the
|
||||
Frame clipboard.
|
||||
- **Mac in the headset** (macOS): show any Mac window, or a whole screen, as
|
||||
its own panel in the headset. Place it with the SteamVR dashboard, click and
|
||||
scroll with the laser, and type on the Mac. Streams hardware H.264 through
|
||||
an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
|
||||
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
|
||||
Remmina).
|
||||
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
|
||||
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
|
||||
Linux). Sleep, restart and shut down open a terminal window because SteamOS
|
||||
asks for the sudo password over SSH.
|
||||
|
||||
## How it works
|
||||
|
||||
`app/` is an Electron shell. It starts `ui/server.py` on a free loopback port
|
||||
and shows it in its own window; the server stops when you quit the app. The
|
||||
app bundles `ui/`, `scripts/`, `frame/android/`, Valve's `frame/devkit-utils/` and the rated catalogue from
|
||||
`apk-catalog/`, plus a standalone Python
|
||||
([python-build-standalone](https://github.com/astral-sh/python-build-standalone))
|
||||
and `adb` from Google's platform-tools, so there's nothing else to install. It
|
||||
also bundles curl's copy of Mozilla's CA list, because Python on Windows only
|
||||
trusts root certificates already in the Windows store. And it bundles KDE
|
||||
Connect for the Frame (Valve's arm64 build and five libraries, 3.6 MB,
|
||||
[`frame/kdeconnect`](../frame/kdeconnect/NOTICE.md)), which it copies to the
|
||||
Frame for the keyboard and trackpad.
|
||||
`app/build/fetch-deps.js` downloads all of it, pinned by SHA-256.
|
||||
|
||||
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
|
||||
with a non-local `Host` header, and any `/api/` request without a custom
|
||||
header, so other websites can't drive it or read captures. Everything reaches
|
||||
the headset through the `frame` SSH alias. On macOS and Linux it keeps one
|
||||
multiplexed SSH connection open, so status and each capture take about 0.3 s.
|
||||
Windows' OpenSSH can't share a connection, so there each request connects on
|
||||
its own and the app is a little slower. What differs between the three
|
||||
systems lives in `ui/frame_host.py`.
|
||||
|
||||
Headset captures are deleted from the Frame as soon as they're copied, because
|
||||
they show everything on screen, including anything private. The look follows
|
||||
the Steam client: its palette, Motiva Sans (loaded from Valve's CDN), portrait
|
||||
library capsules and green Play buttons.
|
||||
|
||||
**Verified on the Frame 2026-09-25 (macOS app):** status and charging details,
|
||||
both capture modes (headset view while in use, and a blank frame in standby,
|
||||
which the UI labels), clipboard, volume, file push, and input validation.
|
||||
**Not yet exercised from the UI:** Launch, Flatpak install/remove, APK drop,
|
||||
title sideloading (not yet run on a headset at all), and the power buttons. Each of these calls a command that was verified
|
||||
separately.
|
||||
|
||||
## Per-platform notes
|
||||
|
||||
**macOS.** The app reads `PATH` from your login shell, so Homebrew's `rsync`
|
||||
and `adb` are used when you launch it from Finder. Set Up Connection runs
|
||||
`scripts/connect.sh` in Terminal. The log is at
|
||||
`~/Library/Logs/Frame Control/server.log`. The build is ad-hoc signed and not
|
||||
notarized: a downloaded copy is quarantined until you run
|
||||
`xattr -dr com.apple.quarantine "/Applications/Frame Control.app"`. The first
|
||||
time you use them, macOS asks to allow local network access (for SSH) and
|
||||
control of Terminal (for SSH and power actions).
|
||||
|
||||
**Windows.** `ssh` is Windows' built-in OpenSSH client
|
||||
(Settings → System → Optional features, if it's been removed). Set Up
|
||||
Connection runs `ui/frame_connect.py` in a console window. Copies use `scp`
|
||||
because Windows has no `rsync`. The installer isn't code-signed, so SmartScreen
|
||||
warns on first run: choose **More info → Run anyway**. The log is at
|
||||
`%APPDATA%\Frame Control\logs\server.log`.
|
||||
|
||||
**Linux.** Needs `ssh`, which most desktops have; the `.deb` pulls it in.
|
||||
The arm64 build also needs your distribution's `adb` for Android apps, because
|
||||
Google publishes no arm64 Linux platform-tools. Set Up Connection runs
|
||||
`ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm
|
||||
and others). The log is at
|
||||
`~/.config/Frame Control/logs/server.log`. Running `ui/server.py` in a browser
|
||||
instead of the app, sending the clipboard needs `wl-clipboard` (Wayland) or
|
||||
`xclip` (X11).
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
cd app
|
||||
npm install
|
||||
npm start # run from the checkout without packaging
|
||||
npm run dist # macOS: dist/*.dmg and .zip (Apple Silicon)
|
||||
npm run dist:win # Windows: installer and .zip
|
||||
npm run dist:linux # Linux: AppImage and .deb, x64 and arm64
|
||||
```
|
||||
|
||||
Pushing a `v*` tag builds all three in GitHub Actions and attaches them to a
|
||||
draft release (`.github/workflows/release.yml`). Running copies are offered it
|
||||
once you publish it: see [releasing.md](releasing.md).
|
||||
|
||||
## AI agents and assistant
|
||||
|
||||
**Documented:** [the MCP adapter and assistant panel](agents.md) are Frame
|
||||
Control implementations. MCP wraps this HTTP API without API keys. Changes
|
||||
require a separate user approval; power also retains its password prompt. The
|
||||
assistant uses a user-chosen endpoint and sends nothing until the user opts in
|
||||
for a message. Screenshot context is separately opt-in. Model replies cannot
|
||||
operate the headset. Tools → Open assistant opens the page; the linked guide
|
||||
covers putting it in a Chromium panel on the Frame.
|
||||
@@ -0,0 +1,97 @@
|
||||
# How the Frame is put together (field notes)
|
||||
|
||||
What we learnt by poking at a real Frame over SSH. Unless a line says
|
||||
otherwise, it was **verified 2026-09-25** on SteamOS 0.3.0 (`VARIANT_ID=vr`,
|
||||
build 20260922.6101926, kernel 6.18, aarch64). Topic docs go deeper. This page
|
||||
is the map.
|
||||
|
||||
## The layer cake
|
||||
|
||||
```
|
||||
SteamVR (vrserver, vrcompositor, vrdashboard) ← renders the room + panels
|
||||
└─ gamescope --backend openvr ← one SteamVR overlay per app id
|
||||
├─ Xwayland :0 (Steam UI, games, tagged apps) ← STEAM_GAME property = app id
|
||||
├─ Xwayland :1 (STEAM_GAME_DISPLAY_0)
|
||||
├─ Wayland socket gamescope-0
|
||||
└─ steamos-nested-desktop ← "the Linux desktop" panel
|
||||
└─ dbus-run-session startplasma-wayland
|
||||
└─ kwin_wayland 1280×800, Wayland wayland-0, Xwayland :2
|
||||
└─ plasmashell, Konsole, Dolphin, Flatpaks you open there
|
||||
Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 3056000
|
||||
```
|
||||
|
||||
## Facts worth knowing
|
||||
|
||||
| Fact | Where it matters |
|
||||
|---|---|
|
||||
| The desktop is a **nested** Plasma session: runtime dir `/run/user/1000/nested_plasma`, its own D-Bus bus, `WAYLAND_DISPLAY=wayland-0`, `DISPLAY=:2`. A plain `ssh frame app` can't find it. Copy the env from `plasmashell`'s `/proc/<pid>/environ`. | `run-on-frame.sh`, `paste-to-frame.sh` |
|
||||
| The desktop size is hard-coded to 1280×800 in `/usr/bin/steamos-nested-desktop` (read-only rootfs). | [panels.md](panels.md) |
|
||||
| gamescope runs with `--virtual-connector-strategy PerAppId`. Each app id becomes a SteamVR overlay `valve.steam.desktopgame.<id>`, which is a panel you can float. Setting `STEAM_GAME` on an X11 window on `:0` makes a new panel. | `panel-on-frame.sh`, [panels.md](panels.md) |
|
||||
| Handy gamescope root properties on `:0`: `GAMESCOPE_FOCUSABLE_APPS`, `GAMESCOPE_FOCUSABLE_WINDOWS` (triples: window, app id, pid), `GAMESCOPE_FOCUSED_APP`. Read them with `DISPLAY=:0 xprop -root`. | Debugging panels |
|
||||
| `gamescopectl screenshot <file>` (with `WAYLAND_DISPLAY=gamescope-0`) captures gamescope's flat layer. | Frame Control's capture |
|
||||
| The **headset view** (both eyes, fully composited: room, panels, dashboard, controllers) comes from OpenVR `IVRScreenshots::RequestScreenshot(VRScreenshotType_Stereo)`. It's callable from `python3` with `ctypes` against `/opt/steamvr/bin/linuxarm64/libopenvr_api.so` as an overlay app. The compositor appends `.png`, writing a 1920×1080 side-by-side image (960×1080 per eye) plus a left-eye preview, in about 0.3s. In standby the frame is blank. `vrcmd --screenshot` and `vrcmd --compositorcmd screenshot_request` wrote nothing, even with `steamvr/rawCapturePath` set. | `ui/frame_vrshot.py` |
|
||||
| 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 |
|
||||
| Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale` (installable in `~`, see below), `krfb`, `wayvnc`. | Script design |
|
||||
| **SteamOS updates arrive on their own.** The Frame went from 0.3.0 (build 20260922.6101926) to **0.4.1, build 20260925.6191901**, between 2026-09-27 and 2026-09-28 with no action from us; `~` (keys, user Flatpaks, `~/.local/share`) survived. **Verified 2026-09-28.** | Keep changes in `~` |
|
||||
| **Valve's package repository has more than the image.** `pacman -Si` / `pacman -Sp` work as `steamos` without root and list Valve's own builds, such as `kdeconnect` 24.02.2 and `python-evdev` 1.7.0 in `extra`. Unpacking those packages into `~` runs them without touching the read-only root. The repository URLs say not to share them, so never write them down; Valve also publishes each build's source package there (`sources/packages/`), which is how Frame Control got the complete source for the KDE Connect it ships. **Verified 2026-09-28**, SteamOS 0.4.1. | [streaming.md](streaming.md#input-type-and-point-in-the-frame-from-the-mac-or-iphone) |
|
||||
| **gamescope runs two Xwayland displays.** `:0` holds Steam's VR bar and menus (`valve.steam.gamepadui.*`) and ignores XTest pointer motion; `:1` holds apps such as Chromium and takes it. There's also a libei socket, `/run/user/1000/gamescope-0-ei`. **Verified 2026-09-28**, SteamOS 0.4.1. | Keyboard and trackpad |
|
||||
| Flathub is a **system** remote. `--user` installs over SSH work and show up in the desktop menu. | `install-apps.sh` |
|
||||
| `/` is 10 GB and read-only. `/home` is 929 GB. | Where to put things |
|
||||
| Clipboard: Klipper over the nested D-Bus bus (`qdbus6 org.kde.klipper …`). | `paste-to-frame.sh` |
|
||||
| Lepton listens for ADB on the Frame's loopback `5555`, so tunnel it over SSH. It's Android 11 (API 30), 64-bit ARM only, with no `clipboard` service: Compose < 1.11, SDL/Kivy and Godot 4.3 apps crash on launch. | [apks.md](apks.md), `apk-catalog/` |
|
||||
| Lepton Development deletes every ADB-installed app when it exits (`clear_baked_app_data "non steamlaunch container"` in `…/common/Lepton/lepton`) unless `LEPTON_NO_CLEANUP` is set. | [apks.md](apks.md) |
|
||||
| Any APK can run as its own Lepton instance: run `…/common/Lepton/lepton waitforexitandrun -- app.apk` with `SteamAppId` set and `STEAM_COMPAT_DATA_PATH` under `~/.local/share/Steam`. Data persists and each gets its own container and panel. `frame/android/lepton-app.sh`, `ui/frame_android.py`. | [apks.md](apks.md) |
|
||||
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
|
||||
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
|
||||
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
|
||||
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
|
||||
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
|
||||
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
|
||||
| Power actions need `sudo`, which asks for the Developer Mode password over SSH. | Frame Control's power buttons |
|
||||
| **SSH server:** OpenSSH 9.7p1. It offers `publickey,password` (keyboard-interactive is off, PAM on) and also asks `userdbctl ssh-authorized-keys` for keys. OpenSSH ≥ 8.8 rejects SHA-1 `ssh-rsa` signatures by default, so a client whose RSA support is SHA-1 only (the Swift library Citadel, for one) can't log in with the RSA key that devkit pairing installs; use ed25519 (**inferred** from OpenSSH defaults). **Verified 2026-09-27**, BUILD_ID 20260922.6101926. | [iphone.md](iphone.md), `ui/frame_connect.py` |
|
||||
| **A Chromium app window on gamescope's `:0` with its own `STEAM_GAME` becomes a panel, even when started over SSH.** `chromium-xr/chrome --ozone-platform=x11 --app=URL --window-size=1280,720` got a 1920×1080 window, and tagging it produced `[Overlays] Created: valve.steam.desktopgame.<id>` in `~/.local/share/Steam/logs/vrwebhelper_systemui.txt`. Chromium XR decoded a 1080p H.264 WebCodecs stream from the Mac at about 60 fps. XTest events sent to `:0` (libXtst through Python ctypes) did not reach the page. The Frame has `libXtst`, `xprop`, `xwininfo`, `curl` and the `C.utf8` locale. **Verified 2026-09-28**, BUILD_ID 20260925.6191901. | [mac-in-headset.md](mac-in-headset.md) |
|
||||
| **Streaming video into a panel: what the Frame adds.** Chromium XR decodes H.264 in **software** (1920×1290 at about 9 Mbit/s took 4–6 ms per frame); its GL is ANGLE → zink → Turnip on the Adreno 750, and it logs `GetVSyncParametersIfAvailable() failed`. An idle page's `requestAnimationFrame` runs at about 140 Hz; when the headset is worn or woken, SteamVR picks the 4320×2160 @ 144 Hz mode. **An unworn headset throttles panel apps** whatever they draw: a local canvas page ran at 58 fps for about 6 s, then 28, then about 15 once in standby (vrserver logs `entering standby`, with `power.pauseCompositorOnStandby 1`). `vrcmd --handlewakeup` gave 137–144 fps for about 2 s, then 36. So a panel's frame rate and compositor delay can only be measured while it's worn. `tailscaled` runs with `--tun=userspace-networking` and cost about 8% of a core at 9 Mbit/s (0.5% over the LAN address). Wi-Fi power saving is on (`iw … get power_save`). **Verified 2026-09-28**, BUILD_ID 20260925.6191901. | [mac-in-headset.md](mac-in-headset.md#measuring) |
|
||||
| **USB-C networking.** Plugged into a Mac, the Frame is a USB network device: macOS names the port "Steam Frame" (here `en9`, 10.86.200.234/29), and the Frame's `usb0` is 10.86.200.233. Ping is about 0.9 ms, and SSH works with the usual host key (`-o HostName=10.86.200.233 -o HostKeyAlias=<tailscale name>`). Steam's Remote Play discovery also broadcasts over it. **Verified 2026-09-28**, BUILD_ID 20260925.6191901. | Frame Control's Mac stream uses it when present ([mac-in-headset.md](mac-in-headset.md)) |
|
||||
| **Tools on the image:** Python 3.12.3, `ffmpeg`, `openssl`, `curl`, `rsync`, `zip`/`unzip`, `flatpak`, `wpctl`, `podman`. **No `adb`.** `steamos` is uid 1000, in `wheel`, and sudoers has `%wheel ALL=(ALL) ALL`, so `sudo -S` takes the Developer Mode password on stdin. **Verified 2026-09-27.** | Running Frame Control's server on the Frame (`FRAME_LOCAL=1`, [iphone.md](iphone.md)) |
|
||||
| **Each Lepton instance is a podman container** named `lepton-steamlaunch-<instance id>`, labelled with its ADB port (`podman ps --format '{{.Names}} {{.Labels.adb_port}}'`). `podman exec <container> /system/bin/sh -c '…'` runs Android's shell inside it with no adb at all (used for `pidof` and `logcat` by the app tester). Running `wm size`/`wm density` that way is untested. **Verified 2026-09-27.** | `ui/frame_android.py`, the iPhone app's display settings |
|
||||
| **Asleep means off the network.** In standby the Frame stops answering on its LAN address, `frame.local` and Tailscale alike (`Host is down`, `No route to host`, timeouts), and ping fails. It was unreachable for about 2.5 hours until woken. Nothing over SSH can wake it. **Verified 2026-09-27.** | Frame Control's offline banner and retries |
|
||||
| **What puts it to sleep is Steam's idle timer**, not logind. The journal shows `steamui_system: Switching to power state: [ k_ESystemPowerState_Sleep ] reason: 'ComputeNextPowerState: active: 3600 < 3600 (k_EACState_Connected)'`, then Steam suspends. SSH work doesn't count as activity. The timers are the client settings `system_idle_suspend_ac_sec` (3600) and `system_idle_suspend_battery_sec` (900); 0 means Never (Settings → Power → Sleep after inactivity). They can be written over DevTools the way the settings page does. logind refuses a `systemd-inhibit --mode=block` sleep lock from an SSH session (`Interactive authentication required`) but accepts one started with `systemd-run --user`. `scripts/keep-awake.sh on|off|status` does both and restores the old timers on `off`. **Verified 2026-09-28**, BUILD_ID 20260925.6191901. Whether Steam's suspend honours the inhibitor on its own is **inferred** (polkit gives `steamos` no `suspend-ignore-inhibit`), not tested. | Keeping the Frame awake for agent work |
|
||||
| **Battery at full on a charger** can read `Discharging` at about 0 W (for example 99 %, 0.0 W, USB-C PD 18 W). Treat under 0.5 W on a charger as "not charging", not "draining". **Verified 2026-09-27.** | Frame Control's battery card |
|
||||
| **The OS image is downloadable.** Valve's recovery images for the Frame are at `https://steamdeck-images.steamos.cloud/recovery/`. The root filesystem inside is btrfs, and it runs as an SSH test target on ARM64 Linux without the headset (`tests/frame-container/frame-image.sh`). **Verified 2026-09-27.** | [recovery-and-images.md](recovery-and-images.md) |
|
||||
| **Boot / recovery menu.** Hold Power ~10 s until the LED goes off, then power on while holding the **AUX button on top of the Power button** (not the volume keys) until a text menu appears. Entries: `Current` (SteamOS-A/B + build), `Previous` (the other A/B slot), `Boot from USB`, `Repair Steam Installation`, `Erase User Data` (factory reset), `ADB mode`, `Battery Ship Mode`. It auto-boots `Current` after a ~15 s countdown. **Volume Up/Down (left side) move, AUX (right side) selects.** For a boot loop, Valve says pick `Previous` (keeps user data); then `Repair Steam Installation`; `Erase User Data` wipes `~` (SSH keys, Tailscale, Flatpaks, T3 setup). Last resort is a full re-image, two ways: (1) USB: write `steamframe-oobe-repair-<build>.img.bz2` to an 8 GB+ USB-C stick (Balena Etcher on the Mac), pick `Boot from USB`, then use "Wipe Device & Install SteamOS" / "Repair SteamOS" (keeps games and personal content) from the recovery desktop; (2) cable/EDL: `steamframe-oobe-repair-qdl-<build>.tar.gz`, run `flash.sh` (Linux) or `flash.cmd` (Windows), then with the Frame off for 10 s hold Power + Vol Up + Vol Down for 10 s and plug it in; it reflashes and reboots. Both images: `https://steamdeck-images.steamos.cloud/recovery/` (build 20260922.5153644, 0.3.0, 3.8 GiB each, no published checksums); local copies in `~/Downloads/steam-frame-recovery/`. File names, checksums and what's inside: [recovery-and-images.md](recovery-and-images.md). Source: Valve's [SteamOS Recovery FAQ](https://help.steampowered.com/en/faqs/view/1B71-EDF2-EB6D-2BB3) and [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227), plus a menu photo in [EloiStree/HelloSteamFrame#9](https://github.com/EloiStree/HelloSteamFrame/issues/9). **Inferred** (Valve docs, 2026-09-26); not yet tried on our Frame. | Recovering from a boot loop |
|
||||
| **Boot loop cause: the SteamVR health check.** `steamvr.service` runs `/usr/share/deckard/steamvr-health-check`, which appends `frog:glasses:` to `$XDG_RUNTIME_DIR/steamvr-short-session-tracker` on every failed or <10 s SteamVR run. At 3 it runs `steam-health-check --repair-now`, which **deletes all of `~/.local/share/Steam` (games, login, Developer Mode) and `~/.steam`**, keeping only `registry.vdf`. At 4 it also tries `steamos-bootconf set-mode reboot-other` (fails as the user: `bootenv: Permission denied`). SteamVR normally fails 1–2 times per boot while it waits for the Steam client (`SteamAPI_InitEx failed … Steam is probably not running`, then `fatal stalled cross-thread pipe`). Once Steam has been wiped, it has to re-download a ~210 MB client on every boot, so SteamVR keeps failing, Steam keeps getting wiped and the Frame reboots, in a loop. Also, the Steam updater can deadlock at `Installing update...` (main process blocked writing to the `-child-update-ui` process, which is stuck in `drm_syncobj_array_wait_timeout`). Killing only the `-child-update-ui` process lets the install finish (`package/*.installed` appears). **Fix without sudo:** over USB-C ADB (`adb -s frame shell` works as `steamos` while the Frame is looping; SSH is refused once Developer Mode is lost), truncate both `/run/user/1000/steam{,vr}-short-session-tracker` files and `chmod 444` them (the health check then logs `Permission denied` and does nothing; this is tmpfs, so it resets on reboot). Unstick the updater if needed, let Steam finish installing, then hold Power 10 s and start the Frame normally. `systemctl reboot` over ADB needs interactive auth. After the fix, sign in to Steam and turn Developer Mode back on. **Verified 2026-09-26**, BUILD_ID 20260922.6101926, slot B (clean boot: 0 SteamVR failures, SSH and Tailscale back). **Seen again 2026-09-28** on BUILD_ID 20260925.6191901, beta client 1790377368. The Frame rebooted by itself while off the network. At 21:13 the check tried `reboot-other` (`bootenv: Permission denied`) and reset the unpacked Steam install, and the tracker had 15 entries. The tracker fix above, applied over SSH, stopped the resets (the checks then log `Permission denied`), but Steam itself kept exiting about 17 s after each start (34 restarts). Cause not found yet. | Diagnosing a boot loop |
|
||||
|
||||
## Debug recipes
|
||||
|
||||
```sh
|
||||
# Which panels (app ids) exist right now?
|
||||
ssh frame 'DISPLAY=:0 xprop -root GAMESCOPE_FOCUSABLE_APPS GAMESCOPE_FOCUSED_APP'
|
||||
|
||||
# Watch panels being created
|
||||
ssh frame 'journalctl --user -f | grep --line-buffered "\[Overlays\]"'
|
||||
|
||||
# gamescope's full flags (in case Valve changes them)
|
||||
ssh frame 'tr "\0" " " < /proc/$(pgrep -x gamescope | head -n 1)/cmdline'
|
||||
|
||||
# Everything the SteamVR dashboard can say (find hidden features)
|
||||
ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashboard_english.json'
|
||||
```
|
||||
|
||||
## Where the rest lives
|
||||
|
||||
- Access and SSH: [ssh.md](ssh.md)
|
||||
- Seeing the Frame from the Mac, and the Mac from the Frame: [streaming.md](streaming.md)
|
||||
- Files and clipboard: [file-transfer.md](file-transfer.md)
|
||||
- Android apps: [apks.md](apks.md)
|
||||
- Installing and buying Steam games: [steam-games.md](steam-games.md)
|
||||
- Remote access from anywhere: [tailscale.md](tailscale.md)
|
||||
- Floating windows in space: [panels.md](panels.md)
|
||||
- Recovery images, what's in them, testing without the headset: [recovery-and-images.md](recovery-and-images.md)
|
||||
- Frame Control on iPhone (the server running on the Frame itself): [iphone.md](iphone.md)
|
||||
- What's still unverified: [open-questions.md](open-questions.md)
|
||||
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 824 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 125 KiB |
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>Install with Frame Control</title>
|
||||
<!-- Landing page for install links (docs/web-install.md): install.html?manifest=URL
|
||||
or ?url=URL opens frame-control://install?… and offers the download if the
|
||||
app doesn't open. Static, no requests of its own. Not published yet. -->
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0d1117; color: #e6edf3;
|
||||
font: 15px/1.5 -apple-system, "Segoe UI", sans-serif; }
|
||||
main { max-width: 520px; padding: 32px; }
|
||||
h1 { font-size: 20px; margin: 0 0 8px; }
|
||||
p { color: #8b98a8; }
|
||||
code { color: #e6edf3; overflow-wrap: anywhere; }
|
||||
a.btn { display: inline-block; margin: 8px 12px 0 0; padding: 9px 16px; border-radius: 3px; text-decoration: none;
|
||||
background: #2d333b; color: #e6edf3; }
|
||||
a.btn.go { background: #1a9fff; color: #fff; font-weight: 600; }
|
||||
.err { color: #ff7b72; }
|
||||
[hidden] { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Install with Frame Control</h1>
|
||||
<p id="what"></p>
|
||||
<p id="bad" class="err" hidden>This link doesn't name an https:// manifest or file, so there's nothing to install.</p>
|
||||
<div id="actions" hidden>
|
||||
<a class="btn go" id="open">Open in Frame Control</a>
|
||||
<a class="btn" href="https://github.com/saphid/steam-frame/releases/latest">Get Frame Control</a>
|
||||
</div>
|
||||
<p id="missing" hidden>Nothing happened? Frame Control isn't installed on this computer, or is older than the
|
||||
version that handles install links. Get it, open it once, then use the link again.</p>
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const q = new URLSearchParams(location.search);
|
||||
const kind = q.has("manifest") ? "manifest" : q.has("url") ? "url" : null;
|
||||
const target = kind && q.get(kind);
|
||||
let ok = false;
|
||||
try {
|
||||
const u = new URL(target);
|
||||
const local = ["localhost", "127.0.0.1"].includes(u.hostname);
|
||||
ok = !u.username && !u.password && (u.protocol === "https:" || (u.protocol === "http:" && local));
|
||||
} catch {}
|
||||
if (!ok) { document.getElementById("bad").hidden = false; return; }
|
||||
const link = `frame-control://install?${kind}=${encodeURIComponent(target)}`;
|
||||
document.getElementById("what").textContent = `From ${new URL(target).hostname}. Frame Control shows what it will `
|
||||
+ "install and asks you before downloading anything.";
|
||||
document.getElementById("open").href = link;
|
||||
document.getElementById("actions").hidden = false;
|
||||
// If the app opens, this page loses focus or is hidden; if not, say how to get it.
|
||||
let left = false;
|
||||
window.addEventListener("blur", () => { left = true; });
|
||||
document.addEventListener("visibilitychange", () => { if (document.hidden) left = true; });
|
||||
setTimeout(() => { if (!left) document.getElementById("missing").hidden = false; }, 2000);
|
||||
location.href = link;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,110 @@
|
||||
# Frame Control for iPhone
|
||||
|
||||
The iPhone (and iPad) app does what the desktop app does, from the phone:
|
||||
headset view and live video, battery and status, screenshots, Steam games,
|
||||
Android apps and their display settings, sideloading, files, clipboard,
|
||||
Flatpaks, and power. Source: [`ios/`](../ios).
|
||||
|
||||
## How it works
|
||||
|
||||
An iPhone can't run Python or `ssh`, but the Frame can. So the app:
|
||||
|
||||
1. connects to the Frame over SSH itself (the [Citadel](https://github.com/orlandos-nl/Citadel)
|
||||
Swift SSH library), with its own ed25519 key from the Keychain;
|
||||
2. copies Frame Control's server and helpers (`ios/scripts/make_frame_bundle.py`,
|
||||
4.6 MB, 3.6 MB of it the KDE Connect the keyboard and trackpad use) to
|
||||
`~/.cache/frame-control/<version>` on the Frame, once per version;
|
||||
3. starts `ui/server.py` there with `FRAME_LOCAL=1`. It listens only on the
|
||||
Frame's own 127.0.0.1, and it stops when the phone disconnects (`--exit-on-eof`);
|
||||
4. tunnels to it through the SSH session and shows the same page as the desktop
|
||||
app, in a web view. The page carries a fresh key each session, which the
|
||||
server requires on every request.
|
||||
|
||||
With `FRAME_LOCAL=1`, every `ssh frame COMMAND` the server runs goes to
|
||||
`ui/local-bin/ssh`, which runs the command on the Frame directly (rsync uses it
|
||||
as its transport too), so the desktop and phone share one code path. Android
|
||||
display settings use `podman exec` into each Lepton container instead of adb,
|
||||
which the Frame doesn't have.
|
||||
|
||||
Nothing is left running on the Frame after the phone disconnects; the copied
|
||||
files stay in `~/.cache/frame-control` (delete it any time).
|
||||
|
||||
## Pairing
|
||||
|
||||
On the Frame, turn on Developer Mode and set a user password (Steam Settings →
|
||||
System, then Developer → Set User Password). In the app, enter the headset's
|
||||
address (`frame.local`, its IP, or its Tailscale name) and that password once.
|
||||
The app adds its own key to `~/.ssh/authorized_keys` and remembers the Frame's
|
||||
host key; the password isn't saved. If you already reach the Frame over SSH,
|
||||
**Or add the key yourself** shows the phone's key to paste into
|
||||
`authorized_keys`, and connects without a password.
|
||||
|
||||
Valve's tap-to-approve devkit pairing isn't used: it only takes RSA keys, and
|
||||
the Frame's OpenSSH 9.7 rejects the SHA-1 RSA signatures the Swift SSH library
|
||||
makes.
|
||||
|
||||
## What's different on the phone
|
||||
|
||||
| Desktop | iPhone |
|
||||
|---|---|
|
||||
| Drop files anywhere | Tap **Send to Frame** (or Add a game) and pick files; folders need zipping |
|
||||
| Screenshots save to `~/Pictures/SteamFrame` | Save opens the share sheet: Save Image puts it in Photos |
|
||||
| SSH and SFTP open a terminal | They open an app that handles `ssh://` / `sftp://` (Blink Shell, Termius) |
|
||||
| Steam Link, remote desktop | Open the Steam Link and Windows App apps |
|
||||
| Sleep, restart, shut down ask in a terminal | The page asks for the Developer Mode password |
|
||||
| Compatibility reports kept on the computer | Kept on the Frame (`~/.local/share/Frame Control`) |
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
cd ios
|
||||
xcodegen generate # after changing project.yml
|
||||
open FrameControl.xcodeproj
|
||||
```
|
||||
|
||||
The build packs the Frame bundle from the checkout, so the phone always runs
|
||||
the page and server from the same commit. Running on a phone needs your own
|
||||
signing team in Xcode (Signing & Capabilities).
|
||||
|
||||
## Verified
|
||||
|
||||
<img src="img/iphone-tabs.jpg" alt="The four tabs in the iPhone app, connected to a Frame" width="900">
|
||||
|
||||
In the iOS Simulator (iOS 26.5) against a real Frame, 2026-09-27: the app connected
|
||||
with its key, copied the bundle over SFTP, started the server on the Frame and
|
||||
showed all four tabs with live data. In the app's web view, Capture returned a
|
||||
headset still and Live played H.264 video at 31 fps (WebCodecs works in
|
||||
WKWebView). Through the app's tunnel: status, games, Steam library, Android apps,
|
||||
screenshots, a file upload (checked on the Frame), a background install job, and
|
||||
the power password check (a wrong password is refused). The server on the Frame
|
||||
exits within seconds of the app closing.
|
||||
|
||||
Against Valve's own Steam Frame OS (SteamOS 0.3.0 build 20260922.5152327, the
|
||||
`rootfs-A` partition of the Frame recovery image, run with its own sshd; see
|
||||
[tests/frame-container](../tests/frame-container)), and a Holo Core stand-in:
|
||||
pairing with the password (key added with the right
|
||||
permissions, host key pinned, password stored nowhere), the power password
|
||||
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.
|
||||
|
||||
Debug builds have Simulator test hooks (`FRAME_TEST_HOST`, `FRAME_TEST_PAGE`,
|
||||
`FRAME_TEST_JS`, and the tunnel URL in the app's Caches folder); release builds
|
||||
don't.
|
||||
@@ -0,0 +1,195 @@
|
||||
# PC VR streaming from Linux
|
||||
|
||||
**Recommendation, 2026-09-28:** test Valve's current SteamVR/Steam Link path
|
||||
on a Linux gaming PC before building another streamer. Valve now documents
|
||||
Linux streaming fixes and USB support. We have no Linux host attached, so
|
||||
Linux-to-Frame VR streaming remains **unverified here**.
|
||||
|
||||
This is the feasibility and options report for
|
||||
[#24](https://github.com/saphid/frame-control/issues/24), not a shipped streaming
|
||||
feature. Frame Control's features must use our own implementation or standard
|
||||
platform components. WiVRn and ALVR are research comparisons, not dependencies.
|
||||
An optional install shortcut is the most we would offer for a third-party app.
|
||||
Our own streamer requires Alex's choice before implementation.
|
||||
|
||||
## What was checked on the Frame
|
||||
|
||||
**Verified** on 2026-09-28: aarch64, SteamOS **0.4.1**, BUILD_ID
|
||||
`20260925.6191901`, SteamVR **2.18.1**. Version and build are recorded separately;
|
||||
earlier docs associate this build with other SteamOS version labels.
|
||||
|
||||
| Client | Installation | Runtime result |
|
||||
|---|---|---|
|
||||
| WiVRn **26.9**, upstream `WiVRn-release.apk` | API 29, arm64-v8a; installed in its own immersive Lepton instance | OpenXR instance creation fails: missing `XR_KHR_convert_timespec_time`. Both 1.1.58 and 1.0.58 attempts return `XR_ERROR_EXTENSION_NOT_PRESENT` |
|
||||
| ALVR **20.14.1**, upstream `alvr_client_android.apk` | API 26, arm64-v8a; installed in its own immersive Lepton instance | Same missing extension. Client panics at `client_openxr/src/lib.rs:220` with `ERROR_EXTENSION_NOT_PRESENT` |
|
||||
|
||||
The [evidence excerpt](evidence/linux-vr/2026-09-28.txt) includes APK SHA-256s,
|
||||
upstream release links, loader errors and cleanup results. These are failures
|
||||
before an OpenXR session, not successful VR clients. WiVRn was launched twice;
|
||||
ALVR's container remained up despite its client panic. Container liveness alone
|
||||
does not establish VR compatibility.
|
||||
|
||||
Both APKs already declare `MAIN` and `LAUNCHER`. They were installed unmodified
|
||||
using this branch's existing `python3 ui/frame_android.py install APK --vr`,
|
||||
then launched through their Steam shortcuts. Logs came from the instance's
|
||||
`podman exec … /system/bin/logcat`; the user journal also retained WiVRn's errors
|
||||
after its container exited. No headset was worn and no host was connected.
|
||||
|
||||
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
||||
SteamVR's original process remained running. No global settings changed.
|
||||
|
||||
### Relation to the VR APK branch
|
||||
|
||||
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
||||
was read, not edited (branch inspected at
|
||||
[`038dcd4`](https://github.com/saphid/frame-control/commit/038dcd48cd75336f6a86c63c7878bfc9c52deec9)).
|
||||
Its compatibility layer handles OpenXR version negotiation, some controller
|
||||
profiles and refresh-rate requests. It does **not** implement
|
||||
`XR_KHR_convert_timespec_time`. Its launcher fix is unnecessary for these APKs.
|
||||
This report has **no unmerged code dependency** on that PR, and neither APK was
|
||||
tested with its layer injected.
|
||||
|
||||
**Documented from upstream source:** WiVRn requests the extension in
|
||||
[`application.cpp`](https://github.com/WiVRn/WiVRn/blob/bbc6e4cc36c355fa6180980abd231673dc15115d/client/application.cpp#L1286)
|
||||
and uses it to convert `CLOCK_MONOTONIC` into `XrTime` in
|
||||
[`instance::now()`](https://github.com/WiVRn/WiVRn/blob/bbc6e4cc36c355fa6180980abd231673dc15115d/client/xr/instance.cpp#L335).
|
||||
ALVR also [requests it unconditionally](https://github.com/alvr-org/ALVR/blob/a9f6542fa507a841f40ab4f3fcb531427cd02550/alvr/client_openxr/src/lib.rs#L188).
|
||||
Simply deleting the extension request or returning made-up timestamps would
|
||||
not prove correct tracking or timing. A real fix needs a valid clock mapping
|
||||
and further runtime tests. No such patch was made.
|
||||
|
||||
### Native SteamOS aarch64 clients
|
||||
|
||||
**Verified:** the Frame has a native OpenXR runtime manifest at
|
||||
`~/.config/openxr/1/active_runtime.json`, pointing to SteamVR's
|
||||
`bin/linuxarm64/vrclient.so`.
|
||||
|
||||
**Documented:** WiVRn's [26.9 README](https://github.com/WiVRn/WiVRn/blob/bbc6e4cc36c355fa6180980abd231673dc15115d/README.md)
|
||||
describes its Linux client as debugging-only, without audio or hardware decode.
|
||||
ALVR 20.14.1's [non-Android decoder](https://github.com/alvr-org/ALVR/blob/a9f6542fa507a841f40ab4f3fcb531427cd02550/alvr/client_core/src/video_decoder/mod.rs)
|
||||
returns no decoded frames. The inspected releases ship Android clients, not a
|
||||
ready-to-run native Frame client.
|
||||
|
||||
**Inferred:** a native port is possible research, but neither release offers a
|
||||
demonstrated native alternative to the blocked APKs. Native builds, native
|
||||
extension enumeration, hardware decoding and audio were **not tested**. The
|
||||
Android extension failure does not establish that the native runtime lacks it.
|
||||
|
||||
## (a) Valve's own path — recommended first
|
||||
|
||||
**Documented**, from Valve's release notes rather than launch-window reports:
|
||||
|
||||
- [SteamVR 2.17.8 beta](https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1842212951314598)
|
||||
says “Fix crash using Steam Link on Linux when games submit invalid textures”
|
||||
and “Improve streaming recovery when using Steam Link on Linux.” It also
|
||||
adds initial USB streaming, with Steam Client Beta required to use USB
|
||||
without Wi-Fi.
|
||||
- [SteamVR 2.17 release](https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1843481262693486)
|
||||
repeats Linux streaming fixes and initial USB support. USB is no longer
|
||||
solely a claim about an old beta, but version/channel requirements still
|
||||
need checking on the actual host.
|
||||
- [SteamVR 2.18.1 beta](https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1844751498219787)
|
||||
adds USB-tethered **Quest** support with Steam Link Beta. That entry is not
|
||||
proof of a Frame/Linux combination.
|
||||
- The [Steam Link page](https://store.steampowered.com/app/353380/Steam_Link/)
|
||||
lists Linux desktop clients, while its Quest VR requirements still say
|
||||
Windows 10 or newer. Desktop Steam Link support is not equivalent to VR host
|
||||
support, and the Quest requirements are not a Frame support matrix.
|
||||
|
||||
**Inferred:** Valve has a Linux VR streaming path worth testing. The old blanket
|
||||
claim “Linux cannot stream VR” is no longer justified by the evidence. These
|
||||
release notes do not establish which Linux GPU/driver/Frame combinations work.
|
||||
USB changes the transport; it does not by itself prove host encoder support.
|
||||
|
||||
**Not verified:** Linux host discovery, pairing, wireless or USB streaming,
|
||||
stereo rendering, controllers, haptics, audio, latency, or a game. Frame-only
|
||||
inspection cannot establish any of these. Flat Remote Play and a desktop shown
|
||||
on a panel are not substitutes for this test.
|
||||
|
||||
Next test, once a Linux gaming PC is available: record distro, GPU/driver,
|
||||
Steam client channel/version and SteamVR version; use the Frame's built-in
|
||||
Steam connection flow, first wirelessly and then over a data-capable USB cable.
|
||||
Launch a free OpenXR sample or developer-consented VR game. Verify stereo,
|
||||
head/controller tracking, haptics and audio while worn; retain both ends' logs
|
||||
and measure latency and recovery after a link interruption. Restore any test
|
||||
channel changes. Do not change the shared headset's channel just for this report.
|
||||
|
||||
If that works, Frame Control can provide our own host checks, setup guidance
|
||||
and session controls around Valve's existing platform. First establish which
|
||||
controls have a usable interface; no stable automated pairing API has been
|
||||
verified. **Estimate (inferred):** 2–5 engineer-days for the hardware feasibility
|
||||
pass; another 1–2 weeks for a small integration if those interfaces exist.
|
||||
|
||||
## (b) Our own streaming — proposal only
|
||||
|
||||
This is a new VR transport and device integration, not a desktop capture feature.
|
||||
A plausible first target is **one Linux GPU family, one host, one Frame**, using
|
||||
SteamVR on both ends. Our host driver would expose a remote HMD/controllers,
|
||||
receive poses and inputs, and obtain stereo textures for hardware encoding.
|
||||
Our Frame OpenXR app would decode, submit the correct eye views and render poses
|
||||
at predicted display times, and return tracking/input. SteamVR/OpenXR, bundled
|
||||
codec/transport libraries and platform GPU APIs fit the ownership rule; a
|
||||
WiVRn/ALVR/Monado server dependency would not.
|
||||
|
||||
**Inferred design risks:** Linux SteamVR texture-sharing/driver interfaces and
|
||||
Frame decode-to-GPU interoperability need a spike before committing to this
|
||||
architecture. Sending an already-composited desktop mirror loses the stereo,
|
||||
pose and timing information we need. Late reprojection, clock conversion,
|
||||
backpressure, controller bindings, audio sync and reconnects are substantial
|
||||
work. A runtime shim must not assume `XrTime` equals monotonic nanoseconds.
|
||||
|
||||
### Reuse from `mac-in-headset`
|
||||
|
||||
**Documented from our code**, read-only at
|
||||
[`1b90c64`](https://github.com/saphid/frame-control/commit/1b90c64b73bace54c63a3aae154c5d29a9448d72):
|
||||
|
||||
- Reuse the ideas for low-latency encoding without B-frames, dropping work
|
||||
before encoding, bounded queues, keyframe recovery, adaptive bitrate,
|
||||
per-frame timing and authenticated session setup.
|
||||
- Its VideoToolbox encoder and ScreenCaptureKit capture are macOS-specific.
|
||||
Linux needs a new GPU encoder path (for example VA-API or NVENC via bundled
|
||||
libraries) and VR texture capture, not a port of window capture.
|
||||
- Its WebSocket over SSH is useful for a first controlled transport experiment
|
||||
and control messages. Reliable TCP can stall behind lost packets; a VR media
|
||||
path needs measured deadline behaviour, likely datagrams with loss recovery
|
||||
using an ordinary bundled transport library. Do not invent cryptography.
|
||||
- Its Chromium/WebCodecs panel viewer is not a VR client. That branch reports
|
||||
software H.264 decoding and occasional long Wi-Fi stalls on the Frame.
|
||||
Its desktop latency measurements are not motion-to-photon measurements or
|
||||
evidence that a 90/120 Hz stereo stream will work.
|
||||
|
||||
**Size/effort estimate (inferred, one experienced full-time engineer, hardware
|
||||
available):**
|
||||
|
||||
| Phase | Deliverable / stop condition | Effort |
|
||||
|---|---|---|
|
||||
| Feasibility | Linux driver texture access, Frame hardware decode into OpenXR, pose/clock loop; stop if any cannot meet frame deadlines | 2–4 weeks |
|
||||
| First end-to-end prototype | One GPU/codec, stereo sample over a controlled LAN, head/controllers, logs and teardown | 4–8 additional weeks |
|
||||
| Usable limited beta | Audio/haptics, pairing, recovery, bitrate/loss handling, installer, worn testing and latency work | 6–12 additional weeks |
|
||||
| Wider support | Multiple GPU vendors/distros, USB and Wi-Fi variation, long-session stability | 2–4 additional months |
|
||||
|
||||
Planning range: **12–24 engineer-weeks for a limited beta**, roughly
|
||||
**10–25k lines of our code plus tests/tooling**, excluding bundled libraries.
|
||||
This is a low-confidence scope estimate, not a delivery promise; an unsupported
|
||||
driver or decode interface could block it entirely. Foveated streaming,
|
||||
eye tracking and parity with Valve are excluded. A Linux gaming PC and repeatable
|
||||
worn-headset testing are prerequisites. **Do not build this until Alex chooses.**
|
||||
|
||||
## (c) Optional “install WiVRn” shortcut only
|
||||
|
||||
Allowed as a clearly optional convenience, never a prerequisite for a Frame
|
||||
Control feature. **Documented:** WiVRn's server Flatpak ID is
|
||||
`io.github.wivrn.wivrn`; its client/server versions must match, and its Flatpak
|
||||
includes xrizer/OpenComposite. Those are properties of an independently
|
||||
installed third-party stack, not components of our implementation.
|
||||
|
||||
**Recommendation:** defer the shortcut while the current client fails before
|
||||
session creation. If offered later, label that compatibility result and let
|
||||
the user choose the install; do not present “install” as “streaming works.”
|
||||
**Estimate (inferred):** 1–2 engineer-days for an optional host-side shortcut
|
||||
with package/version detection and honest status, excluding third-party fixes.
|
||||
No shortcut, host install, pairing automation or streaming UI was built here.
|
||||
|
||||
Choose **(a)** for the next hardware test. Keep **(b)** as a separately approved
|
||||
project if Valve's path fails or lacks a required capability. **(c)** does not
|
||||
solve the verified client blocker and should not be the product's foundation.
|
||||
@@ -0,0 +1,470 @@
|
||||
# Mac in the headset
|
||||
|
||||
Frame Control can show any Mac window, or a whole Mac screen, as its own panel
|
||||
in the Steam Frame. You place each panel anywhere in the room with the SteamVR
|
||||
dashboard. The laser clicks and drags, the thumbstick scrolls, and you type on
|
||||
the Mac's own keyboard. Find it under **Tools → Mac in the headset** (macOS
|
||||
only).
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
|
||||
## Why this design
|
||||
|
||||
First-party options come first, as the repo's rule asks, with the reason
|
||||
each one was or wasn't chosen. The full list for every device is in
|
||||
[streaming.md](streaming.md#first-party-options-and-why-they-do-or-dont-fit).
|
||||
Checked 2026-09-28.
|
||||
|
||||
| Goal | First-party option | Chosen? | Why |
|
||||
|---|---|---|---|
|
||||
| One Mac screen in the headset | **Apple Screen Sharing** (VNC) → Remmina (Remmina 1.4.43 is already installed on this Frame) | Kept as the fallback (`panel-on-frame.sh mac-screen`) | It's the closest to first-party and needs nothing new. But VNC sends compressed tiles rather than video, so moving content is slow: noticeable lag even on a good 5 GHz link (**verified** 2026-09-27, see [streaming.md](streaming.md)), and the Mac's pointer isn't in the picture without a helper. It shows only whole screens |
|
||||
| One Mac screen | **Steam Remote Play**, Mac as host (Valve) | For Mac games only; see [Steam's own streaming](#steams-own-streaming) | The Mac's and the Frame's Steam clients already find each other (**verified**). But Remote Play streams a game (the whole desktop only while the game is out of focus, untested from a Mac), never single windows, and a Mac can't host the Frame's VR streaming |
|
||||
| One Mac screen | **AirPlay** (Apple) | No | Apple licenses AirPlay receivers only to TV and speaker makers, and nothing official runs on Linux. UxPlay is an unofficial receiver, and it mirrors a whole screen, not single windows |
|
||||
| One Mac screen | **Sidecar / Mac Virtual Display** (Apple) | No | These work only with an iPad or Apple Vision Pro |
|
||||
| **Each Mac window as its own panel** | None | – | No first-party way does this: Apple's per-app streaming is only for Vision Pro, and Valve's desktop streaming needs a Windows SteamVR host. So Frame Control does it itself |
|
||||
| Mac keyboard and trackpad driving the headset | **Bluetooth HID** | No | macOS can't act as a Bluetooth keyboard or mouse. A real Bluetooth keyboard paired with the Frame still works |
|
||||
| Mac keyboard and trackpad | **KDE Connect** (KDE) | No | The Frame has no `kdeconnectd` and it isn't on Flathub. Its Mac app has no keyboard or mouse sharing (**inferred**), and on Wayland it can only reach the desktop panel |
|
||||
| Mac keyboard and trackpad | **xrdp** (Valve, Developer Mode) | No | It runs a separate Linux session that you view on the Mac. It isn't the headset's view, and it doesn't carry input the other way |
|
||||
|
||||
What that leaves is our own stream: nothing to install on the Mac or the
|
||||
Frame, and whole screens or single windows. Here the Mac's own keyboard and
|
||||
trackpad need no forwarding, because the windows are still on the Mac. The
|
||||
laser is the only input that has to be sent back.
|
||||
|
||||
Other routes that were compared:
|
||||
|
||||
| Option | One screen | Each window | Speed | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| Sunshine → Moonlight | ✓ | – | Good | Sunshine's macOS support is still experimental ([discussion #777](https://github.com/orgs/LizardByte/discussions/777)), and it captures whole screens only |
|
||||
| Virtual Desktop, Immersed | – | – | – | No Frame client as of September 2026 |
|
||||
| **Frame Control's own stream** | ✓ | ✓ | Hardware H.264, sending only changed frames | **Built** |
|
||||
|
||||
To type into VR surfaces other than these panels (SteamVR's dashboard,
|
||||
games), the Frame supports a uinput keyboard and mouse without sudo
|
||||
(verified 2026-09-27: `steamos` is in `input`, and `/dev/uinput` is
|
||||
`root:input 660`). That's a separate feature, not part of this one.
|
||||
|
||||
## Steam's own streaming
|
||||
|
||||
The Frame is built around Steam streaming, so this was checked first
|
||||
(2026-09-28). It fits Mac games, not Mac windows.
|
||||
|
||||
- **VR streaming from the Mac: no.** The Frame streams VR from a PC running
|
||||
SteamVR ("Steam Link" with foveated streaming). SteamVR dropped macOS in
|
||||
2020, and Valve lists PCs, laptops, Steam Deck and Steam Machine as
|
||||
hosts, never a Mac (**documented**:
|
||||
[UploadVR](https://www.uploadvr.com/steamvr-drops-mac-support/),
|
||||
[Road to VR](https://roadtovr.com/steam-frame-game-certification-specs/)).
|
||||
- **Flat Remote Play from the Mac: probably, for Steam games.**
|
||||
- Steam on this Mac has streaming on, and the two Steam clients already
|
||||
see each other. The Frame's `remote_connections.txt` shows it
|
||||
connecting directly to "Alexs-MacBook-Pro-7" at 192.168.1.211:27036,
|
||||
and the Mac's shows the Frame connecting over Wi-Fi and over the USB-C
|
||||
link (**verified** in both clients' logs).
|
||||
- Whether a stream then starts, and how a flat game looks in the headset
|
||||
(reviews describe a theater screen), is **not tested yet**. The Frame's
|
||||
Steam was crash-looping during this session (below).
|
||||
- Mac-hosted Remote Play has a long-standing report of the stream
|
||||
closing as the game loads
|
||||
([Steam forum](https://steamcommunity.com/groups/homestream/discussions/1/574921459914429988/),
|
||||
**reported**).
|
||||
- **The Mac desktop through Steam: untested; single windows: no.** Valve
|
||||
says Remote Play shows the host's desktop when the game loses focus
|
||||
([Steam Remote Play FAQ](https://help.steampowered.com/en/faqs/view/0689-74B8-92AC-10F2),
|
||||
**documented**), so a whole Mac screen may be reachable by starting a
|
||||
game, then switching away from it. Nobody has tried that from a Mac
|
||||
host. It would still be one screen in one panel: Remote Play has
|
||||
nothing like one panel per Mac window, so Frame Control's own stream
|
||||
stays the way to see separate windows.
|
||||
- **Steam has a "stream desktop" call, and it pairs with a Mac
|
||||
(verified 2026-09-28).** In the Remote Play device list, the Frame's
|
||||
Steam UI calls `SteamClient.RemotePlay.StartDesktopStream(<client id>)`
|
||||
for a connected device. Called over CDP with the Mac's client ID, it made
|
||||
the Mac's Steam show "Authorize Device" and ask for a 4-digit code shown
|
||||
on the Frame. Once the code was entered, the Mac logged
|
||||
`k_ERemoteDeviceAuthorizationSuccess`. No stream started in that attempt,
|
||||
and a second attempt, now that the device is authorized, is the next
|
||||
test. If it streams the Mac's desktop, that's a whole-screen option built
|
||||
into Steam: one panel, Valve's encoder and transport. It still wouldn't
|
||||
give each window its own panel.
|
||||
- **What Steam's work did give us: the USB-C link.** Plugged into the Mac,
|
||||
the Frame appears as a network port called "Steam Frame". Steam's Remote
|
||||
Play discovery uses it, and so does Frame Control's stream now (see
|
||||
"USB-C, when it's plugged in" below).
|
||||
|
||||
Next steps, once Steam on the Frame is healthy:
|
||||
|
||||
1. Call `StartDesktopStream` again now that the Frame is authorized, and
|
||||
compare its latency and sharpness with Frame Control's stream of the same
|
||||
screen.
|
||||
2. Stream the one Mac game installed here (Fortune Mill) from the Frame's
|
||||
library, over Wi-Fi and over USB-C.
|
||||
3. Record whether it starts, how it's shown, and its latency. Steam's
|
||||
streaming overlay shows this; our benchmark can't measure it.
|
||||
4. While streaming, switch away from the game on the Mac, and see whether
|
||||
the Mac's desktop appears in the headset, and whether its keyboard and
|
||||
pointer work.
|
||||
5. If it works, Frame Control's Games page could offer "Stream from the
|
||||
Mac" for Mac-installed games.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Mac Frame
|
||||
ScreenCaptureKit (one window or display)
|
||||
→ VideoToolbox H.264 (hardware, low-latency,
|
||||
no B-frames)
|
||||
→ frame-mac-view, 127.0.0.1 ──ssh -R──→ 127.0.0.1:479xx
|
||||
→ Chromium app window per stream
|
||||
(WebCodecs decode), on gamescope's
|
||||
X display, tagged STEAM_GAME
|
||||
→ its own SteamVR panel
|
||||
← CGEvent (clicks, drags, wheel, keys) ←──── pointer, wheel and key events
|
||||
```
|
||||
|
||||
- **The agent** is `mac/bin/frame-mac-view`, built from `mac/frame-mac-view`
|
||||
(Swift, no dependencies; `build.sh`). Frame Control's server starts it on
|
||||
first use and stops it on quit.
|
||||
- It captures with ScreenCaptureKit, which sends frames only when something
|
||||
changes, so idle windows cost nothing.
|
||||
- It encodes in hardware with VideoToolbox's low-latency rate control (plain
|
||||
real-time mode where that's unavailable).
|
||||
- While anyone is watching, it keeps the Mac's display awake. A sleeping
|
||||
display isn't drawn, so there would be nothing to capture.
|
||||
- **The link** is an `ssh -R` tunnel on its own connection. It's encrypted and
|
||||
works anywhere `ssh frame` works, Tailscale included, with no firewall
|
||||
changes on the Mac. If the headset sleeps or the network drops, Frame
|
||||
Control reopens the tunnel on the same port, and open viewers reconnect by
|
||||
themselves.
|
||||
- **USB-C, when it's plugged in.** Connected to the Mac by cable, the Frame
|
||||
is also a USB network device: macOS lists a network port called "Steam
|
||||
Frame", and the Frame's `usb0` answers in under 1 ms. Frame Control
|
||||
checks for it each time it opens the tunnel and uses it when it's there,
|
||||
with the Frame's usual SSH host key. Otherwise it uses the normal path.
|
||||
`FRAME_MACVIEW_USB=0` turns this off. **Verified** 2026-09-28, in two
|
||||
interleaved pairs of runs:
|
||||
|
||||
| | USB-C | Wi-Fi (Tailscale) |
|
||||
|---|---|---|
|
||||
| test: content p50 / p95 | 6.9–7.3 / 8.4–8.8 ms | 9.8–10.1 / 12.1–12.3 ms |
|
||||
| test: click to drawn p50 | 16.6–16.9 ms | 26.4–27.8 ms |
|
||||
| scroll: content p95 | 23.0–23.5 ms | 31.4–36.9 ms |
|
||||
| scroll: late frames | 3.2–3.3% | 4.7–7.0% |
|
||||
|
||||
(`bench/results/2026-09-28-*-usb1.json`, `-usb2`, `-wifi1`, `-wifi2`.)
|
||||
- **Access.**
|
||||
- Frame Control's own key never leaves the Mac.
|
||||
- Each viewer is opened with a **single-use ticket**. It's tied to one
|
||||
window or display and expires after a minute. It's spent as soon as the
|
||||
viewer confirms it has received its reconnect key. Until then, a retry
|
||||
gets the same key, so a connection lost at that moment doesn't strand
|
||||
the viewer. Stop revokes tickets that haven't been used yet.
|
||||
- After that, the viewer holds a reconnect key for that one source, in
|
||||
memory only. **Stop** revokes it.
|
||||
- Remaining risk: a program running as `steamos` on the Frame could read a
|
||||
ticket from Chromium's command line in the first second or so and use it
|
||||
first. That gets it the one source being opened, not the Mac, and the
|
||||
real viewer would then fail to connect. Android apps in Lepton run in
|
||||
their own podman container, so they shouldn't see the Frame's process
|
||||
list (inferred, not checked).
|
||||
- **The viewer** is `ui/mac-view.html`, served by the agent. It opens on the
|
||||
Frame as a Chromium app window, preferring Chromium XR (`~/chromium-xr`,
|
||||
built with H.264) over Flathub Chromium.
|
||||
- The page puts `[fcNNNNN]` in its title. The launcher finds the window by
|
||||
that tag and sets `STEAM_GAME` to a stable id per source, which gives it
|
||||
its own panel (see [panels.md](panels.md)). The same Mac window gets the
|
||||
same panel id each time.
|
||||
- It decodes with WebCodecs. If it falls behind, it skips to the next
|
||||
keyframe instead of showing old frames late.
|
||||
- It falls back to JPEG stills (**Compatible** quality) where H.264 isn't
|
||||
available.
|
||||
- **Flow control.** The agent never lets frames queue up anywhere on the
|
||||
way. It skips capture frames *before* encoding, so no reference frame goes
|
||||
missing, and it lowers the bitrate, then the frame rate, then the size, to
|
||||
fit the link (see [Adapting to the network](#adapting-to-the-network)).
|
||||
- **Input.**
|
||||
- A click on a window's panel brings that Mac window to the front
|
||||
(Accessibility API), then clicks at the same point. Double clicks, right
|
||||
clicks, drags and the wheel work too.
|
||||
- Keys typed into the panel are sent as Mac key codes. Any keys or buttons
|
||||
still held down are released if the viewer loses focus or disconnects, or
|
||||
when the stream stops. Characters the key
|
||||
table doesn't know, such as those from other keyboard layouts, are typed
|
||||
as text.
|
||||
- The Mac's own keyboard and trackpad keep working as normal. Click a panel
|
||||
with the laser, then type on the Mac.
|
||||
|
||||
## Permissions (Mac)
|
||||
|
||||
- **Screen Recording**, to see windows. Without it, the card asks for it.
|
||||
- **Accessibility**, so input from the headset reaches the Mac. Without it the
|
||||
stream still works, and the viewer says clicks won't go through.
|
||||
|
||||
Both are granted to Frame Control. After granting, press **Refresh**, which
|
||||
restarts the helper so it picks them up. The app is ad-hoc signed, so macOS
|
||||
may ask again after an update.
|
||||
|
||||
## Quality settings
|
||||
|
||||
| Setting | Long side | fps | Codec | Use |
|
||||
|---|---|---|---|---|
|
||||
| Sharp | 2560 | 60 | H.264, ~0.14 bits/pixel | Text-heavy windows on a strong link |
|
||||
| Balanced (default) | 1920 | 60 | H.264, ~0.1 bits/pixel | Most things |
|
||||
| Light | 1280 | 30 | H.264 | Weak Wi-Fi or Tailscale off the LAN |
|
||||
| Compatible | 1280 | 20 | JPEG | A Frame browser without H.264 |
|
||||
|
||||
## Measuring
|
||||
|
||||
Every frame carries a sequence number, and the agent records its journey on
|
||||
the Mac's clock (`Sources/Stats.swift`):
|
||||
|
||||
| Stage | From → to |
|
||||
|---|---|
|
||||
| capture | the Mac composited it (ScreenCaptureKit's display time) → the agent got it |
|
||||
| queue, encode | → encoding started → the encoder finished |
|
||||
| network | → the viewer received it |
|
||||
| decode, draw | → WebCodecs decoded it → it was drawn on the page's canvas |
|
||||
| present | → the page's next animation frame |
|
||||
|
||||
- **Clock sync.** The viewer syncs its clock to the Mac's the way NTP does:
|
||||
it pings over the stream's own WebSocket and keeps the sample with the
|
||||
shortest round trip. It then reports, in Mac time, when each frame arrived
|
||||
(right away, so the agent can pace itself) and when it was decoded and
|
||||
drawn (in batches every 250 ms).
|
||||
- **Input.** The first frame captured after a click or key carries that
|
||||
event's id. So input latency is the viewer's event → injected on the Mac →
|
||||
the first frame after it → drawn in the headset.
|
||||
- **Where to see it.**
|
||||
- `GET /stats` (key required) returns every frame and input record.
|
||||
- `/status` includes a two-second summary, which Frame Control's card
|
||||
shows next to each live stream.
|
||||
- In the headset, add `?stats=1` to the viewer or press
|
||||
Ctrl+Alt+Shift+S for an overlay.
|
||||
- **The benchmark.** `scripts/macview-bench.py` runs fixed scenarios on the
|
||||
real Frame, from the Mac, with nobody wearing the headset:
|
||||
- **test** is the moving test pattern.
|
||||
- **scroll** is a Chrome page on its own display scrolling at 240 pt/s,
|
||||
which gives about 9 Mbit/s of real 1920×1290 video.
|
||||
- **type** types into a Chrome text box, first fast and then with pauses.
|
||||
|
||||
It writes `bench/results/<date>-<commit>-<label>.json`, compares two
|
||||
results, and runs interleaved A/B tests between agent settings (`ab`).
|
||||
Wi-Fi changes from minute to minute, so single runs at different times
|
||||
aren't comparable. Throttled links come from a shaping relay on the Mac,
|
||||
which needs no sudo (`--net 50@0,3@8,50@16` means 50 Mbit/s, then 3 from
|
||||
8 s, then 50 from 16 s).
|
||||
- **What's graded.** "Content" runs from when the Mac composited a frame
|
||||
(or when ScreenCaptureKit delivered it, if that was earlier) to when it was
|
||||
drawn in the viewer. The Frame's compositor adds its own delay after that.
|
||||
That part is reported, but not graded: an unworn Frame throttles panels to
|
||||
about 36 fps after a few seconds, and to 15 fps in standby, whatever they
|
||||
draw. This was **verified** with a local canvas page that ran on the Frame
|
||||
with no network involved (`bench/pages/present.html`). The compositor's
|
||||
share needs a run with the headset worn.
|
||||
|
||||
Targets: content p50 ≤ 25 ms (p95 ≤ 40), click to photon p50 ≤ 50 ms
|
||||
(p95 ≤ 70), 60 fps with ≤ 1% late frames, no stall over 100 ms, and adapting
|
||||
to a new link rate within 1 s.
|
||||
|
||||
Baseline on 2026-09-28 (**verified**, home Wi-Fi, Tailscale, Balanced,
|
||||
`bench/results/2026-09-28-a3c6e5c-dirty-baseline-fixed.json`; ms p50/p95):
|
||||
|
||||
| Scenario | Content | Input to drawn | fps drawn | Notes |
|
||||
|---|---|---|---|---|
|
||||
| test (1280×720) | 9.9 | 28.8 / 39.0 | 59 | encode 4.0, network 4.0, decode 1.3 |
|
||||
| scroll (1920×1290) | 14.7 | – | 50.4 | encode 6.7, decode 6.4 (software), 9.4 Mbit/s, 16% late |
|
||||
| type (1920×1290) | 16.7 | 51.2 / 73.2 | – | most of the input time is the Mac app reacting |
|
||||
|
||||
After this work, with the controller on (**verified**, same setup,
|
||||
`bench/results/2026-09-28-9e4dcdd-final.json`; ms p50/p95). Content is
|
||||
now measured from the earlier of display time and delivery, which adds
|
||||
about 5 ms to scroll compared with the baseline's way of measuring:
|
||||
|
||||
| Scenario | Content | Input to drawn | fps drawn | Grades |
|
||||
|---|---|---|---|---|
|
||||
| test | 10.5 / 16.7 | 29.6 / 36.3 | 60 | all within target |
|
||||
| scroll | 19.8 / 27.4 | – | 55.9 | fps, late frames (4.8%) and worst gap (222 ms) only "acceptable": Wi-Fi stalls (the Mac captured 57 fps in this run; earlier runs got 46–53 from virtual displays) |
|
||||
| type | 15.0 / 21.4 | 43.0 / 60.5 | – | all within target |
|
||||
|
||||
Of the targets, click to photon is met without the Frame's compositor (the
|
||||
headset has to be worn to measure its share), and so is content latency.
|
||||
The frame-rate and no-stall targets aren't yet met while scrolling.
|
||||
|
||||
**Worn** (**verified** 2026-09-28 22:00, `vrcmd --stats` activity level 1,
|
||||
home Wi-Fi over Tailscale, `bench/results/2026-09-28-39afb23-worn.json`;
|
||||
ms p50/p95):
|
||||
|
||||
| Scenario | Content | Input to drawn | fps drawn | What happened |
|
||||
|---|---|---|---|---|
|
||||
| test | 10.8 / 15.3 | 25.7 / 34.4 | 58 | as unworn |
|
||||
| scroll | 22.2 / 141 | – | 37.7 | Wi-Fi queued 56–364 ms and held frames for 220–270 ms; the controller went to 2.6 Mbit/s and 45 fps |
|
||||
| type | 13.5 / 24.3 | 56.8 / 106 | – | slower replies than unworn (43 / 60) |
|
||||
|
||||
The Frame's CPU wasn't the limit: about 27% in total, and the viewer took
|
||||
45% of one core. The link to a headset on someone's head is much rougher
|
||||
than to one lying still. The adaptation keeps latency bounded there, but
|
||||
the frame rate drops. The USB-C cable avoids Wi-Fi entirely. Frames
|
||||
actually shown were still about 39 fps for the test pattern while worn, so
|
||||
the unworn throttling isn't the whole story. The cause is **unknown**.
|
||||
|
||||
What was learned (all **verified**, unless marked):
|
||||
|
||||
- The biggest costs are encoding (4–7 ms), network (4–6 ms), and decoding
|
||||
on the Frame. Chromium XR on the Frame decodes H.264 in **software**.
|
||||
- Wi-Fi alone stalls for 240–580 ms now and then, over Tailscale and over
|
||||
the LAN alike. Over the LAN (`--host 192.168.1.237`) latency was no
|
||||
better, but the Frame used about 8% less CPU, because tailscaled runs in
|
||||
userspace there.
|
||||
- With no controller, a link that slows down queues without limit. In one
|
||||
run, frames arrived 1.9 s late, and at worst 9.4 s late.
|
||||
- Tried, and no help, so not kept: VideoToolbox options (require hardware,
|
||||
no frame delay, prioritise speed, a hard data-rate cap), Chromium flags
|
||||
(`--disable-gpu-vsync`, `--disable-frame-rate-limit`,
|
||||
`--use-angle=vulkan`), and a 120 Hz virtual display.
|
||||
- Inconclusive, so off by default: keeping the Frame's Wi-Fi awake during
|
||||
typing (`FRAME_MAC_VIEW_WARM=40`, a tiny message every 40 ms for 5 s
|
||||
after input). Over three interleaved runs each, input p50 went 44 → 47 ms
|
||||
and p95 92 → 71 ms, and the ranges overlapped widely
|
||||
(`…-ab-keepwarm.json`). In that run, and in the baseline's typing, the
|
||||
harness typed spaces as "+" (a URL-encoding bug, since fixed), so they
|
||||
went through the text path rather than as space keys.
|
||||
- Pointer moves now go out on an 8 ms timer, not on the page's next
|
||||
animation frame, which an unworn Frame slows to 15–36 Hz. This is
|
||||
**inferred** to help dragging; the benchmark has no drag scenario yet.
|
||||
- Kept: the encoder's timestamps never jump more than two frame intervals.
|
||||
Before this, the first frame after a pause got a quarter of a second's
|
||||
bit budget, and one P-frame reached 204 KB.
|
||||
|
||||
## Adapting to the network
|
||||
|
||||
`Sources/Controller.swift`, per stream, latency first:
|
||||
|
||||
- **The gate.** The viewer acknowledges every frame as it arrives. A new
|
||||
frame is sent only while the oldest unacknowledged one is younger than
|
||||
the path's usual round trip, plus one frame interval, plus room for this
|
||||
link's normal jitter (1.5 times its recent spread, 25–80 ms). So frames
|
||||
never queue in SSH, TCP or the Wi-Fi driver. While the link is stuck, the
|
||||
newest picture waits and goes out as soon as it moves.
|
||||
- **The bitrate.** The link counts as congested when, for two checks in a
|
||||
row (100 ms apart), round trips grow by more than 40 ms while the stream
|
||||
uses much of its budget, or the gate holds frames back, or a frame is
|
||||
stuck for 100 ms. Then the bitrate drops to a bit under what actually got
|
||||
through: at least a fifth off, and at most half. Once the link has been
|
||||
clear for a second, it rises by 10% steps, never above the quality
|
||||
setting's bitrate.
|
||||
- **The tier.** When the bitrate stays low, and the stream is really
|
||||
limited by the link rather than having little to send, it steps down:
|
||||
60 → 45 → 30 fps, then 75%, then 50% of the pixels. It goes straight to
|
||||
the tier the bitrate supports after half a second, and steps back up one
|
||||
tier at a time after two seconds with room to spare.
|
||||
- `FRAME_MAC_VIEW_ADAPT=0` turns it off, for comparison.
|
||||
|
||||
Measured on the real Frame, 2026-09-28 (**verified**; interleaved A/B, off
|
||||
versus on, medians of the runs, ms):
|
||||
|
||||
| Link | Scenario | Content p95, off → on | fps drawn, off → on | Result file |
|
||||
|---|---|---|---|---|
|
||||
| Clean Wi-Fi (3 runs each) | scroll | 32.3 → 33.6 | 57 → 56.2 | `…-ab-adapt-clean2.json` |
|
||||
| Clean Wi-Fi | test | 15 → 14.2 | 60 → 59.7 | same |
|
||||
| 50 → 3 → 50 Mbit/s at 8 s and 16 s (2 runs each) | scroll | **4670 → 72** | 45 → 42 | `…-ab-adapt-step3.json` |
|
||||
| 50 → 3 → 50 Mbit/s | test | 66 → 16 | 60 → 60 | same |
|
||||
|
||||
- **Clean link.** On a clean link it costs nothing measurable. An earlier
|
||||
version with a fixed gate slack lost 9 fps to Wi-Fi jitter while
|
||||
scrolling (47 → 38 fps), and that's why the slack now follows the link's
|
||||
jitter.
|
||||
- **Throttled link.** Without the controller, frames queued for up to 5.6 s
|
||||
and never caught up while the link was slow (p95 1.8–5.6 s, second by
|
||||
second). With it, in the two runs:
|
||||
|
||||
| | Run 1 | Run 2 |
|
||||
|---|---|---|
|
||||
| Worst second's p95 just after the drop | 219 ms | 428 ms |
|
||||
| p95 back under 100 ms for 3 s in a row | after 1 s | after 4 s |
|
||||
| Stepped down to 1440 px at 30 fps | 1.8 s after the drop | 3.5 s after |
|
||||
| p95 per second after that, on the 3 Mbit/s link | 55–94 ms | 60–148 ms |
|
||||
|
||||
Sending one frame takes about 27 ms on that link by itself. After the
|
||||
link recovered, the stream was back at full size and 60 fps in about
|
||||
7.5 s. It steps up one tier every two seconds, on purpose, so it doesn't
|
||||
bounce. The 1 s adaptation target was met in one run of two.
|
||||
- **A hiccup on a small stream.** In one clean run, a Wi-Fi hiccup made an
|
||||
earlier version halve the test pattern's bitrate five times and drop it
|
||||
to half size. That cut couldn't help: the stream only sends
|
||||
0.47 Mbit/s. Now the controller estimates what a stream wants (captures
|
||||
per second × average frame size). While a stream wants about half its
|
||||
budget or less, no cut takes it below twice what it wants, so it doesn't change
|
||||
tier.
|
||||
|
||||
## Checked so far (2026-09-28)
|
||||
|
||||
- **Mac (checked by hand, macOS 26.5.2).**
|
||||
- The agent builds, and lists windows and displays.
|
||||
- In a browser, the test pattern decoded at about 60 fps (H.264) and 30 fps
|
||||
(JPEG, about 22 Mbps).
|
||||
- A click in the viewer arrived at the same point in the source.
|
||||
- `pmset -g assertions` showed the display-awake assertion only while a
|
||||
stream was being watched.
|
||||
- **Automated** (`tests/test_macview.py`, on CI's macOS runner): the agent
|
||||
builds (a build failure fails the job).
|
||||
- `/ping` and the page are open; everything else needs the key.
|
||||
- Tickets work once and only for their own source, and Stop revokes
|
||||
reconnect keys.
|
||||
- A WebSocket frame claiming 2^63 bytes closes that socket, and the agent
|
||||
keeps running.
|
||||
- A stream sends an SPS-led H.264 keyframe, and sends another when asked.
|
||||
- Stop ends the stream on the Mac even if the viewer ignores it.
|
||||
- The test doesn't decode video, time it, or check where clicks land.
|
||||
- **Linux aarch64 (verified in a stand-in, not on the Frame).** In an Arch
|
||||
Linux ARM container with sshd, Xvfb as `:0` and Chromium 153:
|
||||
- The real `show` path worked in 1–2.3 s: tunnel, ticket, launcher,
|
||||
window found and tagged `STEAM_GAME`.
|
||||
- Frame Control's key didn't appear anywhere in the stand-in's process
|
||||
list.
|
||||
- After the tunnel was killed, it came back on the same port within 4 s,
|
||||
and the viewer reconnected by itself.
|
||||
- Chromium decoded the stream in software with the GPU off.
|
||||
- Stop closed the window, and Chromium exited.
|
||||
- This test found and fixed a bug: in a C locale, `xwininfo` can't print a
|
||||
title with non-ASCII characters, so the launcher reads `_NET_WM_NAME`
|
||||
with `xprop`.
|
||||
- **On the Frame (verified 2026-09-28, SteamOS build 20260925.6191901,
|
||||
from the Mac, nobody wearing the headset).** Frame Control's **Show** with
|
||||
the test pattern:
|
||||
- The panel was ready in 1.45 s. SteamVR logged `[Overlays] Created:
|
||||
valve.steam.desktopgame.2001639889` (in `vrwebhelper_systemui.txt`).
|
||||
- The window was tagged `STEAM_GAME`, and gamescope sized it to 1920×1080
|
||||
although 1280×720 was asked for.
|
||||
- Chromium XR decoded it live at about 60 fps: the frame counter advanced
|
||||
62 in 1.04 s.
|
||||
- Over home Wi-Fi, the frames in the viewer window had been drawn on the
|
||||
Mac about 11–17 ms earlier, plus `xwd`'s own time. That's measured
|
||||
against the two clocks, which were 37–39 ms apart (±4 ms, measured over
|
||||
one SSH session). SteamVR's compositor and the display come on top.
|
||||
- Clicks and keys injected with XTest into gamescope's Xwayland didn't
|
||||
reach the page. That's inconclusive, not a failure: XTest on gamescope
|
||||
isn't how real input arrives. The laser should arrive as a left mouse
|
||||
button and the thumbstick as a wheel, because the window has an app id
|
||||
(from gamescope's source; see [panels.md](panels.md)).
|
||||
- **Not yet checked:**
|
||||
- Clicking, dragging and scrolling with the laser while wearing the
|
||||
headset.
|
||||
- Real window capture and input on a Mac with both permissions granted.
|
||||
- Keys from SteamVR's on-screen keyboard.
|
||||
- Whether Flathub Chromium has H.264. Chromium XR is used when it's
|
||||
installed, as it is on this Frame.
|
||||
- Latency with real, busy windows at Sharp.
|
||||
- A benchmark run while wearing the headset. Only then does the Frame show
|
||||
panels at full rate, so only then can the compositor's share of the
|
||||
latency, and the frame rate you actually see, be measured.
|
||||
|
||||
## Limits
|
||||
|
||||
- Only windows on the Mac's current desktop (Space) are listed, and
|
||||
minimised windows can't be captured.
|
||||
- A window's panel shows only that window. Its menus and sheets are separate
|
||||
windows on the Mac, so open them from the Mac or use **Whole screen**.
|
||||
- Keys go to whichever Mac window is in front. Clicking a panel brings its
|
||||
window to the front first.
|
||||
- Ctrl stays Ctrl. On the Mac, copy is ⌘C, so use Meta+C on a keyboard paired
|
||||
with the Frame.
|
||||
@@ -0,0 +1,200 @@
|
||||
# Real separation of Mac windows in the headset
|
||||
|
||||
Research and experiments on 2026-09-28 (macOS 26.5.2, Apple M5 Pro), for
|
||||
making each streamed Mac window truly independent. Builds on
|
||||
[mac-in-headset.md](mac-in-headset.md). Labels: **verified** (tried here),
|
||||
**documented**, **source** (read in someone's code) or **reported**.
|
||||
|
||||
## What "separate" lacks today
|
||||
|
||||
Today each window is captured on its own
|
||||
(`SCContentFilter(desktopIndependentWindow:)`), but it still lives on the
|
||||
Mac's one desktop:
|
||||
|
||||
- **Clicks.** A click has to raise the window first, so it reorders the
|
||||
Mac's windows, steals focus and moves the real cursor.
|
||||
- **Child windows.** A window's menus, popovers, sheets and tooltips are
|
||||
separate windows, so they aren't in its panel. Apple documents that the
|
||||
single-window filter leaves them out.
|
||||
- **Size.** A panel's size is tied to the window's size on the Mac screen.
|
||||
- **Hidden windows.** Minimised windows, and windows on other Spaces, can't
|
||||
be shown live.
|
||||
|
||||
## How the Mac draws, and where pixels can be read
|
||||
|
||||
Apps draw with Core Animation into IOSurfaces. WindowServer's compositor
|
||||
stacks every window onto each display, including virtual ones, and sends
|
||||
the result to the screen. Pixels can be read at three points:
|
||||
|
||||
| Where | How | Gets | Doesn't get |
|
||||
|---|---|---|---|
|
||||
| One window's content | ScreenCaptureKit `desktopIndependentWindow` (public, what we use) | Live, zero-copy, even when covered by other windows (**documented**) | Menus, popovers, sheets. It pauses while the window is minimised (**reported**) |
|
||||
| One window plus its children | `SCStreamConfiguration.includeChildWindows` (macOS 14.2+, **reported**) | Menus, popovers and sheets attached to the window | Anything the app puts outside the window's bounds |
|
||||
| A whole display | ScreenCaptureKit display filter, with apps or windows included or excluded | Everything on that display, cursor included | Only what's on that display |
|
||||
| A snapshot of any window | Private `CGSHWCaptureWindowList`, which AltTab uses for minimised windows and other Spaces (**source**: `alt-tab-macos` `PrivateApis.swift`) | Minimised windows and other Spaces | It's one still image, not a live stream |
|
||||
| Another app's layer tree | Private `CALayerHost` with a context id | A live, zero-copy picture | It only works when the other app cooperates, so it's no good for arbitrary windows (**reported**) |
|
||||
|
||||
`CGWindowListCreateImage` and `CGDisplayStream` are obsolete in the macOS 15
|
||||
SDK (**reported** by MacPorts and JUCE). Nothing reads another app's pixels
|
||||
without the Screen Recording permission.
|
||||
|
||||
## The idea: each window gets its own virtual display
|
||||
|
||||
A virtual display (the private `CGVirtualDisplay`, as used by BetterDisplay,
|
||||
DeskPad and quest-display) is a compositor target with no physical screen.
|
||||
Put one streamed window alone on its own virtual display, sized to its
|
||||
panel, and capture the whole display:
|
||||
|
||||
- **Nothing can overlap it.** A plain click at that point always lands on
|
||||
that window, so input doesn't need raising or background-event tricks.
|
||||
- **Menus, sheets, popovers, tooltips and context menus appear on the same
|
||||
display, so they're in the panel.** With "Displays have separate Spaces"
|
||||
on (it is on this Mac), each display also has its own menu bar, so the
|
||||
app's menu bar can be part of the panel.
|
||||
- **The panel's size is the display's size,** in HiDPI. Resizing the panel
|
||||
means changing the display mode and resizing the window to fill it
|
||||
(Accessibility API).
|
||||
- **Minimised windows and other Spaces stop being a problem,** because
|
||||
streamed windows live on their own displays.
|
||||
- **The cursor goes where the laser points.** That display's panel is the
|
||||
one being used, much as Vision Pro's Mac Virtual Display works. Keys from
|
||||
the Mac's keyboard go to the window last clicked.
|
||||
|
||||
### Verified here (no permission needed)
|
||||
|
||||
- **Creating one.** It needs no permission or entitlement. 1920×1080 HiDPI
|
||||
gives a 3840×2160-pixel, 60 Hz display, placed next to the built-in
|
||||
screen, and `NSScreen.screensHaveSeparateSpaces` was true.
|
||||
- **Many at once.** 16 were created at once with no error.
|
||||
- **Placement.** `CGConfigureDisplayOrigin` far away failed with error
|
||||
1014: macOS keeps displays edge to edge. Disabling one with the private
|
||||
`CGSConfigureDisplayEnabled` from a command-line tool also failed with 1014.
|
||||
- **Removal is deferred while the physical screen sleeps.** With the
|
||||
built-in display asleep, virtual displays were not removed when released,
|
||||
or when their process exited, even from their own `.app`. A second display
|
||||
with the same vendor, product and serial couldn't be created. All of them
|
||||
disappeared as soon as the screen woke (`caffeinate -u`). The helper must
|
||||
therefore:
|
||||
- keep a fixed pool of displays and reuse them rather than create new ones;
|
||||
- keep the screen awake while they exist, which it already does while
|
||||
streaming.
|
||||
|
||||
### Built: "Give each window its own display"
|
||||
|
||||
Frame Control's helper now does this (`mac/frame-mac-view/Sources/Separate.swift`),
|
||||
and it's the default in the Tools card. Streams of this kind are named
|
||||
`separate:<window id>`.
|
||||
|
||||
- For each window shown, it creates a HiDPI virtual display. The display is
|
||||
sized to the window plus a menu bar, with a fresh serial each time, so a
|
||||
display left over while the screen slept can't block a new one.
|
||||
- The window is moved onto the display and resized to fill it (Accessibility
|
||||
API), and the whole display is captured.
|
||||
- On **Stop** the window goes back where it was, and the display is released.
|
||||
- Plain per-window capture is still there: untick "Give each window its own
|
||||
display". Separate mode needs Accessibility (to move the window), and
|
||||
without it, Show says so rather than quietly falling back.
|
||||
|
||||
Verified 2026-09-28 (macOS 26.5.2, M5 Pro), using a TextEdit test document
|
||||
and the benchmark's Chrome windows:
|
||||
|
||||
- **Separation works.** The window moved onto its own display and streamed,
|
||||
with its first frames in 3.8 s. The display showed TextEdit's own menu bar.
|
||||
- **Child windows come along.** A context menu and the Page Setup sheet
|
||||
opened on the same display and were in the capture.
|
||||
- **Input.** Typing through the stream reached the window. The benchmark's
|
||||
typing scenario does this on every run.
|
||||
- **Stop.** Stop put the window back at exactly its old size (656×422), and
|
||||
the display was removed.
|
||||
- **The helper must run a real Cocoa event loop.** Earlier, it ran only a
|
||||
`RunLoop`. With that, AppKit never learned about new displays:
|
||||
- `NSScreen.screens` never listed them, so placing the window always waited
|
||||
3 s and then gave up;
|
||||
- the display's HiDPI mode never applied, so windows were captured at 1×
|
||||
(1280×860 instead of 1920×1290 pixels) and text was soft.
|
||||
|
||||
Running `NSApplication` (with no Dock icon) fixed both. The screen now
|
||||
appears within 100 ms, and captures are at 2× (**verified** in
|
||||
`bench/results/*-baseline.json` against `*-baseline-fixed.json`).
|
||||
- **Frame rate.** Chrome drew at 60–61 fps on the virtual display, but
|
||||
ScreenCaptureKit delivered only about 46–53 fps from it, whereas the
|
||||
built-in ProMotion screen gave 121 fps (**verified**). Neither a 120 Hz
|
||||
virtual display (`FRAME_MAC_VIEW_VD_HZ=120`) nor a looser
|
||||
`minimumFrameInterval` changed that. Cause unknown.
|
||||
- **Windows that keep their own size** (Calculator, 230×408). The window
|
||||
moved and streamed, but stayed small in the display's corner, so the panel
|
||||
was mostly wallpaper. Now only that corner is captured
|
||||
(`SCStreamConfiguration.sourceRect`): the menu bar and the window, at least
|
||||
480×360 points so menus fit. Clicks map to the cropped area. The first
|
||||
frame arrived in 0.6–1.1 s, and on Stop the window went back and the
|
||||
display was removed. A display's menu bar names the active app, so it
|
||||
shows the window's own menus only once the panel has been clicked.
|
||||
- **Several windows at once** (on the Mac, with a local stand-in viewer that
|
||||
acknowledges frames). Three and four Chrome windows, each scrolling on its
|
||||
own display at 1920×1290 pixels, streamed at 53–56 fps and about
|
||||
9 Mbit/s each. The helper used 20% (three) or 24% (four) of one CPU core.
|
||||
The hardware encoder is shared: its time per frame went from 6.7 ms for
|
||||
one stream to 7–15 ms for three and 11–25 ms for four, so each extra
|
||||
moving window adds latency to the others. Once in four runs, before a fix,
|
||||
one window left its display when several displays appeared at the same
|
||||
moment, and its stream stopped: nothing on that display was changing any
|
||||
more. The helper now checks every second and puts the window back. Three
|
||||
further runs with four windows were clean, and every display was gone
|
||||
afterwards (checked with `CGGetOnlineDisplayList`).
|
||||
- **Quitting.** On SIGTERM, or when Frame Control goes away, the helper ends
|
||||
every stream first, so windows go back before their displays disappear.
|
||||
Verified: a 900×600 window was back at 900×600 after SIGTERM. Closing a
|
||||
viewer 0.05–1.5 s into startup left the window at its old size and no
|
||||
extra display.
|
||||
- **A consent prompt.** macOS 26 asks whether to let ScreenCaptureKit apps
|
||||
"bypass the system private window picker". The prompt appeared *on the
|
||||
virtual display*, so it would show up inside the headset panel. Allow it
|
||||
once on the Mac.
|
||||
|
||||
### Still to check
|
||||
|
||||
1. That a context menu opens over the text being clicked, not just somewhere
|
||||
on the display. This needs a retest after the consent prompt above is
|
||||
allowed.
|
||||
2. Stage Manager, which is reported to undo Accessibility resizes.
|
||||
3. Where the Dock and the cursor go on a virtual display.
|
||||
4. Closing the lid with virtual displays attached (clamshell).
|
||||
5. Many moving windows at once in the headset: whether to lower the bitrate
|
||||
or frame rate of panels you aren't using, so the one you are using keeps
|
||||
the encoder to itself.
|
||||
6. All of it in the headset, with the laser.
|
||||
|
||||
## Input without disturbing the Mac (a finer option)
|
||||
|
||||
For windows that stay on the Mac's own screen, input can go to a window
|
||||
behind others without raising it:
|
||||
|
||||
- **Background click.** `CGEventPostToPid` with the CGEvent fields 91 and
|
||||
92, which say which window a click is for (`kCGMouseEventWindowUnderMousePointer`
|
||||
and `...ThatCanHandleThisEvent`), plus a window-relative location
|
||||
(**reported**, reverse-engineered, needs testing).
|
||||
- **Focus without raise.** yabai makes a window key without raising it by
|
||||
posting event records with the private `SLPSPostEventRecordTo` (**source**:
|
||||
`yabai/src/window_manager.c`).
|
||||
- **Known failures.**
|
||||
- Chromium and Electron ignore background clicks unless they're built
|
||||
with the 2026 `acceptsFirstMouse` fix (electron/electron#54493).
|
||||
- Games and canvas apps often need real activation.
|
||||
- Password fields (Secure Event Input) drop synthetic keys.
|
||||
- IME composition, as for Chinese or Japanese input, isn't reliable.
|
||||
|
||||
With a virtual display per window, most of this isn't needed. It's worth
|
||||
having for hovering over one panel while typing in another.
|
||||
|
||||
## Encoding and transport
|
||||
|
||||
- **Codec.** Keep H.264 4:2:0. Apple's High Performance Screen Sharing uses
|
||||
4:4:4 over two virtual displays (**documented**), but the Frame's Chromium
|
||||
decodes H.264 in software, and no 4:4:4 decode path is confirmed there.
|
||||
- **Sharper static text.** When a panel has been still for a moment, send a
|
||||
high-quality refresh, either a keyframe at low quantisation or a lossless
|
||||
WebP overlay, so static text is sharp. Moving content stays as video.
|
||||
- **Transport.** Keep WebSocket over SSH for now, as it works everywhere.
|
||||
WebRTC (UDP, congestion control) is the proven step up for Wi-Fi.
|
||||
WebTransport over QUIC is promising, but its server side on macOS is
|
||||
unverified.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Flat-to-VR mods and Beat Saber songs
|
||||
|
||||
**Status: feasibility work, not an installer.** Frame Control does not yet
|
||||
manage mods or Beat Saber songs. [Issue #26](https://github.com/saphid/frame-control/issues/26)
|
||||
stays open: neither UEVR injection nor Beat Saber custom-song playback has
|
||||
been verified on this Frame. Alex has an owned copy on a Quest 2; that copy
|
||||
has not been inspected. There is no Mods button until the underlying
|
||||
install, playback and removal have been checked.
|
||||
|
||||
The mod manager must be Frame Control's own implementation. Mods and songs
|
||||
are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other
|
||||
managers must not be dependencies. Steam, Proton and SteamVR remain platform
|
||||
dependencies. No game purchases, entitlement bypasses, withdrawn builds or
|
||||
unofficial mod mirrors are part of this work.
|
||||
|
||||
## Per-game support
|
||||
|
||||
Checked 2026-09-28 on SteamOS **0.4.1**, BUILD_ID **20260925.6191901**, aarch64,
|
||||
with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
|
||||
the observation stated, not a promise that the game is playable. “Documented”
|
||||
means an upstream source describes it; “inferred” means it still needs a test.
|
||||
|
||||
| Game / build | Mod or content | Evidence and support status | Next check |
|
||||
|---|---|---|---|
|
||||
| Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. |
|
||||
| Gravitas, Steam 1067310, Windows | UEVR 1.05 | **Verified: prerequisites and windows only.** Free Steam install completed. The game produced a `SkyArk (64-bit, PCD3D_SM5)` window. UEVR needed .NET; with official .NET 6.0.36 libraries it produced a `UEVR` window. A combined run exited 1 with X11 errors before injection was verified. **Inferred: compatibility remains unknown**, not proven broken. | Retry during a stable headset session; verify injection, stereo scene output, controls and removal. |
|
||||
| Beat Saber, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. |
|
||||
| Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. |
|
||||
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2**, currently charging. No APK, version or installed mods inspected; no Frame playback verified. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
|
||||
| Hogwarts Legacy, Steam 990080 | R.E.A.L. | **Verified: listed in this Frame's owned library, not installed.** Official release access and redistribution permission were not established; the referenced author Patreon page returned HTTP 403. No archive downloaded or game tested. | Obtain a current free release from the author and confirm its terms before any test. A news report saying “free” is not a redistribution grant. |
|
||||
| Horizon Zero Dawn, Steam 1151640; Horizon Forbidden West, Steam 2420110 | R.E.A.L. | **Verified: both listed as owned, neither installed.** Same source/permission blocker as above; runtime support is unverified. | Check each game's supported version against an accessible official release. |
|
||||
| Half-Life 2 VR / other OpenVR games | OpenComposite, per-game replacement | **Documented:** forwards OpenVR calls to OpenXR. **Inferred: Frame compatibility unknown.** Not installed or tested. HL2 VR reached setup with the shipped OpenVR path already. | Test a specific game and replacement DLL only if needed; preserve its original DLL. Never switch the shared headset's runtime globally. |
|
||||
| Doom / Quake / Half-Life Team Beef ports | Author's VR ports plus separately owned or free game data | **Inferred: untested.** Android ARM64 support does not establish OpenXR extension or controller compatibility on Lepton. | Choose an official release and legally usable data set, then test that exact port. |
|
||||
| Skyrim VR, Steam 611670 | SKSEVR / HIGGS / PLANCK stack | **Verified: Skyrim VR is absent from this library.** Owning flat Skyrim or Special Edition is not the VR game's entitlement. Runtime and mod support are unverified. | An already-owned VR copy and version-matched official mod releases are required. |
|
||||
|
||||
The [test record](evidence/mods-2026-09-28.md) distinguishes process startup,
|
||||
visible output and failures. It also records a SteamVR restart during the
|
||||
shared session, which prevents attributing the failed UEVR attempt to FEX.
|
||||
|
||||
## Beat Saber: songs first
|
||||
|
||||
**Documented:** the [BSMG PC guide](https://bsmg.wiki/pc-modding.html)
|
||||
describes extracting each map into its own directory below
|
||||
`Beat Saber/Beat Saber_Data/CustomLevels`. Basic custom songs do not require
|
||||
SongCore; maps that require mod features need their matching dependencies.
|
||||
This is a candidate for our own file manager, not a verified Frame feature.
|
||||
|
||||
Alex's Quest 2 copy is a separate Android candidate. Its ownership does not
|
||||
make the PC `CustomLevels` layout applicable. Until the actual build is
|
||||
inspected, neither a direct song-copy recipe nor APK patching is justified.
|
||||
|
||||
Only maps whose music and chart are permitted for distribution may be used
|
||||
as test fixtures or bundled content. A public download alone does not establish
|
||||
those rights. Start with an original or explicitly licensed basic map.
|
||||
|
||||
**Documented:** [ModsBeforeFriday](https://github.com/Lauriethefish/ModsBeforeFriday)
|
||||
targets Quest Beat Saber over WebUSB/ADB. It is not a generic native ARM64
|
||||
modding protocol. [BSManager](https://github.com/Zagrios/bs-manager/releases/tag/v1.6.0)
|
||||
publishes an aarch64 Flatpak, but its architecture says nothing about Beat
|
||||
Saber or its plugins running on Frame. Neither app is an installation step
|
||||
or dependency for Frame Control.
|
||||
|
||||
## Requirements for our manager
|
||||
|
||||
These are **planned**, not implemented or verified:
|
||||
|
||||
1. Resolve the selected Steam game's real library, installed build, executable
|
||||
architecture and Proton prefix. Confirm ownership through Steam; a directory
|
||||
or app manifest alone is not proof. Keep downloading, installed and playable
|
||||
as separate states.
|
||||
2. Download a pinned mod version from the author's official release. Record
|
||||
the URL, version, license and digest. Verify the published digest when
|
||||
available; an upstream SHA-256 detects corruption but is not a signature.
|
||||
Do not treat “free to download” as permission to redistribute.
|
||||
3. Stage and validate archives before writing into the game or prefix. Reject
|
||||
path traversal, links escaping the destination, archive bombs and unexpected
|
||||
executable content in song packs. Check song metadata and its referenced
|
||||
files, not just the `.zip` suffix.
|
||||
4. Refuse changes while the game is running. Back up originals and journal
|
||||
every managed file and digest. Apply changes atomically where possible and
|
||||
roll back partial failures. Keep runtime prerequisites scoped to this game.
|
||||
5. Uninstall only files still matching our receipt; restore originals without
|
||||
overwriting later user edits. Preserve saves, unrelated mods and songs.
|
||||
Song removal must target one managed map, never the whole CustomLevels tree.
|
||||
6. Expose one-click actions beside the game only after real-Frame install,
|
||||
playback and uninstall pass. Test filesystem and download failure handling
|
||||
with fake-Frame fixtures; those cannot prove FEX injection or VR rendering.
|
||||
|
||||
## Sources
|
||||
|
||||
- [UEVR 1.05 official release](https://github.com/praydog/UEVR/releases/tag/1.05)
|
||||
and [author's usage instructions](https://github.com/praydog/UEVR#getting-started).
|
||||
- [Microsoft .NET 6 release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json),
|
||||
including the SHA-512 hashes used for the test runtimes.
|
||||
- [OpenComposite's OpenXR branch](https://gitlab.com/znixian/OpenOVR/-/tree/openxr),
|
||||
including per-game installation and the need to preserve original DLLs.
|
||||
- [R.E.A.L. author post referenced by the research](https://www.patreon.com/realvr/posts/but-wheres-link-165840151)
|
||||
(HTTP 403 from this environment; contents not verified).
|
||||
- [Half-Life 2 VR official site](https://halflife2vr.com/) and
|
||||
[Episode One on Steam](https://store.steampowered.com/app/2177750/).
|
||||
- [Beat Saber store metadata](https://store.steampowered.com/api/appdetails?appids=620980).
|
||||
@@ -0,0 +1,152 @@
|
||||
# Open questions and on-device checks
|
||||
|
||||
Research as of 2026-09-25, eight days after the Frame's retail release
|
||||
(2026-09-18). Most first-party detail comes from Valve's Steamworks developer
|
||||
pages. Searches of Reddit and the Steam forums turned up **almost no
|
||||
end-user reports** about SSH, desktop streaming, or macOS. Treat that as
|
||||
"not documented yet", not "doesn't work".
|
||||
|
||||
## Verified on device (2026-09-25)
|
||||
|
||||
Checked over SSH from the Mac, read-only, on SteamOS 0.3.0 (`VARIANT_ID=vr`,
|
||||
build 20260922.6101926, kernel 6.18, aarch64):
|
||||
|
||||
- **1–2.** Developer Mode + Set User Password gave working SSH with no terminal
|
||||
steps. `sshd` is enabled and active. The user is `steamos` (in `wheel`) and
|
||||
the hostname is `frame`.
|
||||
- **3.** `frame.local` resolves from the Mac; `avahi-daemon` is active.
|
||||
- **5.** `/etc/ssh/sshd_config` has `Include /etc/ssh/sshd_config.d/*.conf`.
|
||||
The existing drop-ins are `20-systemd-userdb.conf` and `99-archlinux.conf`, so
|
||||
`01-frame-keys-only.conf` would sort first as intended. (`--harden` itself
|
||||
hasn't been run.)
|
||||
- **8.** The in-headset desktop is `kwin_wayland` + `plasmashell` nested
|
||||
inside gamescope (1280×800), with `XDG_RUNTIME_DIR=/run/user/1000/nested_plasma`,
|
||||
`WAYLAND_DISPLAY=wayland-0`, `DISPLAY=:2` and a private D-Bus bus. SteamVR
|
||||
(`vrserver`, `vrcompositor`) and `xrdp` are running.
|
||||
- **9.** `rsync`, `flatpak`, `python3`, `git`, `qdbus6` and `xrdp` are present.
|
||||
`wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale`, `krfb` and `wayvnc`
|
||||
are **not** (Tailscale can be added in `~`; see [tailscale.md](tailscale.md)). `paste-to-frame.sh` now uses Klipper over D-Bus and round-trips
|
||||
text correctly.
|
||||
- Flathub is already configured as a **system** remote; Chromium is the only
|
||||
installed Flatpak. `/` is 10 GB (42% used); `/home` is 929 GB.
|
||||
- `push.sh` copied a test file with rsync.
|
||||
- **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).
|
||||
- **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, 12–15, 16 (off-LAN and after a reboot), 17–21.
|
||||
|
||||
## Check on the headset (in order)
|
||||
|
||||
1. **Is Developer Mode available on a retail unit?** Valve's pages are aimed at
|
||||
developers. Confirm that **Steam Settings → System → Enable Developer Mode**
|
||||
and **Developer → Set User Password** both exist on your OS channel (Stable
|
||||
vs Beta).
|
||||
2. **Does SSH work straight after that, with no terminal steps?** From the Mac,
|
||||
run `nc -z frame.local 22`, then `./scripts/connect.sh`.
|
||||
3. **Does `frame.local` resolve from the Mac (mDNS/Avahi)?** If not, use the IP
|
||||
and set up a DHCP reservation.
|
||||
4. **Does SSH stay enabled after a reboot and after an OS update?** Also check
|
||||
that `~/.ssh/authorized_keys` survives an update.
|
||||
5. **Is the `sshd_config.d` include present?** Check before `--harden`:
|
||||
`ssh frame 'grep -n Include /etc/ssh/sshd_config'`.
|
||||
6. **What does Steam Link on macOS show when connected to `frame`?** Is it the
|
||||
VR view, a flat mirror, or the desktop? Does keyboard/mouse input reach the
|
||||
headset?
|
||||
7. **Does the xrdp session work from Microsoft Windows App on macOS?** Valve
|
||||
only documents Windows Remote Desktop Connection. Is clipboard sync
|
||||
supported?
|
||||
8. **What kind of session is the in-headset Linux desktop?** It could be a
|
||||
normal Plasma Wayland session (with a `wayland-*` socket in
|
||||
`/run/user/$(id -u)`), X11, or something nested in SteamVR. This decides
|
||||
whether `paste-to-frame.sh` works. `ssh frame 'ls /run/user/$(id -u); loginctl list-sessions'`.
|
||||
9. **Are `wl-copy`, `xclip`, and `rsync` present on the image?**
|
||||
`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**~~: 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
|
||||
to type locally.
|
||||
15. **ADB**: does `adb shell` over USB-C from a Mac (not just a Windows PC)
|
||||
reach the Linux side? Does USB power from the Mac cope?
|
||||
16. ~~**Tailscale**~~: answered 2026-09-25. A userspace `tailscaled` in `~`
|
||||
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)): 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
|
||||
option: do ADB-installed apps survive closing and reopening it?
|
||||
19. **Typing in Android apps:** Lepton has no IME installed. Does the SteamVR
|
||||
keyboard or a Bluetooth keyboard reach Android text fields, or does an
|
||||
F-Droid keyboard (installed and enabled with `ime enable`/`ime set`) work?
|
||||
20. **F-Droid 2.0** (Compose 1.12): does it run? If so, the catalogue can
|
||||
install it instead of 1.17.2.
|
||||
|
||||
21. **DeoVR local files:** does DeoVR's file browser show `Videos → VR`
|
||||
(the symlink from `push-vr-video.sh`) or `Z:\home\steamos\Videos\VR`, and do
|
||||
the colour-coded test clips play in 3D (red left eye, cyan right) for both
|
||||
H.264 and H.265? Does the DLNA browser find a server on the Mac?
|
||||
|
||||
## Verified 2026-09-27
|
||||
|
||||
- **Recovery images exist** for the Frame at
|
||||
`https://steamdeck-images.steamos.cloud/recovery/`; the root filesystem inside
|
||||
is btrfs and runs, as a userland, on ARM64 Linux. See
|
||||
[recovery-and-images.md](recovery-and-images.md).
|
||||
- **Frame Control's server runs on the Frame itself** (the iPhone app does
|
||||
this), including headset capture, 31 fps live video and file uploads. See
|
||||
[iphone.md](iphone.md).
|
||||
- **Password pairing and `sudo -S`** work against the recovery image's own
|
||||
sshd and sudo (not yet against the headset, whose password we don't hold).
|
||||
|
||||
## Still open (2026-09-27)
|
||||
|
||||
- Does `podman exec <lepton container> /system/bin/sh -c 'wm size'` change an
|
||||
instance's display the way `adb shell wm size` does?
|
||||
- Can the recovery image, or its kernel, boot in a VM at all?
|
||||
- Does a real sleep, restart or shut down from the iPhone app work (via
|
||||
`sudo -S systemctl`)?
|
||||
- The Mac EDL flashing script in `~/Downloads/steam-frame-recovery/` hasn't
|
||||
been run against a Frame.
|
||||
|
||||
## Mac in the headset (2026-09-28)
|
||||
|
||||
The test pattern streams to the Frame as its own panel at about 60 fps
|
||||
(verified, build 20260925.6191901; see
|
||||
[mac-in-headset.md](mac-in-headset.md#checked-so-far-2026-09-28)). Still to
|
||||
check in the headset:
|
||||
|
||||
- Laser clicks, drags and thumbstick scrolling in a viewer panel.
|
||||
- Real window capture and input once Screen Recording and Accessibility are
|
||||
granted to Frame Control.
|
||||
- Keys from SteamVR's on-screen keyboard.
|
||||
- Whether Flathub Chromium decodes H.264 (otherwise use Compatible).
|
||||
|
||||
## Unconfirmed claims made in these docs
|
||||
|
||||
- `/home` and `/etc` persist across Frame OS updates. This is inferred from
|
||||
Steam Deck behaviour.
|
||||
- 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.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Arranging windows in space
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
|
||||
## The short version
|
||||
|
||||
- The in-headset **Linux desktop is one flat panel**: a nested Plasma session,
|
||||
fixed at 1280×800, drawn into a single SteamVR overlay. Windows *inside* it
|
||||
are arranged by KWin inside that rectangle. They can't leave it.
|
||||
- Every **Steam app gets its own panel**. gamescope runs with
|
||||
`--virtual-connector-strategy PerAppId`, so each distinct app id becomes a
|
||||
separate SteamVR overlay named `valve.steam.desktopgame.<appid>`.
|
||||
- To float a Linux app on its own, run it on gamescope's X display (`:0`)
|
||||
instead of in Plasma, and tag its window with an app id of its own.
|
||||
`scripts/panel-on-frame.sh` does this:
|
||||
|
||||
```sh
|
||||
./scripts/panel-on-frame.sh konsole # a terminal, as its own panel
|
||||
./scripts/panel-on-frame.sh --name notes -- kate '~/notes.md' # quote ~ so the Frame expands it
|
||||
./scripts/panel-on-frame.sh org.mozilla.firefox # a Flatpak
|
||||
./scripts/panel-on-frame.sh mac-screen # the Mac's screen (Remmina/VNC)
|
||||
```
|
||||
|
||||
- Then **place each panel with the SteamVR dashboard's docking controls**:
|
||||
**Float in World**, **Move**, **Size**, **Toggle Curvature**, dock on the
|
||||
left or right controller, **View in Theater**, and **Multitasking View**.
|
||||
|
||||
## How a panel is born (verified 2026-09-25)
|
||||
|
||||
gamescope's command line on the Frame includes:
|
||||
|
||||
```
|
||||
--backend openvr --xwayland-count 2 --virtual-connector-strategy PerAppId
|
||||
--vr-overlay-key valve.steam.gamepadui.fallback
|
||||
--vr-app-overlay-key valve.steam.desktopgame
|
||||
--vr-overlay-physical-width 2.67 --vr-overlay-enable-control-bar
|
||||
--nested-width 1280 --nested-height 720
|
||||
```
|
||||
|
||||
gamescope reads each X11 window's `STEAM_GAME` property as its app id. That's
|
||||
the same property Steam sets on games it launches. On a new id, Steam's
|
||||
SteamVR system UI logs:
|
||||
|
||||
```
|
||||
[Overlays] Created: valve.steam.desktopgame.7777777
|
||||
[Overlays] Created: valve.steam.desktopgame.7777777.layer1 … layer7
|
||||
```
|
||||
|
||||
The test: an `xterm` on `DISPLAY=:0`, tagged with
|
||||
`xprop -id <win> -f STEAM_GAME 32c -set STEAM_GAME 7777777`, produced the
|
||||
overlay above. Two more apps with different ids (`konsole`, `xterm`) produced
|
||||
two more overlays, and all three were listed together in the root property
|
||||
`GAMESCOPE_FOCUSABLE_APPS`. **Not yet checked by eye:** how the new panels
|
||||
look in the headset and how they handle input.
|
||||
|
||||
Untagged windows on `:0` get app id 0 and share the default panel. Plasma
|
||||
itself (`kwin_wayland`, pid in `GAMESCOPE_FOCUSABLE_WINDOWS`) is one of those.
|
||||
|
||||
### What `panel-on-frame.sh` does
|
||||
|
||||
1. Sets `DISPLAY=:0`, unsets `WAYLAND_DISPLAY`, and forces X11 in the
|
||||
toolkits (`QT_QPA_PLATFORM=xcb`, `GDK_BACKEND=x11`, `SDL_VIDEODRIVER=x11`,
|
||||
`MOZ_ENABLE_WAYLAND=0`). A Wayland-only app would connect to gamescope's
|
||||
own Wayland socket and not get tagged.
|
||||
2. Starts the app detached (`setsid nohup`), so it outlives SSH.
|
||||
3. Diffs the root window's children before and after, and sets `STEAM_GAME`
|
||||
on each new mapped top-level window. It keeps watching about 3s after the
|
||||
first window (for splash screens), up to 20s in total (for slow Flatpaks).
|
||||
It gives up early if the app exits before showing a window.
|
||||
4. The id comes from `--id`, or is derived from `--name`/the command in the
|
||||
range 2,000,000,000–2,000,999,999, far above real Steam app ids. The same
|
||||
label always gives the same id.
|
||||
|
||||
Limits:
|
||||
|
||||
- **Single-instance apps** (Remmina, most KDE apps with a running copy in
|
||||
Plasma) hand the request to the existing process, so the window opens
|
||||
wherever that process lives. Close the app in Plasma first.
|
||||
- A window the app opens later (a dialog, a second window) isn't tagged, so it
|
||||
lands on the default panel. Tag it by hand:
|
||||
`ssh frame 'DISPLAY=:0 xprop -id <win> -f STEAM_GAME 32c -set STEAM_GAME <id>'`
|
||||
(find `<win>` with `DISPLAY=:0 xwininfo -root -children`).
|
||||
- The script tags *any* new window on `:0` during its watch window, so a
|
||||
Steam popup that opens in those few seconds would join the panel too. For
|
||||
the same reason, run one `panel-on-frame.sh` at a time. If a stray window
|
||||
is tagged first, the script can report success while the app's own window
|
||||
stays on the default panel; check in the headset.
|
||||
- Each panel renders at gamescope's nested size (1280×720), not the Plasma
|
||||
desktop's 1280×800.
|
||||
- Steam treats the tagged id as "the current game": it applies a generic
|
||||
controller config and logs `Failed to get app info` for the made-up id. So
|
||||
far this hasn't caused anything worse.
|
||||
|
||||
## Placing panels: the SteamVR dashboard (inferred from SteamVR's UI code)
|
||||
|
||||
The Frame's SteamVR dashboard
|
||||
(`/opt/steamvr/resources/webinterface/dashboard/`) wraps each overlay in a
|
||||
frame with a **dock location**: `Dashboard`, `World`, `Theater`,
|
||||
`LeftController`, `RightController`. The strings and handlers are there
|
||||
(`dashboard_english.json`, `systemui.js`):
|
||||
|
||||
| Control | What it does |
|
||||
|---|---|
|
||||
| **Float in World** | Only shown while the panel is docked on the dashboard. Detaches it into the room, where it stays after the dashboard closes. |
|
||||
| **Move** / grab handle | Push, pull and drag the panel. *Grab Handle Acceleration* in SteamVR settings speeds up push and pull. |
|
||||
| **Size** | Resize the floating panel. |
|
||||
| **Toggle Curvature** | Flat vs curved. |
|
||||
| **Dock on Left/Right Controller** | Attach to a controller, like a wrist screen. |
|
||||
| **Dock on Dashboard / Return to Dashboard** | Put it back. |
|
||||
| **View in Theater** / Show/Hide Theater Screen | Shows the panel as a large theater screen. |
|
||||
| **Multitasking View** | Shows every open panel together (only if `VRHTML.BSupportsMultitaskingView()`). |
|
||||
| **More Options** (…) | Where the less common docking actions live. |
|
||||
|
||||
**Still to check in the headset:** where exactly each control appears, whether
|
||||
floating positions survive a panel closing and reopening, and whether there's
|
||||
a limit on the number of floating panels.
|
||||
|
||||
## Other routes
|
||||
|
||||
- **Just the desktop somewhere else**: float the Plasma panel itself. No
|
||||
script needed.
|
||||
- **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth
|
||||
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
|
||||
- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a
|
||||
PC's desktop in SteamVR. They don't run on the Frame's standalone Linux.
|
||||
@@ -0,0 +1,143 @@
|
||||
# Privacy and analytics
|
||||
|
||||
Frame Control sends anonymous analytics to [PostHog](https://posthog.com)
|
||||
(US cloud) so the maintainer can see how many people use it, which features
|
||||
matter and where installs fail. You choose how much in **Privacy & updates**,
|
||||
the last panel on the page. `ui/frame_telemetry.py` is the whole
|
||||
implementation.
|
||||
|
||||
## The three levels
|
||||
|
||||
| Level | Default | What it sends |
|
||||
|---|---|---|
|
||||
| Anonymous usage statistics | On, after a notice on first run | The events in the table below |
|
||||
| Share compatibility results | Off | Your Android compatibility reports and tests |
|
||||
| Send error details | Off | Scrubbed error messages and tracebacks |
|
||||
|
||||
Nothing is sent until the first-run notice has been shown. The notice's
|
||||
**Share more to help fix problems** button turns on the second and third
|
||||
levels together. Either can be turned off later. Turning a level off
|
||||
deletes that level's events that haven't been sent yet.
|
||||
|
||||
**Show what's been sent** in the panel lists the last 50 events that left your
|
||||
computer, exactly as they were sent.
|
||||
|
||||
## Anonymous
|
||||
|
||||
- Events carry a random id, made when Frame Control first runs and kept in
|
||||
its data folder (`telemetry/settings.json`). It isn't derived from your
|
||||
computer, account or network. To get a new one, delete that file.
|
||||
- Events are sent without person profiles (`$process_person_profile: false`)
|
||||
and without location lookup (`$geoip_disable: true`). Each carries a
|
||||
placeholder address (`$ip: 0.0.0.0`), so PostHog stores that instead of
|
||||
yours.
|
||||
- Every event includes the app version, OS name (macOS, Windows or Linux),
|
||||
CPU architecture and Python version.
|
||||
|
||||
## Usage events
|
||||
|
||||
| Event | When | Properties besides the common ones |
|
||||
|---|---|---|
|
||||
| `app_installed` | First run | |
|
||||
| `app_updated` | First run of a new version | `from_version` |
|
||||
| `app_opened` | At most once a day | |
|
||||
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
|
||||
| `tab_viewed` | The first click on each tab in a session | `tab` |
|
||||
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category`, `installer_code`, and see below |
|
||||
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
|
||||
|
||||
`install_finished` never includes a file name, path or error message. An
|
||||
error becomes one category from a fixed list (for example `apk_wrong_abi` or
|
||||
`frame_unreachable`), plus Android's own `INSTALL_FAILED_…` code when there
|
||||
is one. It names what was installed only when that's already public:
|
||||
|
||||
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
|
||||
catalogue app's package name
|
||||
- Flathub apps: `flatpak_id`
|
||||
- Steam games: `steam_appid`
|
||||
- A sideloaded title: only its runtime (Proton or Linux)
|
||||
|
||||
Any other APK is sent as `catalog: false`, with no name.
|
||||
|
||||
## Compatibility results (opt-in)
|
||||
|
||||
Each report becomes a `compat_report` event with the fields the Report dialog
|
||||
shows: package, version, result or rating, your notes, how it was run, and the
|
||||
SteamOS and Lepton builds. Before sending:
|
||||
|
||||
- the notes, app name and version are scrubbed like error messages (see
|
||||
below)
|
||||
- the APK's source is kept only if it's `F-Droid` or the public host name of
|
||||
a download link (`https://example.com/…`). File names, user names,
|
||||
passwords, ports, paths, IP addresses and local host names are dropped
|
||||
|
||||
When you turn this on, reports you made earlier on this computer are shared
|
||||
too.
|
||||
|
||||
The maintainer's `python3 ui/frame_compat_db.py sync` copies these events
|
||||
into the compatibility database, marked `via=community…`. It takes at most
|
||||
30 per reporter per day.
|
||||
|
||||
## Error details (opt-in)
|
||||
|
||||
`$exception` events carry an error message, the Frame Control file, line and
|
||||
function it came from, and the request that failed (for example
|
||||
`POST /api/android install`). Before anything is sent, the message is
|
||||
scrubbed:
|
||||
|
||||
- your home folder becomes `~`, and any user name becomes `<user>`
|
||||
- IP and MAC addresses, email addresses, `.local`, `.lan` and Tailscale host
|
||||
names, Steam ids, SSH and PEM keys, API tokens and long hex strings are
|
||||
replaced
|
||||
- URLs are cut down to their scheme and a public host name, or `<url>`. User
|
||||
names, passwords, ports, paths and queries are dropped
|
||||
- `token=`, `key=`, `password=` and similar values are replaced
|
||||
|
||||
The same error is sent at most once every 10 minutes.
|
||||
|
||||
## Report a problem
|
||||
|
||||
**Report a problem** is the warning-sign button in the header, also in the
|
||||
Privacy panel and under **Help → Report a Problem…**. It sends the report
|
||||
privately to Frame Control's PostHog project as a `problem_report` event, the
|
||||
same way as the analytics above, so only the maintainer can read it and
|
||||
nothing is published. It works whatever the analytics settings are, because
|
||||
the person sends it deliberately. The report has the kind, title and text you
|
||||
wrote, how to reach you if you gave it, a short reference shown after sending,
|
||||
and the diagnostics below. It has its own random id, so it isn't linked to
|
||||
your analytics events.
|
||||
|
||||
With **Include diagnostics** ticked (the default), the report adds:
|
||||
|
||||
- the app version and whether it's a built app
|
||||
- the OS, its release and CPU, and the Python version
|
||||
- the Frame's SteamOS build, if it has connected since the app started
|
||||
- which analytics levels are on
|
||||
|
||||
**Also include recent activity and the server log** is off by default,
|
||||
because those lines can name files and apps. When ticked, it adds the newest
|
||||
Activity lines and server log lines, without the request lines.
|
||||
|
||||
Everything is scrubbed like error details and limited to what fits in the
|
||||
report. Environment details are kept first, then the newest lines. **Show
|
||||
exactly what's included** shows the snapshot that will be sent, and later
|
||||
activity isn't added to it. If PostHog can't be reached, **Copy report** puts
|
||||
the whole report on the clipboard.
|
||||
|
||||
The maintainer reads reports on the Frame Control dashboard in PostHog, or
|
||||
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
|
||||
API key as `frame_compat_db.py sync`.
|
||||
|
||||
## Turning it all off
|
||||
|
||||
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
|
||||
the environment that starts Frame Control. A copy run from a source checkout
|
||||
never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set.
|
||||
|
||||
## Update checks
|
||||
|
||||
The desktop app asks GitHub for the latest release shortly after starting,
|
||||
then every 6 hours: the latest release's `update.json` on GitHub, or
|
||||
`api.github.com/repos/saphid/frame-control/releases/latest` if that fails.
|
||||
Those requests carry no id. To stop it, set
|
||||
`FRAME_CONTROL_NO_UPDATE_CHECK=1`. See [releasing.md](releasing.md).
|
||||
@@ -0,0 +1,109 @@
|
||||
# Recovery images and OS images for the Frame
|
||||
|
||||
Where to get the Steam Frame's operating system, what's inside it, and how to
|
||||
run it for testing without the headset. For recovering a Frame that won't boot,
|
||||
see the boot menu and boot-loop entries in
|
||||
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing).
|
||||
|
||||
## Downloads
|
||||
|
||||
Valve's SteamOS download page (`store.steampowered.com/steamos/download`)
|
||||
redirects to the [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227),
|
||||
which offers the Steam Deck image. The **Steam Frame images are on the same
|
||||
server** but aren't linked from that page:
|
||||
**https://steamdeck-images.steamos.cloud/recovery/** (a plain directory
|
||||
listing, checked 2026-09-27).
|
||||
|
||||
| File | Size | Use |
|
||||
|---|---|---|
|
||||
| `steamframe-oobe-repair-20260922.5153644-0.3.0.img.bz2` (or `.img.zip`) | 3.8 GiB | Write to an 8 GB+ USB-C stick, then **Boot from USB** in the Frame's boot menu |
|
||||
| `steamframe-oobe-repair-qdl-20260922.5153644-0.3.0.tar.gz` (or `.zip`) | 3.8 GiB | Flash over a USB-C cable in Qualcomm EDL mode with `flash.sh` (Linux) or `flash.cmd` (Windows), which use [qdl](https://github.com/linux-msm/qdl). **Wipes everything** |
|
||||
|
||||
All four are dated 2026-09-22. Everything else there is for the Steam Deck
|
||||
(`steamdeck-…`, x86-64), which won't run on the Frame. Valve publishes **no
|
||||
checksums**. These are the SHA-256s of our downloads (2026-09-26), which passed
|
||||
`bzip2 -t` and `tar -t`:
|
||||
|
||||
```
|
||||
3a4a077f1b1f40688ab3279affcb56776bd97c54db1573e7c65fc52a97106676 steamframe-oobe-repair-20260922.5153644-0.3.0.img.bz2
|
||||
d3323bfa8efe9ece1954948421cdf5f705e8942eb50c960e2916d935d1b850ab steamframe-oobe-repair-qdl-20260922.5153644-0.3.0.tar.gz
|
||||
```
|
||||
|
||||
Our copies, with a Mac EDL flashing script built on qdl (untested), are in
|
||||
`~/Downloads/steam-frame-recovery/` on the Mac.
|
||||
|
||||
## What's inside the USB image
|
||||
|
||||
A GPT disk with 512-byte sectors and one A slot (a Frame has A and B slots;
|
||||
the installer makes the rest). **Verified 2026-09-27** from
|
||||
`steamframe-oobe-repair-20260922.5153644-0.3.0.img.bz2`:
|
||||
|
||||
| # | Name | Start sector | Size | Type GUID |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `esp` | 34 | 256 MiB | `c12a7328-f81f-11d2-ba4b-00a0c93ec93b` (EFI system) |
|
||||
| 2 | `efi-A` | 524322 | 64 MiB | `ebd0a0a2-b9e5-4433-87c0-68b6b72699c7` |
|
||||
| 3 | `rootfs-A` | 655394 | 5120 MiB | `4f68bce3-e8cd-4db1-96e7-fbcaf984b709` |
|
||||
| 4 | `var-A` | 11141154 | 256 MiB | `4d21b016-b534-45c2-a9fb-5c16e091fd2d` |
|
||||
| 5 | `home` | 11665442 | 100 MiB | `933ac7e1-2eb4-4f13-b844-0e14e2aef915` |
|
||||
|
||||
The partitions start at sector 34, not on MiB boundaries, so compute offsets
|
||||
from the table (sector × 512), not from rounded sizes. `rootfs-A` is **btrfs**
|
||||
(label `rootfs-A`, 9.2 GB of files), mounted read-only on the Frame.
|
||||
Its `/etc/os-release` says `NAME="SteamOS"`, `ID=steamos`, `ID_LIKE=arch`,
|
||||
`VERSION_CODENAME=holo`; the running system reports version 0.3.0, variant
|
||||
`vr`, build **20260922.5152327**, which is a different number from the
|
||||
`5153644` in the file name. Our headset reports build 20260922.6101926.
|
||||
|
||||
Inside, it matches a real Frame:
|
||||
|
||||
- User `steamos` (uid 1000) is in `wheel` (gid 998), and sudoers has
|
||||
`%wheel ALL=(ALL) ALL`, so sudo asks for the Developer Mode password.
|
||||
- `sshd_config` includes `sshd_config.d/*.conf`, uses `.ssh/authorized_keys`
|
||||
plus `AuthorizedKeysCommand /usr/bin/userdbctl ssh-authorized-keys %u`,
|
||||
and sets `KbdInteractiveAuthentication no` and `UsePAM yes`. So sshd offers
|
||||
`publickey,password`, the same as the headset.
|
||||
- `/usr/bin` has `sshd`, `sudo`, `python3` and `podman`.
|
||||
|
||||
Get just the root filesystem without unpacking the whole 5.8 GB image (the
|
||||
partition's start and size, in sectors, come from the table above):
|
||||
|
||||
```sh
|
||||
bzcat steamframe-oobe-repair-*.img.bz2 | tail -c +$((655394 * 512 + 1)) | head -c $((10485760 * 512)) > rootfs-A.img
|
||||
```
|
||||
|
||||
A Mac can't mount btrfs; a Linux machine or VM can (`mount -o ro -t btrfs`).
|
||||
|
||||
## Running it without the headset
|
||||
|
||||
The image can't boot in a generic virtual machine: its kernel and bootloader
|
||||
are built for the Frame's Qualcomm Snapdragon 8 Gen 3 (**inferred**; not
|
||||
attempted). Its **userland** runs fine on any ARM64 Linux, which covers
|
||||
anything that talks to the Frame over SSH.
|
||||
|
||||
[`tests/frame-container/frame-image.sh`](../tests/frame-container/frame-image.sh)
|
||||
extracts `rootfs-A`, mounts it read-only with a throwaway writable layer, and
|
||||
starts the image's own `sshd` on port 2223 (user `steamos`, a test password;
|
||||
`systemctl` only records requests). On a Mac, run it in Colima's ARM64 VM (see
|
||||
[tests/frame-container/README.md](../tests/frame-container/README.md)).
|
||||
**Verified 2026-09-27:** the iPhone app paired with it by password (the image's
|
||||
sshd logged `Accepted password`, then `Accepted publickey … ED25519`), ran
|
||||
Frame Control's server on the image's Python, and the image's sudo rejected a
|
||||
wrong power password and passed the right one to `systemctl`. Without the
|
||||
Frame's hardware there's no SteamVR, Steam client, battery or Lepton, so those
|
||||
parts stay untested this way.
|
||||
|
||||
## Holo Core aarch64 (Valve and Collabora)
|
||||
|
||||
The ARM64 port of Arch Linux that the Frame's SteamOS is built on, published as
|
||||
a preview in July 2026 ([Collabora's announcement](https://www.collabora.com/news-and-blog/news-and-events/building-an-arch-linux-aarch64-port-for-holo-core.html)).
|
||||
It's a base system and build environment, not the Frame's OS:
|
||||
|
||||
- Source: `https://gitlab.steamos.cloud/holo/holo-core-aarch64-preview`
|
||||
- Packages: `https://holo-packages.steamos.cloud/holo-core-aarch64-preview/mash-20251118`
|
||||
- Container: `registry.gitlab.steamos.cloud/holo/holo-core-aarch64-preview/base-devel:latest`
|
||||
(1.7 GB; `/etc/os-release` says "Holo core Aarch64 port (preview)"; `pacman`
|
||||
installs OpenSSH 10.2, Python 3.13 and sudo from its repositories. Checked 2026-09-27.)
|
||||
|
||||
[`tests/frame-container/Dockerfile`](../tests/frame-container/Dockerfile) builds a
|
||||
lighter Frame stand-in on it (a `steamos` user with a password and sudo, sshd
|
||||
with keys and passwords), handy when you don't have the 4 GB image.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Releasing and updates
|
||||
|
||||
Frame Control checks for updates itself. The desktop app offers a new version
|
||||
only once it's GitHub's **latest release**, and drafts and pre-releases never
|
||||
count. So a build reaches people only when you publish it, after testing it.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Bump `version` in `app/package.json`, commit, and push a tag:
|
||||
|
||||
```sh
|
||||
git tag v0.4.0 && git push origin v0.4.0
|
||||
```
|
||||
|
||||
`.github/workflows/release.yml` builds macOS, Windows and Linux, and
|
||||
attaches everything to a **draft** release for that tag. Nobody is
|
||||
offered a draft.
|
||||
|
||||
2. Download the draft's installers and test them. An installed copy of the
|
||||
previous version won't offer the draft, so install it directly.
|
||||
|
||||
3. Write the release notes on the draft. The update banner links to them.
|
||||
|
||||
4. Publish:
|
||||
|
||||
```sh
|
||||
scripts/publish-release.sh v0.4.0
|
||||
```
|
||||
|
||||
The script checks that all eight installers are attached, each with the
|
||||
SHA-256 digest GitHub records. It attaches `update.json` (the version, the
|
||||
notes and each installer's digest), then publishes the release and marks it
|
||||
latest. From then on, running copies see the update. They check about 8
|
||||
seconds after starting, then every 6 hours, and anyone can use **Check for
|
||||
Updates…** (the app menu on macOS, the Help menu elsewhere).
|
||||
|
||||
To pull a bad release, mark the previous one as latest
|
||||
(`gh release edit v0.3.9 --latest`) or turn the bad one back into a draft.
|
||||
Copies that already updated stay on it. Nothing downgrades them.
|
||||
|
||||
## How a copy updates itself
|
||||
|
||||
`app/updater.js` reads `update.json` from
|
||||
`github.com/saphid/frame-control/releases/latest/download/`. It falls back to the
|
||||
REST API only when a release has no manifest, because the API allows just 60
|
||||
unauthenticated requests an hour per IP address, shared by a whole household.
|
||||
Then it downloads the installer for its platform and checks it
|
||||
against the SHA-256 digest GitHub publishes for the asset. It refuses if the
|
||||
digest is missing or doesn't match. Then:
|
||||
|
||||
| Installed from | Update |
|
||||
|---|---|
|
||||
| macOS `.dmg`, app in a writable folder such as Applications | The `.zip` is unpacked next to the app and its version checked. After the app quits, a small script swaps the new app in, putting the old one back if that fails, and reopens it. Updates don't get the download quarantine, so there's no `xattr` step. |
|
||||
| Windows installer | The new `Setup` runs silently over the install (`/S --force-run`) and reopens the app. |
|
||||
| Linux AppImage | The new AppImage replaces the old file and is started. |
|
||||
| macOS app still on the disk image or translocated, Windows `.zip`, Linux `.deb` | The banner opens the release page instead. |
|
||||
|
||||
Version 0.3.1 and earlier have no updater, so people on them have to download
|
||||
the new version once by hand.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Scripts and headset setup
|
||||
|
||||
The command-line side of this repo: how SSH gets set up with as little typing on
|
||||
the headset as possible, what to use for each job, and the helper scripts that
|
||||
Frame Control is built on. The scripts are zsh/bash and run on macOS; most also
|
||||
run on Linux. On Windows, use the app.
|
||||
|
||||
## Minimum typing on the headset
|
||||
|
||||
Valve's own developer docs say SSH, ADB, and RDP are all turned on through a
|
||||
**UI toggle**. You don't need a terminal, `passwd`, or `systemctl`. The only
|
||||
thing you type on the headset is a password you choose.
|
||||
|
||||
On the Frame:
|
||||
|
||||
1. **Steam Settings → System → Enable Developer Mode** (a toggle, no typing).
|
||||
2. Scroll down to the **Developer** section and click **Set User Password**.
|
||||
Type a password. **This is the only thing you type on the headset.** Pick
|
||||
something short, because you'll type it once more on the Mac and then
|
||||
never again.
|
||||
3. (Optional, no typing) Note the IP address from **Quick Settings** or
|
||||
**Steam Settings → Internet**, in case `frame.local` doesn't resolve.
|
||||
4. (Optional) Check **Steam Settings → System → Hostname**. Leaving it as
|
||||
`frame` means the scripts work without any extra setup.
|
||||
|
||||
On the Mac:
|
||||
|
||||
To use the scripts from a checkout instead of the app:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/saphid/steam-frame.git && cd steam-frame
|
||||
./scripts/connect.sh # or: ./scripts/connect.sh 192.168.1.50
|
||||
ssh frame # passwordless from now on
|
||||
```
|
||||
|
||||
`connect.sh` does four things:
|
||||
|
||||
- finds the headset (`frame.local`, then `frame`, or the IP/host you pass in)
|
||||
- creates dedicated keys (`~/.ssh/id_ed25519_frame`, plus `~/.ssh/id_rsa_frame_devkit` for pairing)
|
||||
- adds a `Host frame` block to `~/.ssh/config`
|
||||
- tries SteamOS devkit pairing (approve on the headset, no password; **inferred**,
|
||||
see [SSH](ssh.md#password-free-pairing-steamos-devkit-service)), else runs
|
||||
`ssh-copy-id`, which asks for the Developer Mode password once
|
||||
|
||||
Run `./scripts/connect.sh --harden` later if you want to turn off SSH password
|
||||
logins.
|
||||
|
||||
Sources: [Valve: Setting up your Steam Frame for development](https://partner.steamgames.com/doc/steamhardware/steamframe/setup),
|
||||
[Valve: Steam Frame Debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging)
|
||||
(both **confirmed on Steam Frame**, Valve official).
|
||||
|
||||
**Fallback, only if the Developer Mode toggle doesn't give you SSH.** From the
|
||||
Mac, run `./scripts/serve-bootstrap.sh`. It prints a one-liner of about 30
|
||||
characters, like `curl -fsS mac.local:8765|bash`, to type into Konsole on the
|
||||
Frame's Linux desktop. The script it serves installs your Mac's public key and
|
||||
enables `sshd`. See [docs/ssh.md](ssh.md#fallback-bootstrap-one-liner).
|
||||
|
||||
## Recommended options
|
||||
|
||||
| Goal | Recommended | Confidence |
|
||||
|---|---|---|
|
||||
| Shell on the Frame | `ssh frame` (user `steamos`) | Confirmed (Valve docs) |
|
||||
| **See/control the Frame from the Mac** | **Steam Link for macOS → connect to `frame`** (Valve names this). Alternatives: RDP to `xrdp` with Microsoft *Windows App* for the Linux desktop, or `adb`/`scrcpy` for the Android (Lepton) layer only | Steam Link and xrdp confirmed on Frame; the Mac RDP client is inferred |
|
||||
| **Show the Mac's desktop inside the Frame** | **macOS Screen Sharing (built-in VNC) → Remmina (Flatpak, aarch64) on the Frame's Linux desktop**, installed over SSH | Inferred: each piece is documented, but the combination hasn't been tested on a Frame |
|
||||
| File transfer | `scp` / `rsync` over the `frame` alias (`scripts/push.sh`) | **Verified** (rsync is on the image) |
|
||||
| Paste Mac clipboard into the headset | `scripts/paste-to-frame.sh` (`pbpaste` → `ssh` → Klipper over D-Bus), or the clipboard sync in an RDP session | **Verified** (script); RDP untested |
|
||||
|
||||
Details: [docs/ssh.md](ssh.md), [docs/streaming.md](streaming.md),
|
||||
[docs/file-transfer.md](file-transfer.md),
|
||||
[docs/open-questions.md](open-questions.md). For how the Frame's software
|
||||
fits together, see [docs/how-the-frame-works.md](how-the-frame-works.md).
|
||||
|
||||
## Windows anywhere in the room
|
||||
|
||||
The in-headset Linux desktop is a single 1280×800 panel, and its windows can't
|
||||
leave it. Each Steam app, though, gets its own SteamVR panel. That also works
|
||||
for any Linux app tagged with an app id of its own:
|
||||
|
||||
```sh
|
||||
./scripts/panel-on-frame.sh konsole
|
||||
./scripts/panel-on-frame.sh mac-screen # the Mac's screen, in its own panel
|
||||
```
|
||||
|
||||
Then use the SteamVR dashboard's **Float in World**, **Move** and **Size**
|
||||
controls to place each panel. See [docs/panels.md](panels.md).
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Runs on | Purpose |
|
||||
|---|---|---|
|
||||
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
|
||||
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
|
||||
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
|
||||
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
|
||||
| `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**, 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**) |
|
||||
| `scripts/compat-db-backup.sh` | Mac | Maintainer-only: back up the shared compatibility database locally and to Google Drive (**verified**) |
|
||||
| `scripts/push-vr-video.sh` | Mac → Frame | Upload VR180/360 videos to `~/Videos/VR`, linked into DeoVR's Proton prefix; `--launch` starts DeoVR (**verified**: upload and link; in-headset playback of local files not yet checked). See [docs/vr-video.md](vr-video.md) |
|
||||
| `scripts/push.sh` | Mac → Frame | `rsync` files to `~/Downloads` (or a given path) on the Frame (**verified**) |
|
||||
| `scripts/serve-bootstrap.sh` | Mac | Fallback: serve `bootstrap-on-frame.sh` with your public key embedded |
|
||||
| `scripts/bootstrap-on-frame.sh` | Frame | Fallback: install the key and enable `sshd` |
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Sideloading Linux and Windows games
|
||||
|
||||
A game you have as files (an itch.io download, your own build, a DRM-free
|
||||
release) can go into the Frame's Steam library without a Steam store page.
|
||||
Frame Control uses the same path as Valve's
|
||||
[SteamOS Devkit Client](https://gitlab.steamos.cloud/devkit/steamos-devkit):
|
||||
the title becomes a Steam **Devkit Game**, with a runtime (Proton or a Steam
|
||||
Linux Runtime) chosen from the program itself.
|
||||
|
||||
For Android APKs, see [apks.md](apks.md) instead.
|
||||
|
||||
**Status: nothing here has run on a headset yet.** Every device-side step is
|
||||
**inferred from Valve's steamos-devkit source** (release v0.20260925.1). The
|
||||
local steps (reading the zip, picking the program and runtime, building the
|
||||
request) are covered by `tests/test_frame_titles.py`.
|
||||
|
||||
## Using it
|
||||
|
||||
Drop a game's `.zip`, folder or `.exe` on **Send to Frame**. (Folders need the
|
||||
desktop app, which knows where a dropped folder lives; in a plain browser, zip
|
||||
it.) A dialog shows:
|
||||
|
||||
- **Name**: what Steam shows. Steam uses the title id as the name, so it's
|
||||
limited to letters, digits and `_`, and can't start with a digit; the
|
||||
dialog shows the result.
|
||||
- **Launches**: the program picked to start the game, with the other
|
||||
candidates in the list.
|
||||
- **Runtime**: picked from the program, see below. Windows programs can switch
|
||||
between Proton Experimental and Proton (stable).
|
||||
|
||||
Install copies it to the Frame and registers it with Steam; progress shows in
|
||||
the bar and the activity log. **Sideloaded titles** lists what's installed,
|
||||
with Launch and Remove. **Copy to ~/Downloads instead** keeps the old
|
||||
behaviour for a zip that isn't a game.
|
||||
|
||||
From a terminal:
|
||||
|
||||
```sh
|
||||
python3 ui/frame_titles.py inspect Game.zip # what would be installed, no headset needed
|
||||
python3 ui/frame_titles.py install Game.zip [--name N] [--exe REL] [--runtime R]
|
||||
python3 ui/frame_titles.py list | launch ID | remove ID
|
||||
```
|
||||
|
||||
## Choosing the runtime
|
||||
|
||||
The program's header decides, not its file name:
|
||||
|
||||
| Program | Runtime (Steam compat tool) | `steam_play` | Confidence |
|
||||
|---|---|---|---|
|
||||
| Windows `.exe`, x86-64 (PE machine `0x8664`) | `proton-experimental` | 1 | Inferred: ARM64 Proton runs x86-64 code through FEX |
|
||||
| Windows `.exe`, 32-bit x86 (`0x14c`) or ARM64 (`0xaa64`) | `proton-experimental` | 1 | Inferred |
|
||||
| Linux ELF, aarch64 (`e_machine` `0xB7`) | `SteamLinuxRuntime_4-arm64` | 0 | Verified: starts, but natively (see below) |
|
||||
| Linux ELF, x86-64 (`0x3E`) | `SteamLinuxRuntime_4` | 0 | Verified not to start: the runtime isn't installed (see below) |
|
||||
| Shell script | the runtime of the Linux binary beside it, else `SteamLinuxRuntime_4-arm64` | 0 | Guess |
|
||||
| Anything else (32-bit Linux, other CPUs, DLLs, data) | refused with a message | | |
|
||||
|
||||
Proton Experimental is the default rather than stable because the Frame's
|
||||
ARM64 Proton and FEX stack is new and Proton fixes reach Experimental first.
|
||||
If a game misbehaves, reinstall it with Proton (stable).
|
||||
|
||||
The aliases and settings are the ones Valve's client sends: `RUNTIME_ALIASES`
|
||||
in `devkit_client/__init__.py`, and `gui2._update_game`, which sets
|
||||
`steam_play=1, steam_play_debug=0, steam_play_debug_version=2019` for Proton
|
||||
and `steam_play=0` otherwise, plus `compat_tool=<alias>`. Valve's client only
|
||||
offers `SteamLinuxRuntime_4-arm64` and Lepton when the device reports itself
|
||||
as Deckard (the Frame).
|
||||
|
||||
## Picking the program
|
||||
|
||||
`ui/frame_titles.py` reads every file's header: ELF executables (PIE ones are
|
||||
told from shared libraries by their `PT_INTERP` segment), PE executables (not
|
||||
DLLs) and scripts with `#!`. A zip with a single top-level folder is treated
|
||||
as that folder. Candidates are ranked by:
|
||||
|
||||
1. Not a helper: names like `UnityCrashHandler64`, `CrashReportClient`,
|
||||
`*setup*`, `unins*`, `vc_redist*`, `dxsetup`, `*prereq*`, and anything under
|
||||
`_CommonRedist`, `Redist`, `DirectX` or `Engine` go last.
|
||||
2. Platform: native ARM64 Linux, then Windows x86-64, then x86-64 Linux, then
|
||||
other Windows builds.
|
||||
3. Name: a program named like the zip or folder (build words such as
|
||||
`-linux-arm64` or `_v1.2` are dropped from the name).
|
||||
4. Depth, then size: Unreal's top-level `Game.exe` beats
|
||||
`Game/Binaries/Win64/Game-Win64-Shipping.exe`.
|
||||
|
||||
A top-level shell script beats a Linux binary one folder down (`run.sh` +
|
||||
`bin/game`); a binary next to a script wins. The list in the dialog lets you
|
||||
pick another.
|
||||
|
||||
## What happens on the Frame (inferred)
|
||||
|
||||
1. **Tools.** `frame/devkit-utils/` (Valve's scripts, vendored unmodified, MIT)
|
||||
is copied to `~/devkit-utils`, where Valve's client puts it, unless the
|
||||
stamp file there already matches. Files are merged, not replaced, so a
|
||||
newer copy from Valve's client keeps its extra files.
|
||||
2. **Folder.** `python3 ~/devkit-utils/steamos-prepare-upload --gameid ID`
|
||||
makes `~/devkit-game/ID` and prints `{user, directory}`.
|
||||
3. **Copy.** The files go there with `rsync -a --delete` on macOS and Linux,
|
||||
or `scp -r` into a fresh folder that then replaces it on Windows. Then
|
||||
`chmod -R 755`, the modes Valve's client gives an upload.
|
||||
4. **Register.** `python3 ~/devkit-utils/steam-client-create-shortcut --parms JSON`
|
||||
with `{gameid, directory, argv: [target], env: {}, settings, clear_settings,
|
||||
force_appid: "", lepton_args: ""}`. It writes `ID-argv.json`,
|
||||
`ID-env.json` and `ID-settings.json` next to the folder, then sends
|
||||
`create-shortcut` to the running Steam client over `~/.steam/steam.pipe`
|
||||
(authenticated by `~/.steam/steam.token`) and waits up to 5 s for Steam's
|
||||
answer file. Its `error`, for example "The Steam client is not running",
|
||||
is shown as the install error. The files stay, so installing again with
|
||||
Steam running finishes the job.
|
||||
5. **Launch** is `steam-devkit-rpc run-game gameid=ID`. **Remove** is
|
||||
`steamos-delete --delete-title ID`, which deletes the folder and has Steam
|
||||
drop shortcuts with no folder. Frame Control then removes the `ID-*.json`
|
||||
files that Valve's script leaves behind.
|
||||
|
||||
Frame Control also writes `~/devkit-game/ID-framecontrol.json` (name, source
|
||||
file, target, runtime, size). **Sideloaded titles** lists every folder in
|
||||
`~/devkit-game`, including titles uploaded with Valve's client.
|
||||
|
||||
`argv` is one string, as in Valve's client (the start command may carry
|
||||
arguments), so a program path with spaces is sent in double quotes. How Steam
|
||||
splits that string is **not checked**.
|
||||
|
||||
## Safety
|
||||
|
||||
- Zips are unpacked on your computer first. Entries with absolute paths, `..`,
|
||||
drive letters or `:` anywhere in the path, or links that point outside the
|
||||
zip (or at a folder they're in) are refused. So are zips over 64 GB
|
||||
unpacked, over 200,000 entries, more than 200× compressed past 1 GB, or
|
||||
bigger than the free space.
|
||||
- No symlink is created while unpacking, so no write can be redirected
|
||||
through one. A link to a file inside the zip (`libfoo.so.1 → libfoo.so.1.2`)
|
||||
becomes a copy of that file, which also works on Windows. Links to folders,
|
||||
loops and dangling links are left out.
|
||||
- A dropped folder that contains symlinks (or Windows junctions) is copied on your computer first,
|
||||
with the same rule, because `scp -r` would follow a link out of the folder
|
||||
and upload whatever it points at.
|
||||
- Installs run one at a time, and Remove is refused while one runs.
|
||||
- The title id is limited to letters, digits and `_`, doesn't start with a
|
||||
digit (one that would gets `_` in front), and is 2 to 64 characters. That's
|
||||
what Steam's `create-shortcut` accepts: on the Frame it refused
|
||||
`fc-smoke-exe` with `missing/invalid arguments` and registered the same
|
||||
program as `FCSmokeProbe` (2026-09-27, BUILD_ID 20260922.6101926), and
|
||||
Valve's client only allows `^[A-Za-z_][A-Za-z0-9_.]+$`. Valve's scripts
|
||||
also pass the id to a shell (`steamos-delete` runs `rm -r` on it). Valve's
|
||||
reserved sideload names (`steam`, `steamvr`, and their `deckard` forms,
|
||||
which would replace the Steam client itself) get `_game` added.
|
||||
- Nothing needs `sudo`; everything goes to your home folder on the Frame.
|
||||
- In the app, a dropped folder is read from its local path by the app's own
|
||||
server, which only accepts requests from its own page (see
|
||||
[frame-control.md](frame-control.md#how-it-works)).
|
||||
|
||||
## Checked on a headset
|
||||
|
||||
Tested 2026-09-26 on a Frame (BUILD_ID 20260922.6101926) with small static test
|
||||
programs and PuTTY's official 64-bit `putty.exe`, through both the command line
|
||||
and the app (inspect, install job, ▶, Remove, and install links):
|
||||
|
||||
- [x] `create-shortcut` registers a title; it shows in the Steam library and in
|
||||
**Sideloaded titles**, and Steam maps it to the chosen compat tool.
|
||||
- [x] `steam-devkit-rpc run-game` starts it (Steam logs `devkit run-game: started
|
||||
devkit game "<id>"`), and Remove (`steamos-delete`) deletes the files, the
|
||||
shortcut and the Proton prefix.
|
||||
- [x] A quoted path in the start command is fine: Steam runs
|
||||
`proton waitforexitandrun "/home/steamos/devkit-game/<id>/<exe>"`.
|
||||
- [x] An x86-64 Windows `.exe` runs under **Proton 11 (stable)** through FEX
|
||||
(ARM64EC) inside the Steam Linux Runtime 4.0 ARM64 container; PuTTY stayed up.
|
||||
Proton Experimental wasn't installed at the time (it was downloading), so it's
|
||||
untested. A Go-built x86-64 test program crashed in `libarm64ecfex.dll`
|
||||
(a FEX limitation with that program, not the sideloading).
|
||||
- [ ] **An aarch64 build runs natively, not in `SteamLinuxRuntime_4-arm64`**:
|
||||
Steam records the mapping (`CompatToolMapping`, `compat_log.txt`) but launches
|
||||
the devkit title without the runtime's `_v2-entry-point` prefix. Fine for a
|
||||
self-contained build; a build that needs the runtime's libraries may not start.
|
||||
- [ ] **An x86-64 Linux build doesn't start**: Steam logs `Tool 4183110 "Steam
|
||||
Linux Runtime 4.0" is found for appID …, but is not installed`, and the Frame
|
||||
doesn't install that x86-64 runtime for a devkit title (a `steam://install/4183110`
|
||||
request did nothing).
|
||||
- [ ] Whether these titles open as flat panels or need anything VR-specific.
|
||||
@@ -0,0 +1,176 @@
|
||||
# SSH into the Steam Frame
|
||||
|
||||
Confidence labels:
|
||||
|
||||
- **Confirmed (Frame)**: Valve's Steam Frame docs or a Frame-specific source.
|
||||
- **Inferred (Deck/SteamOS)**: true on Steam Deck or SteamOS generally, but
|
||||
not checked on a Frame.
|
||||
- **Guess**: reasoned, with no source.
|
||||
|
||||
## How access is turned on
|
||||
|
||||
| Claim | Confidence | Source |
|
||||
|---|---|---|
|
||||
| **Steam Settings → System → Enable Developer Mode** enables SSH, ADB, and RDP | Confirmed (Frame) | [setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup), [debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging) |
|
||||
| A password is set in **Developer → Set User Password**. There is no default password. | Confirmed (Frame) | [setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup) |
|
||||
| The default user is **`steamos`**, not `deck` | Confirmed (Frame) | [debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging): `ssh steamos@frame` |
|
||||
| The default hostname is **`frame`**, and can be changed in **Steam Settings → System → Hostname** | Confirmed (Frame) | [setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup), [adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton) |
|
||||
| The IP address is shown in Quick Settings or **Steam Settings → Internet** | Confirmed (Frame) | [adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton) |
|
||||
| The rootfs is read-only. `sudo steamos-readonly disable` makes it writable. | Confirmed (Frame) | [debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging) |
|
||||
| `sudo pacman` works. Helper aliases `cdd` (Frame scripts dir), `cdl` (Steam logs), and `lepton` exist. | Confirmed (Frame) | [debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging) |
|
||||
| There's a full KDE Plasma Linux desktop inside the headset, reachable from the SteamVR dashboard | Confirmed (Frame, press) | [Road to VR review](https://roadtovr.com/valve-steam-frame-review/), [UploadVR](https://www.uploadvr.com/flatpaks-open-source-steam-frame/) |
|
||||
| On Deck, the manual route is Desktop Mode → Konsole → `passwd` → `sudo systemctl enable --now sshd` | Inferred (Deck) | [pimylifeup](https://pimylifeup.com/steam-deck-ssh/), [gist](https://gist.github.com/chphr/9c0791de6d2c659af3bf5890d9080973) |
|
||||
|
||||
On Deck, SSH needs the manual terminal steps. On the Frame, the Developer Mode
|
||||
UI handles both the password and the SSH service. That's why the headset-side
|
||||
checklist in the README involves no terminal at all.
|
||||
|
||||
## Name resolution from a Mac
|
||||
|
||||
Valve's examples use a bare `frame`. That works on Windows through
|
||||
LLMNR/NetBIOS. **On macOS, a bare single-label name usually doesn't resolve**
|
||||
unless your router's DNS registers DHCP client names.
|
||||
|
||||
- **Verified on device (2026-09-25):** `avahi-daemon` is running on the Frame
|
||||
and `frame.local` resolves from the Mac over mDNS.
|
||||
- `scripts/connect.sh` tries `frame.local`, then `frame`, then an mDNS browse for
|
||||
the devkit service (below). If none works, it tells you to re-run it with the IP.
|
||||
Once you have a working address, the `Host frame` alias means you just type
|
||||
`ssh frame`.
|
||||
- To check discovery yourself: `dns-sd -G v4 frame.local` (Ctrl-C to stop), or
|
||||
`dscacheutil -q host -a name frame.local`.
|
||||
- A DHCP reservation for the headset on your router makes the IP stable. That's
|
||||
the most reliable fallback.
|
||||
|
||||
## Key-based login (done by `scripts/connect.sh`)
|
||||
|
||||
```sh
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_frame -N '' -C "mac->steam-frame"
|
||||
ssh-copy-id -i ~/.ssh/id_ed25519_frame.pub steamos@frame.local
|
||||
```
|
||||
|
||||
`~/.ssh/config` block (managed between marker lines by the script):
|
||||
|
||||
```
|
||||
Host frame
|
||||
HostName frame.local
|
||||
User steamos
|
||||
IdentityFile ~/.ssh/id_ed25519_frame
|
||||
IdentityFile ~/.ssh/id_rsa_frame_devkit
|
||||
IdentitiesOnly yes
|
||||
ServerAliveInterval 30
|
||||
```
|
||||
|
||||
The script only asks for the password if the pairing below doesn't work.
|
||||
|
||||
## Password-free pairing (SteamOS devkit service)
|
||||
|
||||
From Valve's source ([steamos-devkit-service](https://gitlab.steamos.cloud/devkit/steamos-devkit-service),
|
||||
[steamos-devkit](https://gitlab.steamos.cloud/devkit/steamos-devkit) client). **Verified on a
|
||||
Frame 2026-09-26** (BUILD_ID 20260922.6101926): the service runs with Developer Mode
|
||||
on, `properties.json` answers with `"login": "steamos"`, the headset advertises
|
||||
`_steamos-devkit._tcp` as `frame`, and `/register` needs pairing mode (below). The
|
||||
approve prompt and key install are not verified yet. SteamOS's devkit service is
|
||||
what Valve's Devkit Client uses to pair. `scripts/connect.sh` and
|
||||
`ui/frame_connect.py` try it first:
|
||||
|
||||
- The headset serves HTTP on port **32000** and advertises mDNS
|
||||
`_steamos-devkit._tcp`. `GET /properties.json` gives the `login` user; the
|
||||
script uses it as `User` (unless you set `FRAME_USER`, or it says `root`),
|
||||
for the password fallback too, and keeps it on re-runs.
|
||||
- **Open Steam Settings → Developer → Pair new host in the headset first.**
|
||||
Otherwise `/register` answers at once with `403` `"please put the Steam client
|
||||
in pairing mode: Settings -> Developer -> Pair new host"` (verified). The
|
||||
scripts say so and keep asking for 2 minutes while you open it.
|
||||
- `POST /register` with `ssh-rsa <key> <comment> 900b919520e4cf601998a71eec318fec`
|
||||
(a fixed token from Valve's client) shows an approve prompt inside the
|
||||
headset naming the comment (`frame-control@<your computer>`). It waits 30 s,
|
||||
then installs the key for the device user and turns `sshd` on. The reply is
|
||||
`200 Registered`, or `403` with `{"error": ...}` (declined, timed out, Steam
|
||||
not running).
|
||||
- It only accepts **RSA** keys, hence the second key,
|
||||
`~/.ssh/id_rsa_frame_devkit` (3072-bit).
|
||||
- A host counts as found if port 22 **or** 32000 answers. With no host given,
|
||||
and `frame.local`/`frame` unreachable, it browses `_steamos-devkit._tcp` with
|
||||
`dns-sd` (macOS) or `avahi-browse` (Linux) for a few seconds if installed.
|
||||
- Port 32000 closed, a timeout, or an error: the script says why and falls back
|
||||
to copying the ed25519 key with the Developer Mode password, as before.
|
||||
|
||||
Anyone on your network can send the request, so only approve a prompt you
|
||||
started. `curl http://<frame-ip>:32000/properties.json` shows whether the service is up.
|
||||
|
||||
`~/.ssh/authorized_keys` lives under `/home`, which SteamOS keeps across OS
|
||||
updates (inferred from Deck; the Frame uses the same A/B image scheme).
|
||||
|
||||
## From an iPhone or iPad
|
||||
|
||||
The iPhone app ([iphone.md](iphone.md)) makes its own ed25519 key and adds it
|
||||
with the Developer Mode password, once, over a password login; the Frame's sshd
|
||||
offers `publickey,password` (OpenSSH 9.7p1, keyboard-interactive off). It can't
|
||||
use the devkit pairing above: that installs an RSA key, and the Swift SSH
|
||||
library signs RSA only with SHA-1, which OpenSSH 8.8 and later refuse by default.
|
||||
The app pins the Frame's host key on first use and asks you to pair again if it
|
||||
changes. **Verified 2026-09-27** against the Frame's recovery image
|
||||
([recovery-and-images.md](recovery-and-images.md)); on the headset, the add-the-key-yourself
|
||||
route was used.
|
||||
|
||||
## Keeping `sshd` enabled across updates
|
||||
|
||||
- **Frame**: SSH is tied to the Developer Mode toggle, so it should survive
|
||||
updates as long as Developer Mode stays on. (Inferred: Valve doesn't say how
|
||||
the toggle is implemented.)
|
||||
- **Deck (for comparison)**: `systemctl enable sshd` usually persists because
|
||||
`/etc` is an overlay that survives updates. Changes under `/usr` do not.
|
||||
- Don't `pacman -S` anything you depend on for access. Packages installed into
|
||||
the read-only rootfs are **wiped by OS updates** on SteamOS. Use Flatpaks
|
||||
(`--user`) or `~/` for anything that needs to persist.
|
||||
|
||||
## Hardening (optional: `./scripts/connect.sh --harden`)
|
||||
|
||||
The script writes `/etc/ssh/sshd_config.d/01-frame-keys-only.conf` with
|
||||
`PasswordAuthentication no` and `KbdInteractiveAuthentication no`, then reloads
|
||||
`sshd`. First, it checks that key login works in BatchMode, so you can't lock
|
||||
yourself out.
|
||||
|
||||
- Needs `sudo` (Developer Mode password), entered on the **Mac**.
|
||||
- It assumes `/etc/ssh/sshd_config` includes `sshd_config.d/*.conf`, which is
|
||||
the Arch default. The script checks for this and stops if the include is
|
||||
missing.
|
||||
- `/etc` drop-ins normally persist across SteamOS updates (inferred from Deck).
|
||||
- It doesn't affect RDP (xrdp) or `sudo`, which still use the password.
|
||||
- Undo: `ssh frame 'sudo rm /etc/ssh/sshd_config.d/01-frame-keys-only.conf && sudo systemctl reload sshd'`.
|
||||
|
||||
## Other shells
|
||||
|
||||
- **ADB over USB-C** to the native Linux OS:
|
||||
`adb shell`. Plug the headset into the Mac. Valve notes that USB power may be
|
||||
insufficient. Install with `brew install android-platform-tools`. This is
|
||||
useful if Wi-Fi SSH is broken.
|
||||
(Confirmed (Frame): [debugging](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging))
|
||||
- **ADB over Wi-Fi** reaches the **Lepton (Android) container**, not Linux:
|
||||
`adb connect frame:5555`. It only works while "Lepton Development" or an
|
||||
Android app is running.
|
||||
(Confirmed (Frame): [adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton))
|
||||
- **RDP**: xrdp with user `steamos` and the Developer Mode password (see
|
||||
[streaming.md](streaming.md)).
|
||||
|
||||
## Fallback bootstrap one-liner
|
||||
|
||||
Use this only if the Developer Mode toggle doesn't give you SSH (for example,
|
||||
an OS build without it).
|
||||
|
||||
1. On the Mac: `./scripts/serve-bootstrap.sh`. It serves
|
||||
`bootstrap-on-frame.sh`, with your `~/.ssh/id_ed25519_frame.pub` embedded,
|
||||
on port 8765, and prints the exact one-liner.
|
||||
2. On the Frame's Linux desktop, open **Konsole** and type the printed line,
|
||||
roughly `curl -fsS mac.local:8765|bash` (~30 characters). If `mac.local`
|
||||
doesn't resolve, the script prints an IP form instead.
|
||||
3. The bootstrap installs the key into `~steamos/.ssh/authorized_keys`, and
|
||||
then runs `sudo systemctl enable --now sshd`. `sudo` asks for a password,
|
||||
and if none is set yet, it tells you to run `passwd` first. That means
|
||||
typing the password on the headset one more time.
|
||||
4. Stop the server on the Mac with Ctrl-C.
|
||||
|
||||
This is plain HTTP on your LAN, and it only serves a public key, so the
|
||||
content isn't secret. Anyone on the LAN who can spoof your Mac's address could
|
||||
serve a different script, though, so use it only on a trusted network.
|
||||