Compare commits

..
667 changed files with 0 additions and 164803 deletions

No files matched your search

-57
View File
@@ -1,57 +0,0 @@
---
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` |
| Browse and install Android apps from inside the headset (APK Store panel) | `docs/apks.md` | `scripts/store-on-frame.sh` |
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
| 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).
-13
View File
@@ -1,13 +0,0 @@
# 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
# Test fixtures are byte-exact (hashes, signatures): never convert line endings.
tests/fixtures/** -text
*.apk binary
*.jar binary
*.obb binary
-9
View File
@@ -1,9 +0,0 @@
# GitHub handles approved to bypass contribution auto-close
# Format: <username> <capability>
# capability:
# issue future issues stay open
# pr future issues and PRs stay open
# Maintainers add people by replying `lgtmi` or `lgtm` on an issue
# (.github/workflows/approve-contributor.yml); editing this file by hand works too.
fbl100 pr
-1
View File
@@ -1 +0,0 @@
ko_fi: alexsouthwell
-51
View File
@@ -1,51 +0,0 @@
name: Bug report
description: Report something that's broken
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
**Before you start:** read [CONTRIBUTING.md](https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md).
Issues from new contributors are auto-closed by default. A maintainer reviews them and reopens worthwhile ones. The [website feedback form](https://frame-control.pages.dev/feedback/) skips that queue.
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
- type: textarea
id: description
attributes:
label: What happened?
description: Be specific. Include error messages and the last lines of the server log (Frame → Show Server Log).
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Minimal steps to trigger the bug.
validations:
required: false
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: false
- type: input
id: version
attributes:
label: Frame Control version
description: e.g. v0.3.1
validations:
required: false
- type: input
id: os
attributes:
label: Computer and SteamOS build
description: e.g. Windows 11, SteamOS 20260922.6101926 (Steam Settings → System)
validations:
required: false
-5
View File
@@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Feedback form (no GitHub account needed, skips the queue)
url: https://frame-control.pages.dev/feedback/
about: Bugs, ideas and questions from the website become issues here without being auto-closed.
-36
View File
@@ -1,36 +0,0 @@
name: Idea or contribution proposal
description: Propose a change or feature (required for new contributors before opening a PR)
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
**Before you start:** read [CONTRIBUTING.md](https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md).
Issues from new contributors are auto-closed by default. A maintainer reviews them and reopens worthwhile ones.
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
- type: textarea
id: what
attributes:
label: What do you want to change?
description: Be specific and concise.
validations:
required: true
- type: textarea
id: why
attributes:
label: Why?
description: What problem does this solve?
validations:
required: true
- type: textarea
id: how
attributes:
label: How? (optional)
description: Brief technical approach, and whether you'd like to implement it yourself.
validations:
required: false
-238
View File
@@ -1,238 +0,0 @@
# 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,
});
-98
View File
@@ -1,98 +0,0 @@
name: checks
on:
push:
branches: [main]
pull_request:
# A newer push to the same pull request supersedes a run still in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == '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
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
run: (sudo apt-get update -qq || true) && 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: App tests
run: node --test app/test/*.test.js
- name: Website
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
# 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
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
- name: End-to-end tests
run: scripts/e2e.sh
-134
View File
@@ -1,134 +0,0 @@
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
# See CONTRIBUTING.md for how it works.
name: Issue Gate
on:
issues:
types: [opened]
jobs:
check-contributor:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Check issue author
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']);
const issueAuthor = context.payload.issue.user.login;
const defaultBranch = context.payload.repository.default_branch;
const isBotAuthor = issueAuthor.endsWith('[bot]');
if (TRUSTED_BOT_AUTHORS.has(issueAuthor)) {
console.log(`Skipping trusted bot: ${issueAuthor}`);
return;
}
async function getPermission(username) {
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
});
return permissionLevel.permission;
} catch {
return null;
}
}
async function getTextFile(path) {
const { data: fileContent } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path,
ref: defaultBranch,
});
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
throw new Error(`Expected file content for ${path}`);
}
return Buffer.from(fileContent.content, 'base64').toString('utf8');
}
function parseApprovedUsers(content) {
const users = new Map();
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
if (parts.length !== 2) {
console.log(`Skipping malformed line: ${rawLine}`);
continue;
}
const [username, capability] = parts;
const normalizedCapability = capability.toLowerCase();
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
console.log(`Skipping line with invalid capability: ${rawLine}`);
continue;
}
users.set(username.toLowerCase(), normalizedCapability);
}
return users;
}
const permission = await getPermission(issueAuthor);
if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) {
console.log(`${issueAuthor} is a collaborator with ${permission} access`);
return;
}
const approvedContent = await getTextFile(APPROVED_FILE);
const approvedUsers = parseApprovedUsers(approvedContent);
const capability = approvedUsers.get(issueAuthor.toLowerCase());
if (!isBotAuthor && (capability === 'issue' || capability === 'pr')) {
console.log(`${issueAuthor} is approved for ${capability}`);
return;
}
const message = [
'This issue was auto-closed. All issues from new contributors are auto-closed by default.',
'',
`Maintainers review auto-closed issues regularly and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
'',
'Just want to report a bug or share an idea? The [website feedback form](https://frame-control.pages.dev/feedback/) skips this queue.',
'',
'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open. The command must be at the start of the reply (optionally after one or more `@username` mentions) or at the end.',
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message,
});
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['untriaged'],
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
});
-145
View File
@@ -1,145 +0,0 @@
# 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}`);
}
-53
View File
@@ -1,53 +0,0 @@
name: PC host libraries
# PC in the headset (Experimental). The Windows job builds the native adapter
# and runs the real-encoder protocol tests; the installer's own bundle is
# tested in release.yml. The Linux adapter from PR #38 isn't shipped or offered
# in the app, but shares capture.c and controller.c, so it's still built here.
# These builds add the GPL x264 encoder for headless tests only: they're never
# uploaded or shipped.
on:
pull_request:
paths: ["desktop/**", "ui/**", "tests/**", "scripts/macview-bench.py", ".github/workflows/pc-host.yml"]
workflow_dispatch:
jobs:
linux:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Native libraries
run: |
sudo apt-get update -qq
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
python3 desktop/build.py --with-x264
- name: Tests (real x264, fake capture consent/input)
env:
FRAME_PC_REQUIRE_NATIVE: "1"
FRAME_PC_REQUIRE_X264: "1"
run: python3 -m unittest discover -s tests -p 'test_pc*.py' -v
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
# flaky mirror can't break a release build.
- name: MSYS2 packages for the PC host
shell: msys2 {0}
run: |
for attempt in 1 2 3; do
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
sleep $((attempt * 30))
done
exit 1
- name: Native build and tests (x264; WGC and MF need a desktop/GPU)
shell: msys2 {0}
run: |
python desktop/build.py --with-x264
FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_REQUIRE_X264=1 python -m unittest discover -s tests -p 'test_pc*.py' -v
-131
View File
@@ -1,131 +0,0 @@
# 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);
-175
View File
@@ -1,175 +0,0 @@
name: release
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
# the installers to that tag's GitHub release (created as a draft if missing).
# Pull requests that change how the installers are built (the Electron app, the
# Windows PC helper libraries, the macOS helper, or this workflow) build the same installers as
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
# are tested by the checks workflow; run this one by hand to package them early.
on:
push:
tags: ["v*"]
pull_request:
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
workflow_dispatch:
# A newer push to the same pull request supersedes a build still in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: write
jobs:
# One job makes the draft, before the builds: three matrix jobs each running
# "view || create" could race and make duplicate drafts. The draft is tied to
# the pushed tag (the tag must already exist on GitHub, then tag_name is set
# explicitly), so scripts/publish-release.sh and `gh release upload` find it
# by tag. Editing a draft can make GitHub show an untagged-* name again; the
# upload step below recovers that exact draft if tag lookup fails. It is
# created with the REST API so the id comes straight back: the
# release list can lag a fresh draft (v0.4.2's first run found nothing and
# PATCHed releases/ with an empty id). tests/test_release_workflow.py runs
# this step against tests/fakegh.
draft:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Create the draft release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
tag="${GITHUB_REF_NAME}"
# A rerun finds the draft even if GitHub temporarily shows untagged-*.
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"$tag\" or (.draft and (.tag_name | startswith(\"untagged-\")) and .name == \"Frame Control ${tag#v}\")) | .id")
if [ -n "$id" ] && ! [[ "$id" =~ ^[0-9]+$ ]]; then
echo "expected one release for $tag; found: $id" >&2
exit 1
fi
if [ -z "$id" ]; then
# Given a missing tag, GitHub would create it on the default branch.
gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \
|| { echo "tag $tag isn't on GitHub" >&2; exit 1; }
id=$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="Frame Control ${tag#v}" \
-F draft=true -f body="" --jq .id)
fi
[ -n "$id" ] || { echo "no release id for $tag; not PATCHing releases/" >&2; exit 1; }
gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name'
build:
needs: draft
# Still runs for pull requests and manual runs, where the draft job is skipped.
if: ${{ !failure() && !cancelled() }}
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"
# PC in the headset (Experimental, Windows only): our capture adapter and
# the GStreamer libraries it uses, bundled into resources/desktop/bundle.
- uses: msys2/setup-msys2@v2
if: runner.os == 'Windows'
with:
msystem: UCRT64
update: true
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
# flaky mirror can't break a release build.
- name: MSYS2 packages for the PC host
if: runner.os == 'Windows'
shell: msys2 {0}
run: |
for attempt in 1 2 3; do
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
sleep $((attempt * 30))
done
exit 1
- name: Windows PC streaming libraries
if: runner.os == 'Windows'
shell: msys2 {0}
run: python desktop/build.py --out app/build/desktop/win-x64
- name: Build
working-directory: app
shell: bash
run: npm ci && npm run ${{ matrix.script }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
# The installer, not just the unpacked build: its 7-Zip payload once dropped
# the OpenXR layer's arm64 library without any error. Install it silently
# and compare every installed file with the unpacked build.
- name: Check the Windows installer installs every file
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
$setup = Get-ChildItem app/dist/Frame-Control-Setup-*.exe | Select-Object -First 1
$p = Start-Process -FilePath $setup.FullName -ArgumentList '/S' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "installer exited $($p.ExitCode)" }
$installed = Join-Path $env:LOCALAPPDATA 'Programs\Frame Control'
for ($i = 0; $i -lt 60 -and -not (Test-Path (Join-Path $installed 'Uninstall Frame Control.exe')); $i++) { Start-Sleep 2 }
node app/build/check-resources.js (Join-Path $installed 'resources') app/dist/win-unpacked/resources
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# From the installed app, so this also proves the installer carries the
# libraries. After the check above, and without writing __pycache__,
# so it can't change what that check compares.
- name: Test the installed app's Python with the Windows PC libraries
if: runner.os == 'Windows'
shell: bash
env:
PYTHONDONTWRITEBYTECODE: "1"
run: |
resources="$(cygpath -u "$LOCALAPPDATA")/Programs/Frame Control/resources"
python="$resources/python/python.exe"
native="$(cygpath -w "$resources/desktop/bundle")"
FRAME_PC_NATIVE="$native" FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_INSTALLER_BUNDLE=1 "$python" -B -m unittest discover -s tests -p 'test_pc*.py' -v
- name: Upload to the release
if: startsWith(github.ref, 'refs/tags/')
shell: bash
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
# A release-notes edit can make GitHub expose a draft as untagged-*.
# Keep the normal upload path; recover only that lookup failure.
if result=$(gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber 2>&1); then
printf '%s\n' "$result"
exit 0
fi
printf '%s\n' "$result" >&2
case "$result" in *"release not found"*) ;; *) exit 1 ;; esac
tag="${GITHUB_REF_NAME}"
ids=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
--jq ".[] | select(.draft and .name == \"Frame Control ${tag#v}\" and (.tag_name == \"$tag\" or (.tag_name | startswith(\"untagged-\")))) | .id")
if ! [[ "$ids" =~ ^[0-9]+$ ]]; then
echo "expected one matching draft for $tag; found: ${ids:-none}" >&2
exit 1
fi
gh api -X PATCH "repos/$GH_REPO/releases/$ids" -f tag_name="$tag" >/dev/null
gh release upload "$tag" ${{ matrix.files }} --clobber
# Tagged builds are attached to the release above; only other builds need artifacts.
- uses: actions/upload-artifact@v4
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
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
@@ -1,34 +0,0 @@
# 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,
});
-21
View File
@@ -1,21 +0,0 @@
.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/
# Shared desktop streaming controller build products
desktop/controller.dll
desktop/controller.dylib
desktop/controller.so
desktop/*.obj
desktop/*.lib
desktop/*.exp
desktop/bundle/
app/build/desktop/
# Downloaded at build time (frame/kdeconnect/fetch.py, app/build/fetch-deps.js)
frame/kdeconnect/packages/
-119
View File
@@ -1,119 +0,0 @@
# Changelog
## Unreleased
- Keep media playback running when SteamVR refuses the optional theatre dark
surround, including a retry after standby. Log the refusal and play without
the surround. Overlay teardown attempts every owned handle and always calls
OpenVR shutdown even if a handle fails to close.
- Check the desktop server's single-instance lock before starting background
downloads, so a second server exits cleanly with the existing "already
running" message.
- Recheck saved spatial layouts on SteamOS 0.5.6 / SteamVR 2.18.2 (issue #31).
Read-only probes expose some panel geometry, but safe restoration of
Steam/gamescope docking and positions remains unverified. Record exact API
results and a reproducible probe; saved layouts remain unavailable.
- Record packaged v0.4.17 Desktop Capture succeeding on a mapped nested KDE
compositor on a real Frame with SteamOS 0.5.6. Headset overlay visibility was
not established; this is evidence, not a new fix or resolution of issue #73.
- Correct the Return to Game Mode help text: Frame Control selects the switch
supported by the headset's SteamOS build, including `steamosctl` on current
Frames. The button's behavior is unchanged.
- Distinguish an unreadable gamescope panel list from a confirmed empty list in
Desktop Capture and Control (issue #73). Failed discovery now asks users to
check SteamVR and report the exact message and SteamOS version, rather than
claiming no panel is open. This does not establish a new capture fix.
- Add an opt-in desktop low-battery alert without starting a family session.
Existing status reads confirm low charge and drain; recovery hysteresis and a
ten-minute cooldown limit repeat alerts. Off on page open/headset switch;
pauses with hidden-page status refreshes. No extra polling or headset changes.
- Clarify first-time desktop downloads, installation and opening on the website
and README, including CPU choices, unsigned-app warnings, pairing fallback
and the source-only iOS build. Keep section navigation available on mobile.
- Treat headset aliases and other CSV metadata as plain text in power and heat
exports, so spreadsheet formula prefixes cannot run when a report is opened.
- Add desktop Tools → Power & heat report: bounded read-only one/five-minute
observations with progress, stop and HTML/JSON/CSV downloads. Received samples
survive stop, disconnect and headset switch, labeled incomplete and tied to the
original headset. Shared sampler/report modules ship with the app; no power or
thermal controls are added. Download before replacing a report or quitting.
- Fix Return to Game Mode misidentifying full Plasma Desktop Mode when its VR
overlay also runs gamescope (issue #142). Session targets now determine the
mode; conflicting or incomplete evidence stays unknown. Use SteamOS's
`steamosctl` switch when available, with older selector commands retained.
- Export read-only power and heat observations to an offline accessible HTML
report with JSON/CSV downloads. `--html FILE` limits sessions to ten minutes;
Ctrl+C saves received samples with an explicit incomplete state. Units,
unknown counts and sources remain visible; no headset controls are added.
- Add a guarded `frame-doctor.sh --plan` with read-only, evidence-specific next
steps and explicit local baseline reports for before/after diagnostics (issue
#34). Restart risk blocks plans/comparisons; no automatic repairs, password
changes or headset security changes are performed.
- Recover a tagged release upload when GitHub temporarily displays its draft
as `untagged-…` after an edit, without creating a duplicate draft or
silently retrying unrelated upload failures.
- Import standalone APK/OBB backups from XAPK/APKS/APKM archives through the
existing preview and game-data setup flow. Unsafe archives, split sets and
Android/data restores are refused with guidance. This does not establish RE4
compatibility or diagnose its reported failure (issue #102).
- Add `scripts/frame-power-sample.sh`, a bounded read-only SSH sampler for
battery, CPU, named temperatures, fan and available power rails (issue #52).
JSON samples/summaries and CSV exports retain explicit unavailable states;
active smoke-test locks stop measurement. No power settings are changed.
- Show saved Android compatibility reports on store search cards and app details.
Reports refresh in the background, so a stalled database or credential lookup
cannot delay search. Warnings allow installation; build compatibility blockers
still apply. Public copies use this computer's reports, not private shared ratings.
- Fix two cases where optional diagnostic logs could retain part of a headset
display name or an SSH host name, while preserving existing privacy redaction
(issue #81).
- Keep the full host-key recovery explanation readable in Devices, including
long addresses, and remove doubled punctuation from the connection tooltip
(issue #87). Refresh stays within the header and switching headsets selects
the new headset's details.
- Fix experimental PC screen capture on Windows builds whose streaming libraries
lack Windows Graphics Capture. Whole screens use Desktop Duplication; individual
windows are hidden with an explanation instead of failing when shown. A Windows
VM streamed a real desktop to the Frame; physical input remains unverified.
- Add read-only battery health in Tools: charging state, temperature, cycle count,
and available capacity with explicit unknown states. The parser was checked
on a real Frame; the connected desktop UI was tested with synthetic readings.
- Add Tools → Game storage: read-only headset filesystem capacity and Steam
library/game install-size breakdown, with unknown sizes, partial inventories
and permission/missing-drive states. Includes instructions for Steam's own
Move UI; Frame Control does not move or delete game files. The read-only
parser was checked on a real Frame; the connected UI and game moves were not.
- Add `scripts/frame-doctor.sh` for read-only setup and pairing diagnostics,
with verified SSH identity, private summaries and actionable next steps
(issue #34). Connection steps links to the diagnostic guide. Repairs remain
manual; restart-loop evidence stops deeper diagnostics. The real Frame
verified the SSH connection and safety stop; deeper checks were skipped.
- Fixed headset smoke cleanup reporting success while a smoke-title process
could remain running after its files were removed. Cleanup now stops and
rechecks processes tied to exact reserved test-title paths, uses PID handles
for safe TERM/KILL escalation, and records stopped PIDs. Unrelated titles and
shared Wine services are left alone. Verified with local Linux process
regressions and a 13/13 real Frame smoke run that stopped five test-owned
Proton process PIDs before removing the Windows test title.
-71
View File
@@ -1,71 +0,0 @@
# Contributing to Frame Control
This guide exists to save both sides time. The process is borrowed from
[pi](https://github.com/badlogic/pi-mono/blob/main/CONTRIBUTING.md).
## Just want to report something?
Use the [feedback form](https://frame-control.pages.dev/feedback/). It needs no
GitHub account, and what you send becomes an issue here that stays open.
## The One Rule
**You must understand your code.** If you can't explain what your change does
and how it interacts with the rest of the app, your PR will be closed.
Using AI to write code is fine. Submitting AI-generated slop you don't
understand is not.
## Contribution gate
Issues and PRs opened on GitHub by new contributors are auto-closed by default.
A maintainer reviews auto-closed issues regularly and reopens worthwhile ones.
Issues that don't meet the quality bar below won't be reopened or get a reply.
Approval happens through maintainer replies on issues:
- `lgtmi`: your future issues won't be auto-closed
- `lgtm`: your future issues and PRs won't be auto-closed
The word must be at the start of the reply (optionally after one or more
`@username` mentions) or at the end. Only `lgtm` lets you open PRs. Approved
people are listed in [`.github/APPROVED_CONTRIBUTORS`](.github/APPROVED_CONTRIBUTORS).
## Quality bar for issues
Use one of the issue templates, and keep it short, concrete and worth reading.
- If it doesn't fit on one screen, it's too long.
- Write in your own voice. If you must use an LLM, say so in a clearly labelled
follow-up comment.
- State the bug or request clearly, and why it matters.
- For bugs, include your OS, your SteamOS build (Steam Settings → System), and
the server log (**Frame → Show Server Log** in the app).
- If you want to implement the change yourself, say so.
## Before opening a PR
Don't open a PR until a maintainer has approved you with `lgtm`. Open an
[idea or contribution proposal](https://github.com/saphid/frame-control/issues/new?template=idea.yml)
first.
Then check your change:
```sh
python3 -m unittest discover -s tests # server tests; no headset needed
node --test site/test/*.test.mjs # website feedback function
```
Say what you tested, and whether you tried it on a real Steam Frame.
## Blocking
If you ignore this document twice, or spam the tracker with agent-generated
issues, your GitHub account will be blocked from the repo.
## Why auto-close?
This is a hobby project with one maintainer. Auto-closing is a buffer against
burnout and tracker spam: issues get reviewed on the maintainer's schedule, and
the good ones are reopened. Short, concrete, reproducible reports and thoughtful
contributions are welcome.
-21
View File
@@ -1,21 +0,0 @@
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.
-314
View File
@@ -1,314 +0,0 @@
<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.
[![Latest release](https://img.shields.io/github/v/release/saphid/frame-control?label=release&color=1a9fff)](https://github.com/saphid/frame-control/releases/latest)
[![Platforms](https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-2a475e?label=runs%20on)](#install)
[![Checks](https://img.shields.io/github/actions/workflow/status/saphid/frame-control/checks.yml?branch=main&label=checks)](https://github.com/saphid/frame-control/actions/workflows/checks.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-66c0f4)](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/frame-control/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 and Desktop**<br>
Live video of what the lenses show, or of the app panel in use, flat and still however the wearer looks around. Turn on Control and tap or click right on it to use the Frame from your phone or computer.
</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.
For repeatable read-only battery, power-rail, temperature and fan measurements,
use the [power and heat sampler](docs/power-and-heat.md).
</td>
</tr>
<tr>
<td valign="top">
**🎮 Steam games**<br>
Everything you own with its Steam Frame rating. Install onto the headset with live progress, search the store, and force quit a game that stops responding.
</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 and games**<br>
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you.
</td>
<td valign="top">
**📸 Screenshots and recordings**<br>
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record what the headset shows, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder ([not tested on a headset yet](docs/recording.md)).
**⌨️ Type on the headset**<br>
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Copy text to the headset's clipboard and back again. Nothing else to install, anywhere. Press T to open it.
</td>
</tr>
<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>
<tr>
<td colspan="2" valign="top">
**💾 Backup and recovery**<br>
Copy game saves, settings and Android app data to your computer, and restore them after a factory reset. Sign-in files and keys are never copied. A Return to Game Mode button helps when the headset is stuck in Desktop Mode. Not tested on a headset yet. [How it works](docs/backup-and-recovery.md).
</td>
</tr>
</table>
The optional [Family and comfort](docs/family-comfort.md) card adds session
limits, breaks, local alerts and one-click casting. A session copies a small
Frame Control worker into your headset user account.
For the other features, nothing is installed on the Frame: 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). The optional
[performance HUD](docs/vr-utilities.md) copies our own Python helpers into
`~/.local/share/frame-control/vr/`. [How each feature works](docs/frame-control.md).
## Install
**Install Frame Control on your computer, not on the headset.** The website is
for downloads and instructions; it does not connect to your Frame in a browser.
Choose the operating system and processor of the computer you will use.
| | Download | Needs |
|---|---|---|
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-win-x64.zip) | OpenSSH Client (normally installed) |
| **Linux** (x64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
| **Linux** (arm64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
**Which build?** On a Mac, check Apple menu → About This Mac: the download is
for Apple Silicon (M-series); no Intel Mac build is published. On Windows,
check Settings → System → About → System type: the release is x64, not a native
Windows ARM build. On Linux, `uname -m` reports `x86_64` (x64) or `aarch64`
(arm64). Use `.deb` on Debian/Ubuntu, or AppImage on other distributions.
**iPhone and iPad:** no App Store, TestFlight or `.ipa` download is provided in
the current release. It runs from your phone without a computer after setup.
Build it from [`ios/`](ios) in Xcode on a Mac with your own signing team; see
[docs/iphone.md](docs/iphone.md).
The app brings its own Python and `adb`. macOS includes SSH; Windows needs the
OpenSSH Client optional feature (normally installed).
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, only for a download you trust from the releases linked above:
```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>
## Open the app
- **macOS:** open the `.dmg`, drag **Frame Control** to **Applications**, then
open it from Applications. For the unsigned-app warning, use the macOS steps
above. Allow local network access and control of Terminal when prompted.
- **Windows:** run the installer, then open **Frame Control** from Start. For
the portable download, extract the entire `.zip` first and run
`Frame Control.exe` inside it. See the SmartScreen note above.
- **Linux:** install the `.deb` with your package installer and launch
**Frame Control** from your applications menu; or make the AppImage executable
and run it as described above. Install the OpenSSH client if `ssh` is missing.
## Set up the headset
Keep the Frame awake and connect it and your computer to the same trusted local
network. Guest Wi-Fi/client isolation can prevent discovery and connections.
1. **On the Frame:** Steam Settings → System → **Enable Developer Mode**, then
Developer → **Set User Password**. Choose a password that isn't easy to guess:
it also protects `sudo` and remote desktop, even after SSH keys are set up.
2. **On your computer:** open Frame Control and choose **Set Up Connection…**.
If you dismissed the first-run prompt, use **Frame → Set Up Connection…**.
Setup opens a terminal and tries to find the headset. If it can't, use its IP
address from Quick Settings or Steam Settings → Internet. Windows/Linux setup
prompts for it; macOS exits instead. On macOS, return to the app →
**Devices & connection → + Add a headset…**, set SSH alias to `frame` for
the default headset, fill in **Address**, then choose **Set Up…**.
3. **Pair or enter the password:** setup first tries Valve's devkit pairing.
Open Steam Settings → Developer → **Pair new host** in the headset and approve
only the request you just started. The service and pairing-mode step are
verified on a Frame; approval and key installation through that flow remain
unverified. If pairing fails or times out, setup falls back to asking for the
Developer Mode password in the computer's terminal. Password typing may show
no characters; press Enter when done. See [SSH details](docs/ssh.md#password-free-pairing-steamos-devkit-service).
4. After setup succeeds, return to Frame Control. It uses SSH keys for later
connections whenever the Frame is awake and reachable. If it stays offline,
check Developer Mode, the address and network isolation, then run setup again.
For connections away from home, 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
speech-bubble 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 |
| [Import from my Quest](docs/quest-import.md) | Copy games you own off your Quest by USB; what runs, and why licence-checked games may not |
| [My Meta library](docs/meta-library.md) | Experimental: install and test Quest games your Meta account owns, with your own token |
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
| [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 |
| [Power and heat measurements](docs/power-and-heat.md) | Read-only sampling and offline HTML reports with JSON/CSV downloads |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
| [Open questions](docs/open-questions.md) | What's still unchecked |
<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, that 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.
See [Game storage](docs/game-storage.md) for read-only headset space and Steam install sizes, and [the changelog](CHANGELOG.md) for unreleased changes.
-17
View File
@@ -1,17 +0,0 @@
# 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 |
| GStreamer 1.x, a few of its plugins and the libraries they need, as [MSYS2](https://packages.msys2.org/) builds them | Windows app only, for the experimental [PC in the headset](docs/pc-in-headset.md) | LGPL-2.1-or-later and others (per package); the GPL x264 encoder is left out | `libraries.json` (exact package versions) and the `licenses` folder in the app's `desktop/bundle` folder |
Frame Control starts KDE Connect and talks to it over its network protocol;
it doesn't link to it or include its code. On Windows, Frame Control's own
`pc-host.dll` links to GStreamer dynamically; the libraries are unmodified.
-58
View File
@@ -1,58 +0,0 @@
# 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".
-158
View File
@@ -1,158 +0,0 @@
"""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()
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-19
View File
@@ -1,19 +0,0 @@
"""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])
-8
View File
@@ -1,8 +0,0 @@
{
"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."
}
}
-47
View File
@@ -1,47 +0,0 @@
"""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.
An install_failed report saying the file had no arm64-v8a build (or needed a
newer Android) is about that one APK file, often the wrong per-ABI download of
an app that has an arm64 build, so it says nothing about the app and is left out.
"""
import re
FILE_FAULT = re.compile(r'no arm64-v8a build|needs Android API')
def about_app(r):
"""False for reports about one wrong APK file rather than the app itself."""
return not (r.get('result') == 'install_failed' and not r.get('rating')
and FILE_FAULT.search(r.get('notes') or ''))
def verdict(reports):
"""(verdict, summary lines) for one package's reports, or None."""
reports = [r for r in reports or () if about_app(r)]
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
-123
View File
@@ -1,123 +0,0 @@
"""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()
-80
View File
@@ -1,80 +0,0 @@
"""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()
-2
View File
@@ -1,2 +0,0 @@
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
-31
View File
@@ -1,31 +0,0 @@
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
-3
View File
@@ -1,3 +0,0 @@
node_modules/
dist/
build/deps/
-83
View File
@@ -1,83 +0,0 @@
// Checks that the app's resources hold what Frame Control can't work without.
// The OpenXR compatibility layer is why this exists: VR APK installs need its
// arm64-v8a library (ui/frame_android.py XR_COMPAT_FILES).
//
// As electron-builder's afterPack hook it checks the unpacked app. From the
// command line it checks an installed copy, which is what caught the Windows
// installer silently dropping the library (see .github/workflows/release.yml):
// node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]
// With a reference (the unpacked build's resources), every file in it must also
// be in the installed copy, at the same size.
const fs = require("fs");
const path = require("path");
const REQUIRED = [
"ui/server.py",
"ui/frame_power.py",
"ui/frame_status.py",
"scripts/frame_power_sample.py",
"scripts/frame_power_report.py",
"ui/frame_android.py",
"frame/android/lepton-app.sh",
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
"frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz",
];
function resourcesDir(context) {
if (context.electronPlatformName === "darwin" || context.electronPlatformName === "mas") {
const app = `${context.packager.appInfo.productFilename}.app`;
return path.join(context.appOutDir, app, "Contents", "Resources");
}
return path.join(context.appOutDir, "resources");
}
function size(file) {
try {
return fs.statSync(file).size;
} catch {
return -1;
}
}
// Required files that are missing or empty.
function missing(dir) {
return REQUIRED.filter((rel) => size(path.join(dir, rel)) <= 0);
}
// Files under reference that aren't in dir at the same size.
function differences(dir, reference) {
const out = [];
(function walk(rel) {
for (const e of fs.readdirSync(path.join(reference, rel), { withFileTypes: true })) {
const r = path.join(rel, e.name);
if (e.isDirectory()) walk(r);
else if (e.isFile() && size(path.join(dir, r)) !== size(path.join(reference, r))) out.push(r.split(path.sep).join("/"));
}
})("");
return out;
}
exports.default = async function afterPack(context) {
const dir = resourcesDir(context);
const gone = missing(dir);
if (gone.length) {
throw new Error(`packaged app is missing required resources in ${dir}: ${gone.join(", ")}`);
}
};
exports.missing = missing;
exports.differences = differences;
exports.REQUIRED = REQUIRED;
if (require.main === module) {
const [dir, reference] = process.argv.slice(2);
if (!dir) {
console.error("usage: node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]");
process.exit(2);
}
const problems = [...missing(dir), ...(reference ? differences(dir, reference) : [])];
if (problems.length) {
console.error(`${dir} is missing or has the wrong size for:\n ${[...new Set(problems)].join("\n ")}`);
process.exit(1);
}
console.log(`${dir}: all required resources present${reference ? ", and every file of " + reference : ""}`);
}
-154
View File
@@ -1,154 +0,0 @@
// 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); });
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

-30
View File
@@ -1,30 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-32
View File
@@ -1,32 +0,0 @@
// 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();
});
-33
View File
@@ -1,33 +0,0 @@
// 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 };
-646
View File
@@ -1,646 +0,0 @@
// 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, Notification, clipboard, dialog, ipcMain, nativeImage, session, 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 { preferenceStore } = require("./preferences");
const { createMetaSignIn } = require("./meta-signin");
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";
const preferences = preferenceStore(path.join(app.getPath("userData"), "client-preferences.json"));
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, the time zone 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,
FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
...(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; serverStarted = Date.now(); 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.
// Resolves once it has exited (or after 20 s), so a replacement can take the server
// lock: server.py allows one per user.
function endServer(child) {
const gone = child.exitCode !== null || child.signalCode !== null ? Promise.resolve()
: new Promise((resolve) => child.once("exit", resolve));
try { child.stdin.end(); } catch {}
if (!IS_WIN) child.kill("SIGTERM");
// server.py ignores a second SIGTERM while it shuts down, so the fallback is a hard kill.
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 12000).unref();
return Promise.race([gone, new Promise((resolve) => setTimeout(resolve, 20000).unref())]);
}
function stopServer() {
if (server) endServer(server);
}
function errorPage(message, title = "Frame Control couldn't start") {
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[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>${esc(title)}</h2>
<p>${esc(message)}</p>
<p><button onclick="this.disabled = true; frameApp.restartServer()" style="font:inherit;padding:6px 16px;
border-radius:6px;border:1px solid #30363d;background:#21262d;color:inherit;cursor:pointer">Try Again</button></p>
<p style="color:#8b98a8">Frame → Restart Server does the same.</p></div>`;
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
}
// A server that had been running starts again by itself (something stopped it: a
// signal, a crash). One that stops again within a minute shows the error instead,
// so a server that can't stay up doesn't restart forever.
let serverStarted = 0;
function serverDied(why) {
url = null;
if (!win) return;
if (Date.now() - serverStarted > 60000) restartServer();
else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
}
// Restarts that overlap share one: two could each start a server, and the one
// that lost the lock would leave the app pointing at nothing.
let restarting = null;
function restartServer() {
if (!restarting) {
restarting = (async () => {
const old = server;
server = null;
url = null;
if (old) await endServer(old);
if (starting) await starting.catch(() => {}); // a start it cut short: then start afresh
await load();
})().finally(() => { restarting = null; });
}
return restarting;
}
// 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 select, 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;
let starting = null; // loads that overlap share one server start
async function load() {
const gen = ++loadGen;
try {
if (!url) await (starting ||= startServer().finally(() => { starting = null; }));
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; }
}
// The error page's Try Again button. The error page is the only data: page the window
// shows (`url` can still be set then: the server answered but the page failed to load).
ipcMain.handle("server:restart", (e) => {
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
});
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
// Text from the headset's clipboard onto this computer's.
ipcMain.handle("clipboard:write", (e, text) => {
if (!fromUi(e) || typeof text !== "string") return false;
clipboard.writeText(text);
return true;
});
ipcMain.handle("preferences:read", (e) => fromUi(e) ? preferences.read() : {});
ipcMain.handle("preferences:write", (e, key, value) => fromUi(e) ? preferences.write(key, value) : false);
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
const img = nativeImage.createFromBuffer(Buffer.from(bytes));
if (img.isEmpty()) throw new Error("not an image");
clipboard.writeImage(img);
return true;
});
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(); });
// The page reports the headsets it knows (the server's Devices tab), so the Frame
// menu can switch between them. Only plain names and ids go into the menu.
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
let devices = [];
ipcMain.on("devices:changed", (e, list) => {
if (!fromUi(e) || !Array.isArray(list)) return;
const next = list.slice(0, 20).filter(d => d && typeof d.id === "string" && ALIAS_RE.test(d.alias || ""))
.map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
if (JSON.stringify(next) === JSON.stringify(devices)) return;
devices = next;
buildMenu();
});
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
// SSH through the server, so it goes to the headset and address the app is using
// (with its own pinned identity), and refuses when there's none.
function openSsh() {
if (!url) return dialog.showErrorBox("Couldn't open SSH", "Frame Control's server isn't running.");
const body = JSON.stringify({ what: "terminal" });
const req = http.request(new URL("/api/open", url), {
method: "POST", timeout: 15000,
headers: { "Content-Type": "application/json", "X-Frame-UI": "1", "Content-Length": Buffer.byteLength(body) },
}, (res) => {
let data = "";
res.on("data", (c) => { data += c; });
res.on("end", () => {
if (res.statusCode === 200) return;
let why = `HTTP ${res.statusCode}`;
try { why = JSON.parse(data).error || why; } catch {}
dialog.showErrorBox("Couldn't open SSH", why);
});
});
req.on("error", (e) => dialog.showErrorBox("Couldn't open SSH", e.message));
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
req.end(body);
}
function showDevices() {
if (win && url) win.webContents.executeJavaScript('location.hash = "devices"').catch(() => {});
}
ipcMain.handle("comfort:notify", (e, message) => {
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
return new Promise((resolve, reject) => {
const notification = new Notification({title: "Frame Control", body: message});
const timer = setTimeout(() => reject(new Error("Notification delivery was not confirmed. Check system notification settings.")), 5000);
notification.once("show", () => { clearTimeout(timer); resolve(true); });
notification.once("failed", (_event, error) => {
clearTimeout(timer);
reject(new Error("Notification delivery failed. Check system notification settings: " + error));
});
notification.show();
});
});
// ---- Sign in with Meta (app/meta-signin.js, docs/meta-library.md) ----
// The token goes from the sign-in window's own session straight to the server's
// connect path; the page asks for it and only hears the server's answer.
function postApi(apiPath, body) {
return new Promise((resolve, reject) => {
if (!url) return reject(new Error("Frame Control's server isn't running."));
const data = JSON.stringify(body);
const req = http.request(new URL(apiPath, url), {
method: "POST", timeout: 60000,
headers: { "Content-Type": "application/json", "X-Frame-UI": process.env.FRAME_UI_KEY || "1",
"Content-Length": Buffer.byteLength(data) },
}, (res) => {
let text = "";
res.setEncoding("utf8");
res.on("data", (c) => { text += c; });
res.on("end", () => {
let parsed = {};
try { parsed = JSON.parse(text); } catch {}
resolve({ status: res.statusCode, data: parsed });
});
});
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
req.end(data);
});
}
let metaSignIn = null;
const meta = () => (metaSignIn ||= createMetaSignIn({
BrowserWindow, session, shell, parent: () => win,
// The page only offers sign-in after its "I understand" box is ticked (checked again below).
connect: (token, attempt) => postApi("/api/meta", { action: "connect", token, acknowledged: true, attempt }),
cancel: (attempt) => postApi("/api/meta", { action: "cancel-signin", attempt }),
}));
ipcMain.handle("meta:signin", (e, acknowledged) => {
if (!fromUi(e)) return null;
if (acknowledged !== true) return { ok: false, error: "My Meta library is experimental: tick that you understand the warning first." };
return meta().signIn();
});
ipcMain.handle("meta:signout", (e) => fromUi(e) ? meta().signOut() : false);
// 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, backgroundThrottling: false,
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());
}
}
// Set Up Connection for the headset in use (or another alias, from the Devices tab).
async function setUpConnection(name = activeAlias()) {
if (!ALIAS_RE.test(name)) return;
const alias = `FRAME_ALIAS=${name}`;
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
// --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment.
runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]);
}
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: openSsh },
{ type: "separator" },
...(devices.length > 1 ? [{
label: "Headset",
submenu: devices.map(d => ({ label: d.name, type: "radio", checked: d.active,
click: () => { if (win) win.webContents.send("use-device", d.id); } })),
}] : []),
{ label: "Devices…", accelerator: "CmdOrCtrl+5", click: showDevices },
{ 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);
}
-252
View File
@@ -1,252 +0,0 @@
// "Sign in with Meta" for My Meta library (docs/meta-library.md), the way BSManager's
// "Log in with Meta" works: Meta's own login page in a Frame Control window, then the
// access token cookie (oc_ac_at) that page sets is handed to the local server's
// existing connect path (format check, "who am I" with Meta, 0600 file).
//
// The window has its own persistent session (PARTITION), so the cookie is read only
// from there: never from Frame Control's main window, never from any browser. It has
// no preload and no Node, and it only navigates within Meta's login sites; anything
// else opens in the system browser. Nothing here logs the token, and the page only
// ever gets back the server's answer (connected, display name), never the token.
// Electron is passed in, so app/test/meta-signin.test.js can run it with fakes.
const crypto = require("crypto");
const PARTITION = "persist:meta-signin";
// BSManager's loginUrl (src/main/services/bs-version-download/bs-oculus-downloader.service.ts).
const LOGIN_URL = "https://secure.oculus.com";
const COOKIE = "oc_ac_at";
// Sites the login can go through: the Oculus store web, Meta accounts, Facebook login,
// and Instagram (Meta's login passes through www.instagram.com: seen live, 2026-10-14).
const ALLOWED = ["oculus.com", "meta.com", "facebook.com", "instagram.com"];
const TIMEOUT = 10 * 60 * 1000; // as BSManager
const underDomain = (host, domains) => {
host = String(host || "").replace(/^\./, "").toLowerCase();
return domains.some((d) => host === d || host.endsWith("." + d));
};
// Plain HTTPS on the default port, to one of the login sites.
function allowedUrl(raw) {
let u;
try { u = new URL(String(raw)); } catch { return false; }
if (u.protocol !== "https:" || u.username || u.password || (u.port && u.port !== "443")) return false;
return underDomain(u.hostname, ALLOWED);
}
// Frames inside the page: any HTTPS address (Meta's login can embed captcha and CDN
// frames from other sites), or an empty frame; never data:, blob:, javascript:, http:,
// file: or any other scheme. Frames can't reach the cookie read or IPC (main process only).
function allowedSubframeUrl(raw) {
if (/^about:(?:blank|srcdoc)$/.test(String(raw))) return true;
let u;
try { u = new URL(String(raw)); } catch { return false; }
return u.protocol === "https:" && !u.username && !u.password;
}
const onOculus = (raw) => { try { return underDomain(new URL(raw).hostname, ["oculus.com"]); } catch { return false; } };
// A cheap first look, so a signed-out or app value is never sent anywhere; the server's
// check_token (ui/frame_meta.py) has the final word.
function looksLikeUserToken(v) {
return typeof v === "string" && /^(?:OC|FRL)[A-Za-z0-9+/=_-]{18,4093}$/.test(v)
&& !/^OC\d{15}$/.test(v) && /[a-z]/.test(v);
}
const fingerprint = (v) => crypto.createHash("sha256").update(v).digest("hex");
// Anything token-shaped out of a message, in case a server error ever carried one.
function scrub(text, token) {
let s = String(text == null ? "" : text);
if (token) s = s.split(token).join("<redacted>");
return s.replace(/(?:OC|FRL)[A-Za-z0-9+/=_%|:-]{8,}/g, "<redacted>").slice(0, 600);
}
// deps: { BrowserWindow, session, shell, parent() -> BrowserWindow|null, timeout,
// connect(token, attempt) -> Promise<{status, data}> (the server's connect action),
// cancel(attempt) -> Promise (the server's cancel-signin: never save that attempt) }
function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent = () => null, timeout = TIMEOUT }) {
let active = null; // { win, promise, finish }
const pending = new Set(); // cancels still to reach the server, from flows already finished
let signingOut = null; // the sign-out under way; no sign-in starts while it runs
const SIGNING_OUT = "Frame Control is signing out of Meta; try again in a moment.";
const openOutside = (target) => {
if (/^https?:\/\//i.test(String(target))) shell.openExternal(String(target));
};
function signIn() {
if (signingOut) return Promise.resolve({ ok: false, error: SIGNING_OUT });
if (active) { active.win.focus(); return active.promise; }
const ses = session.fromPartition(PARTITION);
ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false));
ses.setPermissionCheckHandler(() => false);
// Electron adds its own name and the app's to the user agent; send a plain Chrome one.
if (ses.getUserAgent && ses.setUserAgent) {
ses.setUserAgent(ses.getUserAgent().replace(/\s(?:Electron|frame-control|Frame Control)\/\S+/g, ""));
}
const p = parent();
const win = new BrowserWindow({
width: 520, height: 760, title: "Sign in with Meta – Frame Control", autoHideMenuBar: true,
...(p ? { parent: p } : {}),
webPreferences: { partition: PARTITION, contextIsolation: true, nodeIntegration: false, sandbox: true,
nodeIntegrationInSubFrames: false, webviewTag: false, safeDialogs: true, devTools: false },
});
const wc = win.webContents;
let resolve;
const promise = new Promise((r) => { resolve = r; });
let done = false, busy = false, again = false, lastError = null, blocked = null;
const tried = new Set(); // fingerprints of values already sent, so each goes once
// The server refuses to save a connect from an attempt this window gave up on.
const attemptId = crypto.randomUUID();
const onCookie = (_e, cookie, _cause, removed) => {
if (!removed && cookie && cookie.name === COOKIE && underDomain(cookie.domain, ["oculus.com"])) attempt();
};
const timer = setTimeout(() => finish({ ok: false, error: "Sign-in timed out after 10 minutes. Try again." }), timeout);
if (timer.unref) timer.unref();
// Stops cookie intake first, then the window (destroy: a page's beforeunload can't
// keep it open), and answers the page. Unless it connected, and if it sent a token, it
// then has the server drop this attempt: only once the connect under way has settled,
// so the server always sees the cancel after the connect (and deletes the token if
// that connect saved it). The returned promise settles when that is done.
let finished = null;
let inflight = null; // the last connect sent
function finish(result) {
if (done) return finished || Promise.resolve(); // destroy() below re-enters through "closed"
done = true;
ses.cookies.removeListener("changed", onCookie);
clearTimeout(timer);
if (active && active.win === win) active = null;
if (!win.isDestroyed()) win.destroy();
resolve(result);
if (result.ok || !inflight) return (finished = Promise.resolve()); // nothing sent: nothing to cancel
finished = Promise.resolve(inflight).catch(() => {}).then(() => cancel(attemptId)).catch(() => {});
pending.add(finished);
finished.then(() => pending.delete(finished));
return finished;
}
async function attempt() {
if (done) return;
if (busy) { again = true; return; }
busy = true;
try {
do {
again = false;
// Only this window's own session, and only Meta's Oculus cookie.
const cookies = await ses.cookies.get({ name: COOKIE });
const c = cookies.find((k) => underDomain(k.domain, ["oculus.com"]) && looksLikeUserToken(k.value));
if (!c || done) continue;
const key = fingerprint(c.value);
if (tried.has(key)) continue;
tried.add(key);
let r;
try {
inflight = connect(c.value, attemptId);
r = await inflight;
} catch {
lastError = "Frame Control's local server didn't answer.";
continue;
}
if (done) return;
const d = (r && r.data) || {};
if (r && r.status === 200 && d.connected) {
finish({ ok: true, connected: true, user: d.user && d.user.name ? { name: scrub(d.user.name, c.value).slice(0, 80) } : null,
message: scrub(d.message || "Connected your Meta account (Experimental)", c.value) });
return;
}
lastError = scrub(d.error || `HTTP ${r && r.status}`, c.value);
} while (again && !done);
} catch {
// The session went away (the window closed); nothing to do.
} finally {
busy = false;
}
}
// Navigation stays on Meta's login sites. A link elsewhere opens in the system
// browser; a redirect elsewhere is just stopped (it may carry sign-in data).
// Every frame: the window itself stays on the list; frames inside it may load any
// HTTPS page (captchas), and one going anywhere else is just stopped.
wc.on("will-navigate", (e, target) => { if (!allowedUrl(target)) { e.preventDefault(); openOutside(target); } });
// A stopped page change in the window itself says where it went (the host only), so
// closing the stuck window explains why instead of "closed before you signed in".
const stopped = (to) => {
let host = "";
try { host = new URL(String(to)).hostname; } catch { /* not an address */ }
blocked = `Meta's sign-in went to ${scrub(host || "an address", null).slice(0, 80)}, which Frame Control doesn't allow.`;
};
wc.on("will-frame-navigate", (e) => {
if (e.isMainFrame ? allowedUrl(e.url) : allowedSubframeUrl(e.url)) return;
e.preventDefault();
if (e.isMainFrame) stopped(e.url);
});
// Redirects fire for frames too: the same rule per frame (details on the event in
// current Electron, positional arguments in older ones; unknown counts as the window).
wc.on("will-redirect", (e, target, _inPlace, isMainFrame) => {
const to = e && e.url !== undefined ? e.url : target;
const main = e && e.isMainFrame !== undefined ? e.isMainFrame : isMainFrame !== false;
if (main ? allowedUrl(to) : allowedSubframeUrl(to)) return;
e.preventDefault();
if (main) stopped(to);
});
// A page's beforeunload never keeps the window open.
wc.on("will-prevent-unload", (e) => e.preventDefault());
wc.setWindowOpenHandler(({ url: target }) => {
if (allowedUrl(target)) wc.loadURL(target).catch(() => {});
else openOutside(target);
return { action: "deny" };
});
// Signed in: the cookie changes, or the window lands back on Meta's store site.
ses.cookies.on("changed", onCookie);
wc.on("did-navigate", (_e, target) => { blocked = null; if (onOculus(target)) attempt(); }); // it recovered
wc.on("did-navigate-in-page", (_e, target) => { if (onOculus(target)) attempt(); });
win.on("closed", () => finish({ ok: false, cancelled: true,
error: lastError ? `The sign-in window closed before Frame Control could connect. ${lastError}`
: blocked ? `The sign-in window was closed before you signed in. ${blocked}`
: "The sign-in window was closed before you signed in." }));
active = { win, promise, finish };
wc.loadURL(LOGIN_URL).catch(() => {});
return promise;
}
// Signs out of Meta in Frame Control: stops any sign-in under way (and has the server
// refuse to save it), destroys the window, then clears everything
// the sign-in session stored (cookies, storage, cache), so the next sign-in starts fresh.
// One at a time (a second call joins the first), and no sign-in can start meanwhile.
// It goes round until no window is open and no connect or cancel is left, so nothing
// started before it can set a token after it (or after the Disconnect that follows).
function signOut() {
if (!signingOut) {
signingOut = (async () => {
for (;;) {
if (active) {
const { win, finish } = active;
await finish({ ok: false, cancelled: true, error: "Signed out of Meta in Frame Control." });
// Clear only once nothing can write to the session again.
if (!win.isDestroyed()) throw new Error("The Sign in with Meta window didn't close; try again.");
} else if (pending.size) {
// A connect under way settles and is cancelled before the caller (Disconnect)
// deletes the account, so it can't be saved afterwards.
await Promise.all([...pending]);
} else {
break;
}
}
const ses = session.fromPartition(PARTITION);
await ses.clearStorageData();
await ses.clearCache();
if (ses.clearAuthCache) await ses.clearAuthCache();
return true;
})().finally(() => { signingOut = null; });
}
return signingOut;
}
return { signIn, signOut, isOpen: () => !!active, isSigningOut: () => !!signingOut };
}
module.exports = { createMetaSignIn, allowedUrl, allowedSubframeUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE, ALLOWED };
-3591
View File
File diff suppressed because it is too large. Load diff
-226
View File
@@ -1,226 +0,0 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.18",
"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"
]
}
],
"afterPack": "build/check-resources.js",
"directories": {
"output": "dist",
"buildResources": "build"
},
"files": [
"main.js",
"preload.js",
"install-link.js",
"updater.js",
"preferences.js",
"meta-signin.js",
"package.json",
"build/icon.png"
],
"extraResources": [
{
"from": "../ui",
"to": "ui",
"filter": [
"*.py",
"*.html",
"*.js",
"*.css",
"telemetry.json"
]
},
{
"from": "../ui/apk_sources",
"to": "ui/apk_sources",
"filter": [
"*.py",
"*.json"
]
},
{
"from": "../scripts",
"to": "scripts",
"filter": [
"*.sh",
"frame_power_sample.py",
"frame_power_report.py"
]
},
{
"from": "../frame/android",
"to": "frame/android",
"filter": [
"*.sh",
"*.py",
"*.js"
]
},
{
"from": "../frame/openxr-compat",
"to": "frame/openxr-compat",
"filter": [
"XrApiLayer_FRAME_compat.json",
"prebuilt/**/*.so.gz"
]
},
{
"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}",
"extraResources": [
{
"from": "build/desktop/${os}-${arch}",
"to": "desktop/bundle",
"filter": [
"**/*"
]
}
]
},
"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"
}
}
-27
View File
@@ -1,27 +0,0 @@
// Client choices live outside the local server's changing URL/origin.
const fs = require("node:fs");
const path = require("node:path");
const KEYS = ["mvSeparate", "padUsed", "pcView"];
const valid = value => value === "0" || value === "1";
function preferenceStore(file) {
function read() {
try {
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
if (!saved || typeof saved !== "object" || Array.isArray(saved)) return {};
return Object.fromEntries(KEYS.filter(key => valid(saved[key])).map(key => [key, saved[key]]));
} catch { return {}; }
}
function write(key, value) {
if (!KEYS.includes(key) || !valid(value)) throw new Error("Invalid preference");
const saved = { ...read(), [key]: value };
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = file + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(saved), { mode: 0o600 });
fs.renameSync(tmp, file);
return true;
}
return { read, write };
}
module.exports = { preferenceStore };
-59
View File
@@ -1,59 +0,0 @@
// Lets the page read and write this computer's clipboard through Electron, so sending
// it to the Frame, and copying the Frame's back, needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// the page where a dropped file or folder lives, so a folder can be sideloaded
// as a title without zipping it (the local server reads it from there).
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
// the headset can't be reached, and keeps the Frame menu's list of headsets up to date.
// 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. It opens the Sign in
// with Meta window and signs out of it (app/meta-signin.js).
const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
preferences: {
read: () => ipcRenderer.invoke("preferences:read"),
write: (key, value) => ipcRenderer.invoke("preferences:write", key, value),
},
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
writeClipboard: (text) => ipcRenderer.invoke("clipboard:write", text),
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
onUseDevice: (cb) => {
ipcRenderer.removeAllListeners("use-device");
ipcRenderer.on("use-device", (_e, id) => cb(String(id)));
},
// 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());
},
// Sign in with Meta (app/meta-signin.js): Meta's login page in its own window. The
// answer is the server's (connected, display name), never the token.
meta: {
signIn: (acknowledged) => ipcRenderer.invoke("meta:signin", acknowledged === true),
signOut: () => ipcRenderer.invoke("meta:signout"),
},
onInstallLink: (cb) => {
ipcRenderer.removeAllListeners("install-link");
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
ipcRenderer.send("install-link:ready");
},
});
-578
View File
@@ -1,578 +0,0 @@
// Run: node --test app/test/
// "Sign in with Meta" (app/meta-signin.js) with fake Electron windows, sessions and cookies.
const nodeTest = require("node:test");
// A broken flow can leave a sign-in waiting forever; fail it instead of hanging.
const test = (name, fn) => nodeTest(name, { timeout: 5000 }, fn);
const assert = require("node:assert");
const { EventEmitter } = require("node:events");
const { createMetaSignIn, allowedUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE } = require("../meta-signin");
const TOKEN = "OCAbcdefghijklmnopQRSTUVwxyz0123456789abcdEFGH";
const OTHER = "OCzyxwvutsrqponmlkJIHGFEDcba9876543210zyxwVUTS";
class FakeCookies extends EventEmitter {
constructor() { super(); this.store = []; this.reads = 0; }
async get(filter) {
this.reads++;
return this.store.filter((c) => !filter.name || c.name === filter.name);
}
set(cookie) { // as the page would: store it, then tell listeners
this.store = this.store.filter((c) => !(c.name === cookie.name && c.domain === cookie.domain)).concat(cookie);
this.emit("changed", {}, cookie, "explicit", false);
}
}
class FakeSession {
constructor(name, log) { this.name = name; this.log = log; this.cookies = new FakeCookies(); this.cleared = []; this.permission = null; }
setPermissionRequestHandler(fn) { this.permission = fn; }
setPermissionCheckHandler(fn) { this.permissionCheck = fn; }
getUserAgent() { return "Mozilla/5.0 Chrome/140.0 frame-control/0.4.4 Electron/44.4.5 Safari/537.36"; }
setUserAgent(ua) { this.ua = ua; }
async clearStorageData(opts) { this.log.push("clear"); this.cleared.push(["storage", opts]); }
async clearCache() { this.cleared.push(["cache"]); }
async clearAuthCache() { this.cleared.push(["auth"]); }
}
function fakes({ timeout } = {}) {
const log = []; // the order things happened in
const sessions = new Map();
const defaultSession = new FakeSession("default", log);
const session = {
defaultSession,
fromPartition(name) {
if (!sessions.has(name)) sessions.set(name, new FakeSession(name, log));
return sessions.get(name);
},
};
const windows = [];
class BrowserWindow extends EventEmitter {
constructor(opts) {
super();
this.opts = opts;
this.destroyed = false;
this.veto = false; // a page whose beforeunload keeps the window open on close()
this.focused = 0;
const wc = new EventEmitter();
wc.loaded = [];
wc.loadURL = async (u) => { wc.loaded.push(u); };
wc.setWindowOpenHandler = (fn) => { wc.openHandler = fn; };
// Deliberately a different session: the code must read the partition's, not guess.
wc.session = defaultSession;
this.webContents = wc;
windows.push(this);
}
isDestroyed() { return this.destroyed; }
focus() { this.focused++; }
close() { if (this.destroyed || this.veto) return; this.destroy(); }
destroy() { if (this.destroyed) return; this.destroyed = true; log.push("destroyed"); this.emit("closed"); }
}
const opened = [];
const shell = { openExternal: (u) => { opened.push(u); } };
const calls = [], attempts = [], cancels = [];
let answer = () => ({ status: 200, data: { connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" } });
const connect = async (token, attempt) => { calls.push(token); attempts.push(attempt); log.push("connect"); return answer(token); };
const cancel = async (attempt) => {
cancels.push(attempt);
log.push(`cancel (cookie listeners: ${session.fromPartition(PARTITION).cookies.listenerCount("changed")})`);
};
const flow = createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent: () => null, ...(timeout ? { timeout } : {}) });
return { flow, session, sessions, windows, opened, calls, attempts, cancels, log, setAnswer: (fn) => { answer = fn; },
meta: () => session.fromPartition(PARTITION) };
}
const tick = () => new Promise((r) => setImmediate(r));
async function settle() { for (let i = 0; i < 10; i++) await tick(); }
function navigate(wc, event, target) {
let prevented = false;
wc.emit(event, { preventDefault: () => { prevented = true; } }, target);
return prevented;
}
test("only plain HTTPS addresses on Meta's login sites are allowed", () => {
for (const ok of ["https://secure.oculus.com", "https://secure.oculus.com/my/profile", "https://auth.meta.com/login/",
"https://www.meta.com/", "https://www.facebook.com/login.php", "https://oculus.com:443/",
"https://www.instagram.com/accounts/login/"]) {
assert.ok(allowedUrl(ok), ok);
}
for (const bad of ["http://secure.oculus.com/", "https://oculus.com.evil.example/", "https://evil-oculus.com/",
"https://notmeta.com/", "https://example.com/", "javascript:alert(1)", "file:///etc/passwd",
"https://secure.oculus.com:8443/", "https://user:pw@secure.oculus.com/", "https://evil.example/?u=https://www.meta.com",
"data:text/html,hi", "not a url"]) {
assert.ok(!allowedUrl(bad), bad);
}
});
test("the window is isolated: its own partition, no preload, no Node, sandboxed, on Meta's login page", async () => {
const f = fakes();
f.flow.signIn();
const [w] = f.windows;
const wp = w.opts.webPreferences;
assert.strictEqual(wp.partition, PARTITION);
assert.strictEqual(PARTITION, "persist:meta-signin");
assert.strictEqual(wp.contextIsolation, true);
assert.strictEqual(wp.nodeIntegration, false);
assert.strictEqual(wp.sandbox, true);
assert.strictEqual(wp.preload, undefined);
assert.strictEqual(wp.devTools, false);
assert.deepStrictEqual(w.webContents.loaded, [LOGIN_URL]);
assert.strictEqual(LOGIN_URL, "https://secure.oculus.com");
// Permission prompts (camera, notifications...) are refused.
let granted = null;
f.meta().permission(null, "media", (v) => { granted = v; });
assert.strictEqual(granted, false);
assert.strictEqual(f.meta().permissionCheck(null, "notifications", "https://secure.oculus.com"), false);
assert.ok(!/Electron|frame-control/.test(f.meta().ua));
w.close();
});
test("navigation stays on Meta's sites; other links open in the system browser", async () => {
const f = fakes();
f.flow.signIn();
const wc = f.windows[0].webContents;
assert.strictEqual(navigate(wc, "will-navigate", "https://auth.meta.com/login/"), false);
assert.strictEqual(navigate(wc, "will-navigate", "https://www.facebook.com/login.php"), false);
assert.strictEqual(navigate(wc, "will-navigate", "https://example.com/help"), true);
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
// A redirect elsewhere is stopped and not opened anywhere (it may carry sign-in data).
assert.strictEqual(navigate(wc, "will-redirect", "https://evil.example/cb?x=1"), true);
assert.strictEqual(navigate(wc, "will-redirect", "https://auth.meta.com/next"), false);
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
// New windows: never created. Meta's own load in this window; others go outside; non-web schemes nowhere.
assert.deepStrictEqual(wc.openHandler({ url: "https://www.meta.com/legal/terms/" }), { action: "deny" });
assert.deepStrictEqual(wc.openHandler({ url: "https://example.org/" }), { action: "deny" });
assert.deepStrictEqual(wc.openHandler({ url: "file:///etc/passwd" }), { action: "deny" });
await settle();
assert.deepStrictEqual(wc.loaded, [LOGIN_URL, "https://www.meta.com/legal/terms/"]);
assert.deepStrictEqual(f.opened, ["https://example.com/help", "https://example.org/"]);
f.windows[0].close();
});
test("the cookie is read only from the sign-in partition, only for Meta's Oculus domain", async () => {
const f = fakes();
// The same cookie elsewhere: the default session and another partition. Never read.
f.session.defaultSession.cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
f.session.fromPartition("persist:other").cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
const done = f.flow.signIn();
const w = f.windows[0];
// A cookie with the right name on another site, and a signed-out value: ignored.
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: OTHER });
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: "OC123456789012345" });
w.webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
await settle();
assert.deepStrictEqual(f.calls, []);
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
const r = await done;
assert.deepStrictEqual(f.calls, [TOKEN]);
assert.strictEqual(r.ok, true);
assert.strictEqual(f.session.defaultSession.cookies.reads, 0);
assert.strictEqual(f.session.fromPartition("persist:other").cookies.reads, 0);
assert.ok(f.sessions.get(PARTITION).cookies.reads > 0);
});
test("landing on the logged-in page picks up a cookie set earlier", async () => {
const f = fakes();
const done = f.flow.signIn();
f.meta().cookies.store.push({ name: COOKIE, domain: "secure.oculus.com", value: TOKEN }); // no change event
f.windows[0].webContents.emit("did-navigate", {}, "https://auth.meta.com/login/"); // not Meta's store site yet
await settle();
assert.deepStrictEqual(f.calls, []);
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
assert.strictEqual((await done).ok, true);
assert.deepStrictEqual(f.calls, [TOKEN]);
});
test("after a connect the window closes, and the page gets the answer, never the token", async () => {
const f = fakes();
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
const r = await done;
assert.ok(f.windows[0].isDestroyed());
assert.ok(!f.flow.isOpen());
assert.deepStrictEqual(r, { ok: true, connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" });
assert.ok(!JSON.stringify(r).includes(TOKEN));
// The cookie listener is gone: a later change sends nothing.
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
});
test("a token the server refuses keeps the window open, is sent once, and the next one connects", async () => {
const f = fakes();
f.setAnswer((t) => t === TOKEN ? { status: 400, data: { error: "Meta didn't accept this token." } }
: { status: 200, data: { connected: true, user: null } });
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile"); // same value again
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
assert.ok(!f.windows[0].isDestroyed());
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
const r = await done;
assert.deepStrictEqual(f.calls, [TOKEN, OTHER]);
assert.strictEqual(r.ok, true);
assert.strictEqual(r.user, null);
});
test("the token is never logged, and an error that echoed it is scrubbed", async () => {
const seen = [];
const names = ["log", "info", "warn", "error", "debug", "trace"];
const saved = Object.fromEntries(names.map((n) => [n, console[n]]));
const writes = [process.stdout.write, process.stderr.write];
for (const n of names) console[n] = (...a) => { seen.push(a.map(String).join(" ")); };
process.stdout.write = (c, ...rest) => { seen.push(String(c)); return writes[0].call(process.stdout, c, ...rest); };
process.stderr.write = (c, ...rest) => { seen.push(String(c)); return writes[1].call(process.stderr, c, ...rest); };
let r;
try {
const f = fakes();
f.setAnswer((t) => ({ status: 500, data: { error: `RuntimeError: bad ${t} and ${t.slice(0, 12)}` } }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close(); // the user gives up
r = await done;
} finally {
for (const n of names) console[n] = saved[n];
[process.stdout.write, process.stderr.write] = writes;
}
assert.strictEqual(r.ok, false);
assert.strictEqual(r.cancelled, true);
assert.ok(!r.error.includes(TOKEN) && !r.error.includes(TOKEN.slice(0, 12)), r.error);
assert.match(r.error, /<redacted>/);
assert.ok(!seen.some((s) => s.includes(TOKEN.slice(0, 12))), "token in output");
assert.strictEqual(scrub(`x FRL${"a".repeat(30)} y`), "x <redacted> y");
});
test("closing the window before signing in reports it, and a second click reuses the open window", async () => {
const f = fakes();
const a = f.flow.signIn();
const b = f.flow.signIn();
assert.strictEqual(a, b);
assert.strictEqual(f.windows.length, 1);
assert.strictEqual(f.windows[0].focused, 1);
f.windows[0].close();
const r = await a;
assert.deepStrictEqual(r, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
assert.deepStrictEqual(f.calls, []);
});
test("disconnect's sign-out closes the window and clears only the sign-in partition", async () => {
const f = fakes();
f.session.fromPartition("persist:other");
const pending = f.flow.signIn();
await f.flow.signOut();
const r = await pending;
assert.strictEqual(r.ok, false);
assert.ok(f.windows[0].isDestroyed());
assert.deepStrictEqual(f.meta().cleared, [["storage", undefined], ["cache"], ["auth"]]);
assert.deepStrictEqual(f.session.defaultSession.cleared, []);
assert.deepStrictEqual(f.session.fromPartition("persist:other").cleared, []);
// The next sign-in starts with a new window.
f.flow.signIn();
assert.strictEqual(f.windows.length, 2);
f.windows[1].close();
});
test("the first look at a cookie value matches the server's token rules", () => {
assert.ok(looksLikeUserToken(TOKEN));
assert.ok(looksLikeUserToken("FRL" + "aB3".repeat(10)));
for (const bad of [undefined, "", "OC123456789012345", "OC|123|abc", "OCabc%2Fdef" + "x".repeat(20), "OCshort",
"XY" + "a".repeat(30), "OC" + "A".repeat(30)]) {
assert.ok(!looksLikeUserToken(bad), String(bad));
}
});
function frameNav(wc, url, isMainFrame) {
let prevented = false;
wc.emit("will-frame-navigate", { url, isMainFrame, preventDefault: () => { prevented = true; } });
return prevented;
}
test("the window stays on Meta's sites; frames inside it may load any HTTPS page, nothing else", async () => {
const f = fakes();
f.flow.signIn();
const wc = f.windows[0].webContents;
// Frames: captcha and CDN frames from other sites load; other schemes are stopped.
for (const ok of ["https://client-api.arkoselabs.com/fc/gc/", "https://newassets.hcaptcha.com/captcha/v1/x",
"https://www.google.com/recaptcha/api2/anchor", "https://static.xx.fbcdn.net/x",
"https://www.facebook.com/plugin", "about:blank", "about:srcdoc"]) {
assert.strictEqual(frameNav(wc, ok, false), false, ok);
}
for (const bad of ["http://www.facebook.com/plugin", "data:text/html,<script>x</script>", "blob:https://evil.example/1",
"javascript:alert(1)", "file:///etc/passwd", "ftp://example.com/", "https://u:p@evil.example/",
"chrome://settings", "not a url"]) {
assert.strictEqual(frameNav(wc, bad, false), true, bad);
}
// The window itself: Meta's sites only.
assert.strictEqual(frameNav(wc, "about:blank", true), true);
assert.strictEqual(frameNav(wc, "https://evil.example/", true), true);
assert.strictEqual(frameNav(wc, "https://client-api.arkoselabs.com/", true), true);
assert.strictEqual(frameNav(wc, "https://auth.meta.com/login/", true), false);
assert.deepStrictEqual(f.opened, []); // stopped, never opened outside
f.windows[0].destroy();
});
function redirect(wc, url, isMainFrame, style) {
let prevented = false;
const e = { preventDefault: () => { prevented = true; } };
if (style === "details") wc.emit("will-redirect", Object.assign(e, { url, isMainFrame }));
else wc.emit("will-redirect", e, url, false, isMainFrame);
return prevented;
}
test("redirects follow the same rule per frame", async () => {
const f = fakes();
f.flow.signIn();
const wc = f.windows[0].webContents;
for (const style of ["details", "positional"]) {
// A captcha frame redirected within HTTPS loads; a frame redirected to data: or http: doesn't.
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", false, style), false, style);
assert.strictEqual(redirect(wc, "https://newassets.hcaptcha.com/x", false, style), false, style);
assert.strictEqual(redirect(wc, "data:text/html,<b>x</b>", false, style), true, style);
assert.strictEqual(redirect(wc, "http://www.facebook.com/x", false, style), true, style);
assert.strictEqual(redirect(wc, "javascript:alert(1)", false, style), true, style);
// The window: Meta's sites only.
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", true, style), true, style);
assert.strictEqual(redirect(wc, "https://auth.meta.com/next", true, style), false, style);
}
// Without frame information it is treated as the window.
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/", undefined, "positional"), true);
assert.deepStrictEqual(f.opened, []);
f.windows[0].destroy();
});
test("a redirect the window stops is named when the window is closed", async () => {
const f = fakes();
const p = f.flow.signIn();
const wc = f.windows[0].webContents;
assert.strictEqual(redirect(wc, "https://www.instagram.com/x?next=1", true, "details"), false); // Meta's login goes here
assert.strictEqual(redirect(wc, "https://login.evil.example/cb?code=SECRET", true, "details"), true);
f.windows[0].close();
const r = await p;
assert.strictEqual(r.ok, false);
assert.match(r.error, /went to login\.evil\.example, which Frame Control doesn't allow/);
assert.doesNotMatch(r.error, /SECRET|code=/);
});
test("a stopped redirect is forgotten once the window loads a page again", async () => {
const f = fakes();
const p = f.flow.signIn();
const wc = f.windows[0].webContents;
assert.strictEqual(redirect(wc, "https://login.evil.example/cb", true, "details"), true);
wc.emit("did-navigate", {}, "https://auth.meta.com/login/"); // the user carried on
f.windows[0].close();
assert.deepStrictEqual(await p, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
});
test("a page's beforeunload can't keep the window open", async () => {
const f = fakes();
const done = f.flow.signIn();
const w = f.windows[0];
let prevented = false;
w.webContents.emit("will-prevent-unload", { preventDefault: () => { prevented = true; } });
assert.ok(prevented);
// Even if close() would be vetoed, finishing destroys the window.
w.veto = true;
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
assert.strictEqual((await done).ok, true);
assert.ok(w.isDestroyed());
});
test("closing during a connect: the cancel goes to the server only after that connect settles", async () => {
for (const outcome of ["saved", "refused", "failed"]) {
const f = fakes();
let release, fail;
f.setAnswer(() => new Promise((r, j) => { release = r; fail = j; }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
f.windows[0].close(); // the user gives up while the server and Meta are answering
const r = await done; // the page hears at once
assert.strictEqual(r.ok, false);
await settle();
assert.deepStrictEqual(f.cancels, [], outcome); // not before the connect has settled
if (outcome === "saved") release({ status: 200, data: { connected: true } });
else if (outcome === "refused") release({ status: 400, data: { error: "no" } });
else fail(new Error("socket hang up"));
await settle();
// The same attempt id the connect carried, after it settled and after cookie intake stopped.
assert.deepStrictEqual(f.cancels, [f.attempts[0]], outcome);
assert.match(f.cancels[0], /^[A-Za-z0-9-]{8,64}$/);
assert.deepStrictEqual(f.log, ["connect", "destroyed", "cancel (cookie listeners: 0)"], outcome);
assert.deepStrictEqual(f.calls, [TOKEN]);
}
});
test("closing before any connect needs no cancel", async () => {
const f = fakes();
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: TOKEN }); // nothing usable
await settle();
f.windows[0].close();
assert.strictEqual((await done).ok, false);
await f.flow.signOut();
await settle();
assert.deepStrictEqual(f.calls, []);
assert.deepStrictEqual(f.cancels, []);
});
test("a timeout during a connect cancels after it settles", async () => {
const f = fakes({ timeout: 50 });
let release;
f.setAnswer(() => new Promise((r) => { release = r; })); // Meta is slow
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
const r = await done;
assert.match(r.error, /timed out/);
assert.ok(f.windows[0].isDestroyed());
assert.deepStrictEqual(f.cancels, []);
release({ status: 400, data: { error: "no" } });
await settle();
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
});
test("disconnect during a connect waits for it, cancels it, then clears, before the page deletes the account", async () => {
const f = fakes();
let release;
f.setAnswer(() => new Promise((r) => { release = r; }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.log.length = 0;
let signedOut = false;
const out = f.flow.signOut().then(() => { signedOut = true; }); // the page's Disconnect, step one
assert.strictEqual((await done).ok, false);
await settle();
assert.strictEqual(signedOut, false); // still waiting for the connect
assert.deepStrictEqual(f.cancels, []);
release({ status: 200, data: { connected: true } }); // it saved, just now
await out;
// Only now would the page post its disconnect: the cancel (which deletes what that
// connect saved) has reached the server, and the session is cleared.
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
});
test("a later disconnect still waits for a closed window's connect", async () => {
const f = fakes();
let release;
f.setAnswer(() => new Promise((r) => { release = r; }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close();
await done;
let signedOut = false;
const out = f.flow.signOut().then(() => { signedOut = true; });
await settle();
assert.strictEqual(signedOut, false);
release({ status: 400, data: { error: "no" } });
await out;
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
});
test("nothing sent means nothing to cancel; sign-out destroys the window, cancels, then clears", async () => {
const f = fakes();
// Nothing sent: closing cancels nothing.
const a = f.flow.signIn();
f.windows[0].close();
await a;
assert.deepStrictEqual(f.cancels, []);
// Something sent, then signed out: the attempt is cancelled before the session is cleared.
f.setAnswer(() => ({ status: 400, data: { error: "Meta didn't accept this token." } }));
const b = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[1].veto = true; // and its page tries to stay open
f.log.length = 0;
await f.flow.signOut();
assert.strictEqual((await b).ok, false);
assert.ok(f.windows[1].isDestroyed());
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
// After sign-out a new cookie in the partition sends nothing.
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
});
test("each sign-in has its own attempt id", async () => {
const f = fakes();
f.setAnswer(() => ({ status: 400, data: { error: "no" } }));
const a = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close();
await a;
const b = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
f.windows[1].close();
await b;
await settle();
assert.strictEqual(f.attempts.length, 2);
assert.notStrictEqual(f.attempts[0], f.attempts[1]);
assert.deepStrictEqual(f.cancels, f.attempts);
});
test("no sign-in can start while a sign-out waits on an earlier connect", async () => {
const f = fakes();
let release;
f.setAnswer(() => new Promise((r) => { release = r; }));
const first = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
const out = f.flow.signOut(); // Disconnect pressed while that connect is under way
assert.strictEqual((await first).ok, false);
assert.ok(f.flow.isSigningOut());
// The page answered, so the user tries again at once: refused, no window, nothing sent.
const second = await f.flow.signIn();
assert.deepStrictEqual(second, { ok: false, error: "Frame Control is signing out of Meta; try again in a moment." });
assert.strictEqual(f.windows.length, 1);
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
// A second sign-out joins the first.
assert.strictEqual(f.flow.signOut(), out);
release({ status: 200, data: { connected: true } });
await out;
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
assert.strictEqual(f.meta().cleared.filter(([k]) => k === "storage").length, 1);
assert.ok(!f.flow.isSigningOut());
// Once it is done, signing in works again.
f.setAnswer(() => ({ status: 200, data: { connected: true } }));
const third = f.flow.signIn();
assert.strictEqual(f.windows.length, 2);
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
assert.strictEqual((await third).ok, true);
});
test("sign-out goes round until no window and no connect or cancel is left", async () => {
const f = fakes();
let releaseA;
f.setAnswer(() => new Promise((r) => { releaseA = r; }));
const a = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close(); // window A closed; its cancel waits on its connect
await a;
// Window B is open, with its own connect under way, when sign-out starts.
let releaseB;
f.setAnswer(() => new Promise((r) => { releaseB = r; }));
const b = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
let finished = false;
const out = f.flow.signOut().then(() => { finished = true; });
await b;
releaseB({ status: 400, data: { error: "no" } });
await settle();
assert.strictEqual(finished, false); // A's connect is still under way
releaseA({ status: 200, data: { connected: true } });
await out;
assert.deepStrictEqual([...f.cancels].sort(), [...f.attempts].sort());
assert.strictEqual(f.attempts.length, 2);
assert.strictEqual(f.log[f.log.length - 1], "clear");
});
-68
View File
@@ -1,68 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { preferenceStore } = require("../preferences");
function fixture(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-preferences-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
return path.join(dir, "client-preferences.json");
}
test("desktop packaging includes every local application module dependency", () => {
const root = path.resolve(__dirname, "..");
const packaged = new Set(require("../package.json").build.files);
for (const file of ["main.js", "preload.js"]) {
const source = fs.readFileSync(path.join(root, file), "utf8");
for (const match of source.matchAll(/require\(["'](\.\/[^"']+)["']\)/g)) {
const target = match[1].slice(2) + ".js";
assert.ok(packaged.has(target), `${file} needs packaged ${target}`);
}
}
});
test("client choices survive a new native session and local server origin", t => {
const file = fixture(t);
const first = preferenceStore(file);
assert.deepEqual(first.read(), {});
first.write("mvSeparate", "0");
first.write("padUsed", "1");
// A new process/session reads the same client store, regardless of its URL.
const second = preferenceStore(file);
assert.deepEqual(second.read(), { mvSeparate: "0", padUsed: "1" });
second.write("mvSeparate", "1");
assert.deepEqual(first.read(), { mvSeparate: "1", padUsed: "1" });
});
test("only the non-sensitive binary choices can enter the native store", t => {
const file = fixture(t), store = preferenceStore(file);
for (const [key, value] of [["token", "secret"], ["__proto__", "1"], ["padUsed", true], ["mvSeparate", "yes"]]) {
assert.throws(() => store.write(key, value), /Invalid preference/);
}
assert.equal(fs.existsSync(file), false);
fs.writeFileSync(file, JSON.stringify({ token: "discard", mvSeparate: "0", padUsed: 1 }));
assert.deepEqual(store.read(), { mvSeparate: "0" });
store.write("padUsed", "1");
assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), { mvSeparate: "0", padUsed: "1" });
});
test("a missing or damaged preference file retains usable defaults", t => {
const file = fixture(t), store = preferenceStore(file);
assert.deepEqual(store.read(), {});
for (const bad of ["{broken", "null", "[]", '"text"']) {
fs.writeFileSync(file, bad);
assert.deepEqual(store.read(), {});
}
store.write("padUsed", "1");
assert.deepEqual(store.read(), { padUsed: "1" });
});
test("the PC in the headset opt-in is remembered and off unless saved", t => {
const file = fixture(t), store = preferenceStore(file);
assert.equal(store.read().pcView, undefined);
store.write("pcView", "1");
assert.deepEqual(preferenceStore(file).read(), { pcView: "1" });
assert.throws(() => store.write("pcView", "on"), /Invalid preference/);
});
-80
View File
@@ -1,80 +0,0 @@
// 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("the release page is this repository's tag page, never a draft's untagged-... link", () => {
const { fromManifest, fromApi } = require("../updater");
const tagPage = "https://github.com/saphid/frame-control/releases/tag/v0.4.0";
const page = (p) => fromManifest({ version: "0.4.0", assets: [], page: p }).page;
assert.strictEqual(page(tagPage), tagPage);
// 0.4.0's real update.json: the draft's address, a 404 once published.
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/untagged-c6ddfed7f75d67db2e99"), tagPage);
assert.strictEqual(page(undefined), tagPage);
assert.strictEqual(page("https://evil.example/releases/tag/v0.4.0"), tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.4.0?x=1"), tagPage);
// Paths that normalize to another repository.
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/..\\..\\..\\..\\other-owner\\other-repo"),
tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/%2e%2e/%2e%2e/%2e%2e/%2e%2e/other-owner/other-repo"),
tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.3.9"), tagPage); // only its own tag
assert.strictEqual(fromApi({ tag_name: "../../x", assets: [] }).page, "https://github.com/saphid/frame-control/releases");
assert.strictEqual(fromApi({ tag_name: "v0.4.0", assets: [],
html_url: "https://github.com/saphid/frame-control/releases/tag/untagged-x" }).page, tagPage);
});
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/);
});
-263
View File
@@ -1,263 +0,0 @@
// 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);
}
// The release page the banner links to. update.json for 0.4.0 carried the draft's
// address (releases/tag/untagged-...), which is dead once the release is published,
// and a page from the manifest could also be steered elsewhere (e.g. tag/..\..\other/repo
// normalizes to another repository) before shell.openExternal. So the given page is
// ignored: the link is always this repository's tag page, built from a valid version.
function releasePage(version) {
const v = parseVersion(version);
if (!v) return RELEASES;
return `${RELEASES}/tag/v${v.nums.join(".")}${v.pre ? "-" + v.pre : ""}`;
}
// 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 version = String(m.version).replace(/^v/i, "");
const base = `https://github.com/${REPO}/releases/download/v${version}/`;
return { version, notes: String(m.notes || "").slice(0, 4000),
page: releasePage(version),
// 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");
const version = String(r.tag_name || "").replace(/^v/i, "");
return { version, notes: String(r.body || "").slice(0, 4000),
page: releasePage(version),
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 };
-24
View File
@@ -1,24 +0,0 @@
<!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>
-32
View File
@@ -1,32 +0,0 @@
<!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>
-9
View File
@@ -1,9 +0,0 @@
<!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>
-677
View File
@@ -1,677 +0,0 @@
{
"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"
}
]
}
-690
View File
@@ -1,690 +0,0 @@
{
"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"
}
]
}
-682
View File
@@ -1,682 +0,0 @@
{
"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"
}
]
}
-682
View File
@@ -1,682 +0,0 @@
{
"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"
}
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,984 +0,0 @@
{
"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"
}
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,311 +0,0 @@
{
"label": "pc-agent-frame-arm64-hairpin",
"date": "2026-09-28T22:36:19",
"commit": "c5fc552+dirty",
"config": {
"quality": "balanced",
"mode": "separate",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"usb": false,
"ssh_opts": [],
"encoder": "",
"duration_s": 10.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
"source": "",
"pc_host": true,
"headset": "0:03.056203 [Info] - 1 - leaving standby",
"scenarios": [
{
"scenario": "test",
"frames_sent": 351,
"frames_drawn": 351,
"frames_shown": 324,
"duration_s": 10.1,
"stages_ms": {
"capture": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.5,
"n": 351
},
"queue": {
"p50": 0.1,
"p95": 0.3,
"p99": 0.4,
"n": 351
},
"encode": {
"p50": 2.0,
"p95": 2.9,
"p99": 3.5,
"n": 351
},
"socket": {
"p50": 0.1,
"p95": 0.4,
"p99": 0.6,
"n": 351
},
"network": {
"p50": 11.6,
"p95": 75.7,
"p99": 130.1,
"n": 351
},
"decode": {
"p50": 0.7,
"p95": 2.1,
"p99": 4.1,
"n": 351
},
"draw": {
"p50": 0.4,
"p95": 0.8,
"p99": 1.1,
"n": 351
},
"present": {
"p50": 0.8,
"p95": 18.1,
"p99": 24.9,
"n": 324
},
"content": {
"p50": 15.1,
"p95": 80.3,
"p99": 134.2,
"n": 351
},
"content_shown": {
"p50": 21.0,
"p95": 71.8,
"p99": 146.3,
"n": 324
}
},
"fps": 34.8,
"fps_shown": 32.0,
"late_pct": 53.41,
"stall_max": 226.8,
"stalls_over_100ms": 7,
"mbps": 0.26,
"keyframes": 6,
"size": "1280x720",
"captured": 607,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 43.3,
"p95": 98.4,
"p99": 110.1,
"n": 18
},
"input_shown_ms": {
"p50": 54.7,
"p95": 98.9,
"p99": 114.0,
"n": 18
},
"input_parts_ms": {
"uplink": {
"p50": 17.1,
"p95": 48.3,
"p99": 53.1,
"n": 18
},
"mac": {
"p50": 12.5,
"p95": 32.9,
"p99": 35.7,
"n": 18
},
"back": {
"p50": 16.8,
"p95": 50.0,
"p99": 55.9,
"n": 18
}
},
"inputs": {
"asked": 18,
"sent": 18,
"seen": 18
},
"timeline": [
{
"t": 0,
"fps": 38,
"mbps": 0.3,
"content_p50": 14.8,
"content_p95": 25.3,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 33,
"mbps": 0.24,
"content_p50": 15.6,
"content_p95": 103.5,
"bitrate": 3845448,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 39,
"mbps": 0.28,
"content_p50": 15.6,
"content_p95": 29.9,
"bitrate": 5283790,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 25,
"mbps": 0.21,
"content_p50": 17.8,
"content_p95": 175.9,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 34,
"mbps": 0.22,
"content_p50": 15.4,
"content_p95": 69.8,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 38,
"mbps": 0.29,
"content_p50": 17.7,
"content_p95": 68.7,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 38,
"mbps": 0.26,
"content_p50": 15.0,
"content_p95": 18.2,
"bitrate": 3091280,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 30,
"mbps": 0.22,
"content_p50": 19.2,
"content_p95": 111.0,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 35,
"mbps": 0.26,
"content_p50": 13.4,
"content_p95": 102.6,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 38,
"mbps": 0.28,
"content_p50": 12.3,
"content_p95": 20.9,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 3,
"mbps": 0.02,
"content_p50": 20.9,
"content_p95": 24.7,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 15.1,
"content_p95": 80.3,
"input_p50": 43.3,
"input_p95": 98.4,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "acceptable",
"input_p50": "local",
"input_p95": "acceptable",
"fps": "bad",
"late_pct": "bad",
"stall_max": "acceptable"
},
"controller": {
"target": 3442081,
"ceiling": 5529600,
"tier": 0,
"fps": 60,
"scale": 1.0,
"baseRtt": 13.334,
"inFlight": 1,
"slack": 71.738,
"adapt": true
},
"events": [
{
"t": 3.77,
"e": "target 2764800 bit/s; tier 0"
},
{
"t": 7.42,
"e": "target 1725204 bit/s; tier 0"
}
],
"source_fps": 60.1,
"cpu": {
"host": "not sampled"
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
"show_s": 1.88,
"panel": "valve.steam.desktopgame.2001932457",
"src": "test",
"route": "network"
}
],
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
}
@@ -1,302 +0,0 @@
{
"label": "pc-agent-frame-arm64-final",
"date": "2026-09-28T22:55:27",
"commit": "cd20243",
"config": {
"quality": "balanced",
"mode": "native",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"usb": false,
"ssh_opts": [],
"encoder": "",
"duration_s": 10.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
"source": "",
"pc_host": true,
"headset": "0:03.056203 [Info] - 1 - leaving standby",
"scenarios": [
{
"scenario": "test",
"frames_sent": 400,
"frames_drawn": 400,
"frames_shown": 339,
"duration_s": 10.2,
"stages_ms": {
"capture": {
"p50": 0.1,
"p95": 0.1,
"p99": 0.2,
"n": 400
},
"queue": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.4,
"n": 400
},
"encode": {
"p50": 2.0,
"p95": 2.7,
"p99": 3.1,
"n": 400
},
"socket": {
"p50": 0.1,
"p95": 0.3,
"p99": 0.4,
"n": 400
},
"network": {
"p50": 11.8,
"p95": 25.2,
"p99": 37.8,
"n": 400
},
"decode": {
"p50": 0.7,
"p95": 1.5,
"p99": 2.0,
"n": 400
},
"draw": {
"p50": 0.3,
"p95": 0.7,
"p99": 1.0,
"n": 400
},
"present": {
"p50": 0.8,
"p95": 19.3,
"p99": 26.8,
"n": 339
},
"content": {
"p50": 15.1,
"p95": 28.6,
"p99": 41.3,
"n": 400
},
"content_shown": {
"p50": 17.6,
"p95": 39.0,
"p99": 47.2,
"n": 339
}
},
"fps": 39.3,
"fps_shown": 33.4,
"late_pct": 50.25,
"stall_max": 69.3,
"stalls_over_100ms": 0,
"mbps": 0.35,
"keyframes": 6,
"size": "1280x720",
"captured": 612,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 76.6,
"p95": 98.2,
"p99": 127.8,
"n": 19
},
"input_shown_ms": {
"p50": 80.5,
"p95": 107.8,
"p99": 128.1,
"n": 16
},
"input_parts_ms": {
"uplink": {
"p50": 36.5,
"p95": 57.6,
"p99": 83.2,
"n": 19
},
"mac": {
"p50": 14.6,
"p95": 31.3,
"p99": 31.7,
"n": 19
},
"back": {
"p50": 15.1,
"p95": 32.8,
"p99": 36.2,
"n": 19
}
},
"inputs": {
"asked": 19,
"sent": 19,
"seen": 19
},
"timeline": [
{
"t": 0,
"fps": 40,
"mbps": 0.34,
"content_p50": 11.7,
"content_p95": 27.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 39,
"mbps": 0.37,
"content_p50": 11.9,
"content_p95": 28.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 41,
"mbps": 0.35,
"content_p50": 11.4,
"content_p95": 16.6,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 39,
"mbps": 0.37,
"content_p50": 12.7,
"content_p95": 28.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 38,
"mbps": 0.33,
"content_p50": 15.3,
"content_p95": 30.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 39,
"mbps": 0.37,
"content_p50": 16.7,
"content_p95": 28.5,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 41,
"mbps": 0.38,
"content_p50": 15.9,
"content_p95": 29.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 41,
"mbps": 0.31,
"content_p50": 15.6,
"content_p95": 28.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 38,
"mbps": 0.38,
"content_p50": 15.5,
"content_p95": 25.9,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 38,
"mbps": 0.29,
"content_p50": 16.7,
"content_p95": 28.8,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 6,
"mbps": 0.03,
"content_p50": 17.6,
"content_p95": 23.1,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 15.1,
"content_p95": 28.6,
"input_p50": 76.6,
"input_p95": 98.2,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "local",
"input_p50": "acceptable",
"input_p95": "acceptable",
"fps": "bad",
"late_pct": "bad",
"stall_max": "local"
},
"controller": {
"target": 5529600,
"ceiling": 5529600,
"tier": 0,
"fps": 60,
"scale": 1.0,
"baseRtt": 12.332,
"inFlight": 0,
"slack": 74.12,
"adapt": true
},
"events": [],
"source_fps": 60.0,
"cpu": {
"host": "not sampled"
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
"show_s": 1.75,
"panel": "valve.steam.desktopgame.2001932457",
"src": "test",
"route": "network"
}
],
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
}
-2
View File
@@ -1,2 +0,0 @@
.lakebed/
.env.lakebed.server
-88
View File
@@ -1,88 +0,0 @@
# 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.
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
-81
View File
@@ -1,81 +0,0 @@
# 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.
-9
View File
@@ -1,9 +0,0 @@
// 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>
);
}
-11
View File
@@ -1,11 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 658 B

-3
View File
@@ -1,3 +0,0 @@
{
"deployId": "dep_dDmcsosVSiFirpW6"
}
-101
View File
@@ -1,101 +0,0 @@
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"))
}
});
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env python3
"""Build the common controller for the Python PC host (a C11 compiler needed)."""
import os
from pathlib import Path
import subprocess
import sys
root = Path(__file__).resolve().parent
suffix = '.dll' if sys.platform == 'win32' else '.dylib' if sys.platform == 'darwin' else '.so'
out = root / ('controller' + suffix)
if sys.platform == 'win32' and os.environ.get('CC', 'cl') == 'cl':
cmd = ['cl', '/nologo', '/O2', '/LD', '/std:c11', str(root / 'controller.c'), '/link', '/OUT:' + str(out)]
else:
cmd = [os.environ.get('CC', 'cc'), '-O2', '-std=c11', '-shared', '-fPIC', str(root / 'controller.c'), '-o', str(out)]
subprocess.run(cmd, cwd=root, check=True)
print(out)
-126
View File
@@ -1,126 +0,0 @@
#!/usr/bin/env python3
"""Build our native PC adapter and bundle its ordinary shared libraries.
Run on the target architecture after installing GStreamer development packages.
No GStreamer executable or desktop streaming application is shipped or invoked.
Linux libc, display/GPU drivers and the portal service remain platform pieces.
x264 is GPL, so the bundle the Windows installer ships leaves it out: Windows
encodes with Media Foundation. --with-x264 adds it for CI's headless protocol
tests, which have no GPU or desktop to encode with.
"""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parent
def output(*args):
return subprocess.check_output(args, text=True).strip()
def build(destination, with_x264=False):
win = sys.platform == 'win32'
destination.mkdir(parents=True, exist_ok=True)
modules = ['gstreamer-1.0', 'gstreamer-app-1.0', 'gstreamer-video-1.0']
if not win:
modules += ['gio-unix-2.0']
import shlex
flags = shlex.split(output('pkg-config', '--cflags', '--libs', *modules))
lib = destination / ('pc-host.dll' if win else 'pc-host.so')
cmd = [os.environ.get('CC', 'gcc' if win else 'cc'), '-std=c11', '-O2', '-shared', '-Wall', '-Wextra']
if not win:
cmd += ['-fPIC', '-Wl,-rpath,$ORIGIN/lib']
subprocess.run(cmd + [str(ROOT / f) for f in ('controller.c', 'capture.c', 'portal.c')] + flags + ['-o', str(lib)], check=True)
prefix = Path(output('pkg-config', '--variable=prefix', 'gstreamer-1.0'))
plugin_dir = Path(output('pkg-config', '--variable=pluginsdir', 'gstreamer-1.0'))
plugins_out, libs_out = destination / 'lib' / 'gstreamer-1.0', destination / ('bin' if win else 'lib')
plugins_out.mkdir(parents=True, exist_ok=True)
libs_out.mkdir(parents=True, exist_ok=True)
names = ['coreelements', 'videotestsrc', 'videoconvertscale', 'app', 'jpeg', 'videoparsersbad']
if with_x264:
names.append('x264')
names += ['d3d11', 'mediafoundation'] if win else ['pipewire', 'va']
pending = [lib]
for name in names:
matches = list(plugin_dir.glob('*gst' + name + ('.dll' if win else '.so')))
if not matches:
raise SystemExit('Missing GStreamer library: ' + name)
for source in matches:
target = plugins_out / source.name
shutil.copy2(source, target)
pending.append(source)
if not win:
# PipeWire dynamically loads support and protocol modules. ldd cannot
# discover those; bundle them with a private client-only config instead
# of accidentally loading a different distro's client modules/config.
libdir = plugin_dir.parent
for relative in ('pipewire-0.3/libpipewire-module-protocol-native.so',
'pipewire-0.3/libpipewire-module-client-node.so',
'pipewire-0.3/libpipewire-module-adapter.so',
'spa-0.2/support/libspa-support.so',
'spa-0.2/support/libspa-dbus.so',
'spa-0.2/videoconvert/libspa-videoconvert.so'):
source = libdir / relative
if not source.is_file():
raise SystemExit('Missing PipeWire runtime library: ' + str(source))
target = destination / 'lib' / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
pending.append(source)
config = destination / 'share' / 'pipewire'
config.mkdir(parents=True, exist_ok=True)
(config / 'client.conf').write_text(
'context.spa-libs = { support.* = support/libspa-support video.* = videoconvert/libspa-videoconvert }\n'
'context.modules = [\n'
' { name = libpipewire-module-protocol-native }\n'
' { name = libpipewire-module-client-node }\n'
' { name = libpipewire-module-adapter }\n'
']\n')
copied = set()
while pending:
binary = pending.pop()
if win:
imported = re.findall(r'DLL Name:\s*(\S+)', output('objdump', '-p', str(binary)))
dependencies = [prefix / 'bin' / name for name in imported]
else:
dependencies = [Path(p) for p in re.findall(r'=>\s+(/\S+)', output('ldd', str(binary)))]
for dep in dependencies:
if not dep.is_file() or dep.name in copied:
continue
if not win and re.match(r'lib(c|m|dl|rt|pthread|resolv)\.so', dep.name):
continue
shutil.copy2(dep, libs_out / dep.name)
copied.add(dep.name)
pending.append(dep)
# License texts and package provenance accompany the dynamically linked
# libraries. Distribution builders keep the upstream package/source URLs.
licenses = destination / 'licenses'
licenses.mkdir(exist_ok=True)
if win:
source = prefix / 'share' / 'licenses'
if source.exists():
shutil.copytree(source, licenses, dirs_exist_ok=True)
packages = output('pacman', '-Q') if shutil.which('pacman') else 'See MSYS2 build log'
else:
packages = output('dpkg-query', '-W', '-f=${Package} ${Version}\n') if shutil.which('dpkg-query') else ''
# Debian copyright files contain licenses and source homepage details.
for path in Path('/usr/share/doc').glob('*/copyright'):
shutil.copy2(path, licenses / (path.parent.name + '.copyright'))
(destination / 'libraries.json').write_text(json.dumps(dict(gstreamer=output('pkg-config', '--modversion', 'gstreamer-1.0'),
plugins=names, libraries=sorted(copied), packages=packages), indent=2))
print(lib)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--out', type=Path, default=ROOT / 'bundle')
parser.add_argument('--with-x264', action='store_true', help='add the GPL x264 encoder (CI tests only, never shipped)')
args = parser.parse_args()
build(args.out.resolve(), args.with_x264)
-134
View File
@@ -1,134 +0,0 @@
/* Frame Control's capture/encode adapter. GStreamer is a bundled library;
* capture uses WGC on Windows and the consented PipeWire fd on Linux. */
#include "controller.h"
#include <gst/gst.h>
#include <gst/app/gstappsink.h>
#include <gst/video/video-event.h>
#include <string.h>
typedef int (*Gate)(int stage, int64_t pts, int64_t capture, int64_t arrived);
typedef struct {
GstElement *pipeline, *encoder, *sink, *raw_queue;
Gate gate;
GstSample *sample;
GstMapInfo map;
int mapped;
char error[512];
} Capture;
typedef struct { const unsigned char *data; int size, key, width, height; int64_t pts; } Encoded;
FC_API int64_t fc_now(void) {return g_get_monotonic_time();}
FC_API void fc_gst_init(void) {gst_init(NULL,NULL);}
FC_API int fc_has_element(const char *name) {
GstElementFactory *f=gst_element_factory_find(name);
if(!f)return 0;gst_object_unref(f);return 1;
}
FC_API int fc_has_property(const char *element, const char *property) {
/* Probe the same initialized GStreamer instance that capture uses. The
* bundled MSYS2 plugin can omit optional WGC properties entirely. */
GstElement *source=gst_element_factory_make(element,NULL);
if(!source)return 0;
int found=g_object_class_find_property(G_OBJECT_GET_CLASS(source),property)!=NULL;
gst_object_unref(source);
return found;
}
static GstPadProbeReturn probe(GstPad *pad,GstPadProbeInfo *info,gpointer data) {
Capture *c=data; GstBuffer *b=GST_PAD_PROBE_INFO_BUFFER(info);
if(!b)return GST_PAD_PROBE_OK;
int64_t now=fc_now(), cap=now;
GstClock *clock=gst_element_get_clock(c->pipeline);
if(clock && GST_BUFFER_PTS_IS_VALID(b)) {
GstClockTime current=gst_clock_get_time(clock),origin=gst_element_get_base_time(c->pipeline);
if(current>=origin && current-origin>=GST_BUFFER_PTS(b))
cap-= (int64_t)((current-origin-GST_BUFFER_PTS(b))/1000);
}
if(clock)gst_object_unref(clock);
int stage=GPOINTER_TO_INT(g_object_get_data(G_OBJECT(pad),"stage"));
if(stage==1) return c->gate(stage,(int64_t)GST_BUFFER_PTS(b),cap,now)>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
int phase=0;
for(;;) {
/* While congested, prefer a newer raw picture queued upstream. If
* nothing changed, retain this last picture until the gate opens;
* an idle window must not stay stale after a dropped final update. */
guint queued=0;
if(phase && c->raw_queue)g_object_get(c->raw_queue,"current-level-buffers",&queued,NULL);
if(queued)return GST_PAD_PROBE_DROP;
int decision=c->gate(phase,(int64_t)GST_BUFFER_PTS(b),cap,now);
if(decision)return decision>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
phase=2;g_usleep(2000);
}
}
FC_API Capture *fc_capture_open(const char *pipeline,Gate gate,char *error,int capacity) {
GError *e=NULL;Capture *c=g_new0(Capture,1);c->gate=gate;
c->pipeline=gst_parse_launch(pipeline,&e);
if(e || !c->pipeline) {
g_strlcpy(error,e?e->message:"No pipeline",capacity);
if(e)g_error_free(e);if(c->pipeline)gst_object_unref(c->pipeline);g_free(c);return NULL;
}
c->encoder=gst_bin_get_by_name(GST_BIN(c->pipeline),"enc");
c->sink=gst_bin_get_by_name(GST_BIN(c->pipeline),"out");
GstElement *raw=gst_bin_get_by_name(GST_BIN(c->pipeline),"gate");
if(!c->encoder || !c->sink || !raw) {
g_strlcpy(error,"Pipeline is missing enc, out or gate",capacity);
if(raw)gst_object_unref(raw);if(c->encoder)gst_object_unref(c->encoder);
if(c->sink)gst_object_unref(c->sink);gst_object_unref(c->pipeline);g_free(c);return NULL;
}
c->raw_queue=gst_bin_get_by_name(GST_BIN(c->pipeline),"raw_queue");
GstPad *p=gst_element_get_static_pad(raw,"src");
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(0));
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);gst_object_unref(raw);
p=gst_element_get_static_pad(c->encoder,"sink");
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(1));
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);
gst_element_set_state(c->pipeline,GST_STATE_PLAYING);
return c;
}
FC_API int fc_capture_pull(Capture *c,Encoded *out) {
if(c->mapped) {gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);c->mapped=0;}
if(c->sample){gst_sample_unref(c->sample);c->sample=NULL;}
GstBus *bus=gst_element_get_bus(c->pipeline);
GstMessage *m=gst_bus_pop_filtered(bus,GST_MESSAGE_ERROR);gst_object_unref(bus);
if(m) {
if(GST_MESSAGE_TYPE(m)==GST_MESSAGE_ERROR) {
GError *e=NULL;char *debug=NULL;gst_message_parse_error(m,&e,&debug);
g_strlcpy(c->error,e->message,sizeof(c->error));g_error_free(e);g_free(debug);
} else g_strlcpy(c->error,"The capture source closed",sizeof(c->error));
gst_message_unref(m);return -1;
}
c->sample=gst_app_sink_try_pull_sample(GST_APP_SINK(c->sink),100*GST_MSECOND);
if(!c->sample) {
if(gst_app_sink_is_eos(GST_APP_SINK(c->sink))) {
g_strlcpy(c->error,"The capture source closed",sizeof(c->error));return -1;
}
return 0;
}
GstBuffer *b=gst_sample_get_buffer(c->sample);
if(!gst_buffer_map(b,&c->map,GST_MAP_READ))return 0;
c->mapped=1;out->data=c->map.data;out->size=(int)c->map.size;
out->key=!GST_BUFFER_FLAG_IS_SET(b,GST_BUFFER_FLAG_DELTA_UNIT);out->pts=(int64_t)GST_BUFFER_PTS(b);
const GstStructure *s=gst_caps_get_structure(gst_sample_get_caps(c->sample),0);
gst_structure_get_int(s,"width",&out->width);gst_structure_get_int(s,"height",&out->height);return 1;
}
FC_API const char *fc_capture_error(Capture *c) {return c->error;}
FC_API void fc_capture_bitrate(Capture *c,int bps) {
g_object_set(c->encoder,"bitrate",(guint)MAX(1,bps/1000),NULL);
}
FC_API void fc_capture_test(Capture *c,uint32_t input) {
GstElement *source=gst_bin_get_by_name(GST_BIN(c->pipeline),"source");
if(source) {
/* Each benchmark click visibly changes the ball, before injection is
* timestamped. This is a response, not merely a protocol input echo. */
guint color=0xff000000u | ((input*2654435761u)&0x00ffffffu);
g_object_set(source,"foreground-color",color,NULL);gst_object_unref(source);
}
}
FC_API void fc_capture_key(Capture *c) {
GstPad *p=gst_element_get_static_pad(c->encoder,"src");
gst_pad_send_event(p,gst_video_event_new_upstream_force_key_unit(GST_CLOCK_TIME_NONE,TRUE,0));gst_object_unref(p);
}
FC_API void fc_capture_close(Capture *c) {
gst_element_set_state(c->pipeline,GST_STATE_NULL);
if(c->mapped)gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);
if(c->sample)gst_sample_unref(c->sample);
if(c->raw_queue)gst_object_unref(c->raw_queue);
gst_object_unref(c->sink);gst_object_unref(c->encoder);gst_object_unref(c->pipeline);g_free(c);
}
-130
View File
@@ -1,130 +0,0 @@
/* A C port of the Mac agent's RateController (mac/frame-mac-view/Sources/
* Controller.swift) for the Windows host. Same gate, demand protection, bitrate
* recovery and tier hysteresis; bounded sample buffers. The Mac agent keeps its
* Swift original, so a change to either must be made to both:
* tests/test_pc_controller.py checks this one against the Swift trace. */
#include "controller.h"
#include <stdlib.h>
#include <string.h>
#define CAP 1024
#define MIN(a,b) ((a)<(b)?(a):(b))
#define MAX(a,b) ((a)>(b)?(a):(b))
typedef struct { int64_t t, v; uint32_t seq; } Sample;
typedef struct { Sample a[CAP]; int n; } Samples;
struct FCController {
int max_fps, enabled, ceiling, target, tier, frame_bytes, held, signal, saw_ack;
int64_t decrease, increase, below, above, slack;
Samples flight, rtts, acked, sent, captures;
};
static const int fps[] = {60,45,30,30,30}, scale[] = {100,100,100,75,50};
static const double floors[] = {.45,.28,.16,.08,0};
static void remove_first(Samples *s, int n) {
s->n -= n; memmove(s->a, s->a+n, (size_t)s->n*sizeof(Sample));
}
static void add(Samples *s, Sample v, int cap) {
if (s->n >= cap) remove_first(s, 1);
s->a[s->n++] = v;
}
static void expire(Samples *s, int64_t oldest) {
int n=0; while(n<s->n && s->a[n].t<oldest) n++;
remove_first(s,n);
}
static int64_t base(FCController *c) {
int64_t v=0;
for(int i=0;i<c->rtts.n;i++) if(!i || c->rtts.a[i].v<v) v=c->rtts.a[i].v;
return v;
}
static int compare(const void *a, const void *b) {
int64_t x=*(const int64_t*)a, y=*(const int64_t*)b;
return (x>y)-(x<y);
}
static int64_t quantile(Samples *s,int64_t since,int numerator,int denominator) {
int64_t a[CAP]; int n=0;
for(int i=0;i<s->n;i++) if(s->a[i].t>since) a[n++]=s->a[i].v;
if(!n) return 0;
qsort(a,(size_t)n,sizeof(int64_t),compare);
return a[n*numerator/denominator];
}
static int rate(Samples *s) {
int64_t total=0; for(int i=0;i<s->n;i++) total+=s->a[i].v;
return (int)MIN(total*16,2147483647);
}
FCController *fc_new(int max_fps,int enabled) {
FCController *c=calloc(1,sizeof(*c));
if(c) {c->max_fps=MAX(1,max_fps);c->enabled=enabled;c->slack=40000;}
return c;
}
void fc_free(FCController *c) {free(c);}
void fc_ceiling(FCController *c,int bps) {
if(!c->target || c->target>bps) c->target=bps;
c->ceiling=bps;
}
int fc_gate(FCController *c,int64_t now,int counts) {
if(!c->enabled || !c->saw_ack) return 1;
expire(&c->flight,now-2000000);
if(!c->flight.n) return 1;
int64_t interval=1000000/MIN(c->max_fps,fps[c->tier]);
int window=MAX(3,(int)((base(c)+c->slack)/interval)+1);
if(c->flight.n<window && now-c->flight.a[0].t<=base(c)+c->slack) return 1;
if(counts) c->held++;
return 0;
}
void fc_capture(FCController *c,int64_t now) {add(&c->captures,(Sample){now,0,0},256);}
void fc_sent(FCController *c,uint32_t seq,int bytes,int64_t now) {
Sample s={now,bytes,seq};add(&c->flight,s,512);add(&c->sent,s,CAP);
}
int fc_ack(FCController *c,uint32_t seq,int64_t now) {
c->saw_ack=1;
for(int i=0;i<c->flight.n;i++) if(c->flight.a[i].seq==seq) {
Sample s=c->flight.a[i];remove_first(&c->flight,i+1);
add(&c->rtts,(Sample){now,now-s.t,0},CAP);
add(&c->acked,(Sample){now,s.v,0},CAP);return 1;
}
return 0;
}
int fc_update(FCController *c,int64_t now) {
expire(&c->rtts,now-10000000);expire(&c->acked,now-500000);
expire(&c->sent,now-500000);expire(&c->flight,now-2000000);
expire(&c->captures,now-1000000);
if(!c->enabled || c->ceiling<=0) return 0;
int64_t baseline=base(c), spread=quantile(&c->rtts,now-2000000,9,10);
int64_t jitter=spread ? spread-baseline : 0;
c->slack=1000000/MIN(c->max_fps,fps[c->tier])+MIN(MAX(jitter*3/2,25000),80000);
int64_t recent=quantile(&c->rtts,now-300000,1,2);
int64_t queue=recent ? recent-baseline : 0;
int64_t age=c->flight.n ? now-c->flight.a[0].t : 0;
int delivered=rate(&c->acked),sending=rate(&c->sent);
if(c->sent.n) c->frame_bytes=sending/16/c->sent.n;
int demand=(int)MIN((int64_t)MIN(c->captures.n,MIN(c->max_fps,fps[c->tier]))*c->frame_bytes*8,2147483647);
int signal=(sending>=c->target/2 && queue>40000)||c->held>=3||age>baseline+100000;
int congested=signal && c->signal;c->signal=signal;c->held=0;
if(congested && now-c->decrease>300000) {
int next=MAX(300000,MIN((int64_t)c->target*4/5,MAX((int64_t)delivered*9/10,c->target/2)));
if(demand>0 && (int64_t)demand*2<=(int64_t)c->target*5/4) next=MAX(next,MIN(c->target,(int64_t)demand*2));
c->target=next;c->decrease=now;
} else if(!congested && now-c->decrease>1000000 && now-c->increase>250000 && c->target<c->ceiling &&
(sending>(int64_t)c->target*6/10 || now-c->decrease>3000000)) {
c->target=MIN(c->ceiling,(int64_t)(c->target*1.1)+50000);c->increase=now;
}
double share=(double)c->target/MAX(c->ceiling,1);
if(c->tier<4 && share<floors[c->tier] && sending>=(int64_t)c->target*7/10) {
if(!c->below)c->below=now;
if(now-c->below>500000) {
for(c->tier=0;c->tier<4 && share<floors[c->tier];c->tier++) {}
c->below=0;
}
} else c->below=0;
if(c->tier>0 && share>floors[c->tier-1]*1.25) {
if(!c->above)c->above=now;
if(now-c->above>2000000) {c->tier--;c->above=0;}
} else c->above=0;
return c->target;
}
int64_t fc_value(FCController *c,int field) {
switch(field) {
case 0:return c->target;case 1:return c->ceiling;case 2:return c->tier;
case 3:return MIN(c->max_fps,fps[c->tier]);case 4:return scale[c->tier];
case 5:return base(c);case 6:return c->flight.n;case 7:return c->slack;
default:return 0;
}
}
-29
View File
@@ -1,29 +0,0 @@
/* Shared latency controller. Times are monotonic host microseconds.
* Callers serialize access. Capture is gated BEFORE encoding: dropping an
* encoded inter frame would break the decoder's reference chain. */
#ifndef FRAME_CONTROLLER_H
#define FRAME_CONTROLLER_H
#include <stdint.h>
#ifdef _WIN32
#define FC_API __declspec(dllexport)
#else
#define FC_API
#endif
#ifdef __cplusplus
extern "C" {
#endif
typedef struct FCController FCController;
FC_API FCController *fc_new(int fps, int enabled);
FC_API void fc_free(FCController *c);
FC_API void fc_ceiling(FCController *c, int bps);
FC_API int fc_gate(FCController *c, int64_t now, int counts);
FC_API void fc_capture(FCController *c, int64_t now);
FC_API void fc_sent(FCController *c, uint32_t seq, int bytes, int64_t now);
FC_API int fc_ack(FCController *c, uint32_t seq, int64_t now);
FC_API int fc_update(FCController *c, int64_t now);
/* target, ceiling, tier, fps, scale percent, baseline us, in flight, slack us */
FC_API int64_t fc_value(FCController *c, int field);
#ifdef __cplusplus
}
#endif
#endif
-147
View File
@@ -1,147 +0,0 @@
/* xdg-desktop-portal RemoteDesktop + ScreenCast, one consented session per
* panel. PipeWire receives the portal fd, never the unrestricted daemon fd.
* Input uses only the devices granted in Start's response. */
#ifndef _WIN32
#include "controller.h"
#include <gio/gio.h>
#include <gio/gunixfdlist.h>
#include <unistd.h>
#include <string.h>
#define BUS "org.freedesktop.portal.Desktop"
#define PATH "/org/freedesktop/portal/desktop"
#define RD "org.freedesktop.portal.RemoteDesktop"
#define SC "org.freedesktop.portal.ScreenCast"
typedef struct {
GDBusConnection *bus;
GMainContext *context;
char *session;
int fd;
unsigned node, devices;
int width,height;
} Portal;
typedef struct {GVariant *result;int done;unsigned code;} Response;
static void response(GDBusConnection *c,const char *sender,const char *path,const char *iface,
const char *signal,GVariant *params,gpointer data) {
(void)c;(void)sender;(void)path;(void)iface;(void)signal;
Response *r=data;g_variant_get(params,"(u@a{sv})",&r->code,&r->result);r->done=1;
}
static void option(GVariantBuilder *b,const char *key,GVariant *v) {g_variant_builder_add(b,"{sv}",key,v);}
static GVariant *request(Portal *p,const char *iface,const char *method,GVariant *args,
const char *token,char *error,int capacity) {
char *sender=g_strdup(g_dbus_connection_get_unique_name(p->bus)+1);
for(char *c=sender;*c;c++)if(*c=='.')*c='_';
char *path=g_strdup_printf(PATH "/request/%s/%s",sender,token);g_free(sender);
Response r={0};GError *e=NULL;
guint sub=g_dbus_connection_signal_subscribe(p->bus,BUS,"org.freedesktop.portal.Request","Response",
path,NULL,G_DBUS_SIGNAL_FLAGS_NONE,response,&r,NULL);
GVariant *reply=g_dbus_connection_call_sync(p->bus,BUS,PATH,iface,method,args,G_VARIANT_TYPE("(o)"),
G_DBUS_CALL_FLAGS_NONE,10000,NULL,&e);
if(reply)g_variant_unref(reply);
int64_t deadline=g_get_monotonic_time()+120000000;
while(!e && !r.done && g_get_monotonic_time()<deadline) {
while(g_main_context_iteration(p->context,FALSE)) {}
g_usleep(10000);
}
if(!r.done) {
GVariant *closed=g_dbus_connection_call_sync(p->bus,BUS,path,"org.freedesktop.portal.Request","Close",
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
if(closed)g_variant_unref(closed);
}
g_dbus_connection_signal_unsubscribe(p->bus,sub);g_free(path);
if(e || !r.done || r.code) {
g_strlcpy(error,e?e->message:!r.done?"Screen sharing request timed out":"Screen sharing was cancelled or refused",capacity);
if(e)g_error_free(e);if(r.result)g_variant_unref(r.result);return NULL;
}
return r.result;
}
FC_API void fc_portal_close(Portal *p) {
if(!p)return;
if(p->session && p->bus) {
GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,p->session,"org.freedesktop.portal.Session","Close",
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
if(r)g_variant_unref(r);
}
if(p->fd>=0){close(p->fd);p->fd=-1;}
g_free(p->session);if(p->bus)g_object_unref(p->bus);
if(p->context)g_main_context_unref(p->context);g_free(p);
}
/* Each pipeline gets a fresh restricted PipeWire connection. A dup of a
* previously consumed protocol socket is not a new connection.
* Ownership: the Portal owns p->fd for its whole life and is its only closer.
* pipewiresrc never takes the fd it is given: its core connects with
* pw_context_connect_fd(ctx, fcntl(fd, F_DUPFD_CLOEXEC, 3), ...) and that
* duplicate is what PipeWire closes on teardown (src/gst/gstpipewirecore.c,
* unchanged from 0.3.19 through 1.x). So closing p->fd here is not a double
* close; not closing it would leak one socket per pipeline reopen. The
* caller closes the previous pipeline before asking for a new fd. */
FC_API int fc_portal_refresh(Portal *p,char *error,int capacity) {
GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
GUnixFDList *fds=NULL;GError *e=NULL;
GVariant *r=g_dbus_connection_call_with_unix_fd_list_sync(p->bus,BUS,PATH,SC,"OpenPipeWireRemote",
g_variant_new("(oa{sv})",p->session,&b),G_VARIANT_TYPE("(h)"),G_DBUS_CALL_FLAGS_NONE,10000,NULL,&fds,NULL,&e);
if(!r){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
int handle;g_variant_get(r,"(h)",&handle);g_variant_unref(r);
int fd=g_unix_fd_list_get(fds,handle,&e);g_object_unref(fds);
if(fd<0){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
if(p->fd>=0)close(p->fd);
p->fd=fd;return fd;
}
FC_API Portal *fc_portal_select(char *error,int capacity) {
Portal *p=g_new0(Portal,1);p->fd=-1;p->context=g_main_context_new();
g_main_context_push_thread_default(p->context);
GError *e=NULL;GVariant *r=NULL;
p->bus=g_bus_get_sync(G_BUS_TYPE_SESSION,NULL,&e);
if(!p->bus) {g_strlcpy(error,e->message,capacity);g_error_free(e);goto fail;}
GVariantBuilder b;char token[64],session[64];
g_snprintf(session,sizeof(session),"fc_%u",g_random_int());
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());
g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"session_handle_token",g_variant_new_string(session));
r=request(p,RD,"CreateSession",g_variant_new("(a{sv})",&b),token,error,capacity);if(!r)goto fail;
g_variant_lookup(r,"session_handle","s",&p->session);g_variant_unref(r);r=NULL;
if(!p->session){g_strlcpy(error,"Portal did not return a session",capacity);goto fail;}
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
r=request(p,RD,"SelectDevices",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
option(&b,"multiple",g_variant_new_boolean(FALSE));option(&b,"cursor_mode",g_variant_new_uint32(2));
r=request(p,SC,"SelectSources",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));
r=request(p,RD,"Start",g_variant_new("(osa{sv})",p->session,"",&b),token,error,capacity);if(!r)goto fail;
g_variant_lookup(r,"devices","u",&p->devices);
GVariant *streams=g_variant_lookup_value(r,"streams",G_VARIANT_TYPE("a(ua{sv})"));
if(streams && g_variant_n_children(streams)>0) {
GVariant *entry=g_variant_get_child_value(streams,0),*props=NULL;
g_variant_get(entry,"(u@a{sv})",&p->node,&props);
g_variant_lookup(props,"size","(ii)",&p->width,&p->height);
g_variant_unref(props);g_variant_unref(entry);
}
if(streams)g_variant_unref(streams);g_variant_unref(r);r=NULL;
if(!p->node || p->width<=0 || p->height<=0) {g_strlcpy(error,"Portal returned no stream size",capacity);goto fail;}
if(fc_portal_refresh(p,error,capacity)<0)goto fail;
g_main_context_pop_thread_default(p->context);return p;
fail:
g_main_context_pop_thread_default(p->context);fc_portal_close(p);return NULL;
}
FC_API int fc_portal_value(Portal *p,int field) {
switch(field){case 0:return p->fd;case 1:return (int)p->node;case 2:return p->width;
case 3:return p->height;case 4:return (int)p->devices;default:return 0;}
}
FC_API int fc_portal_input(Portal *p,int type,double x,double y,int code,int down) {
const char *method=NULL;GVariant *args=NULL;GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
if(type==0 && (p->devices&2)) {
method="NotifyPointerMotionAbsolute";args=g_variant_new("(oa{sv}udd)",p->session,&b,p->node,x,y);
} else if(type==1 && (p->devices&2)) {
method="NotifyPointerButton";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
} else if(type==2 && (p->devices&2)) {
method="NotifyPointerAxis";args=g_variant_new("(oa{sv}dd)",p->session,&b,x,y);
} else if(type==3 && (p->devices&1)) {
method="NotifyKeyboardKeysym";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
} else return 0;
GError *e=NULL;GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,PATH,RD,method,args,NULL,
G_DBUS_CALL_FLAGS_NONE,2000,NULL,&e);
if(e)g_error_free(e);if(r)g_variant_unref(r);return r!=NULL;
}
#endif
-222
View File
@@ -1,222 +0,0 @@
# 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 **Settings** (the gear in the header) **→ Open assistant settings**, 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.
**Verified end to end on the same Frame/build (2026-09-29), with mutations:**
a stdio MCP client started `ui/frame_mcp.py` in its default mode (private
backend, no API key, no prestarted server) and a human approved or rejected
each change in the approval page in a real Chrome window:
| Tool | Result on the Frame |
|---|---|
| `send_file` | Approved; the file arrived in `~/Downloads` with identical contents |
| `install` | Approved; `io.github.fizzyizzy05.binary` job finished in about 35 s |
| `panel` | Approved; gamescope listed a new panel window, and `computer_state` reported the same window ID and PID. The app rendered in that window (below) |
| `launch` | Approved; Keep Talking and Nobody Explodes (341800) started under Proton and `computer_state` reported it as the focused app |
| `uninstall` | Approved; app and locale removed |
| `power` | Rejected in the page. Unapproved retries, the same token used for `uninstall`, and a retry after rejection were all refused. Nothing was powered off |
| `send_text` | Approved, then refused because the Plasma desktop was not open (documented requirement) |
| `keep_awake` | `status` reports the script unavailable until PR #16 lands |
A separate Claude Code CLI session, with only this server configured, read
status, `computer_state` and a headset capture, and requested an install. It
received an approval URL and did not execute anything.
The assistant opened as a Frame panel through `scripts/assistant-on-frame.py`.
Against a loopback stub model, a send without consent made zero requests. With
consent it made exactly one, carrying the text and a fresh Frame screenshot.
Consent unticked itself after sending. SIGTERM removed the panel, profile, log
and tunnel.
**Not verified while unworn:** every headset capture was a uniform dark frame,
so SteamVR's rendered view of panels and the game could not be checked; window
captures (`xwd`) were used instead. MCP can launch a game or panel but has no
tool to stop one: the tester stopped them over SSH. Removing an app leaves any
runtime it pulled in; Flatpak may also remove related extensions when that
runtime is removed by hand.
![Approval page showing the exact install action](img/mcp-approval-install.png)
![The installed Flatpak rendering in its own gamescope panel window](img/mcp-panel-binary.png)
![Assistant in Frame Chromium, after an opted-in request to the local test endpoint](img/assistant-panel.png)
## Computer-use coverage
MCP is the tool transport, not a limit on what an agent can do. A screenshot,
accessibility snapshot, click or keystroke can all be MCP tools when we have a
reliable underlying implementation. See [the investigation](computer-use.md)
for the verified boundaries. `computer_state` adds observation, not an input
channel: it cannot click an approval button or send keyboard/mouse events.
**Verified 2026-09-29, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** the command saved
by `codex mcp add` launched without a prestarted server, negotiated MCP, listed
12 tools, read live Frame status and returned X11 window state plus AT-SPI
observations. It exited 0 at EOF. Steam's accessibility tree had inaccessible
children, reported as `incomplete: true`; this is not a complete actionable UI.
-57
View File
@@ -1,57 +0,0 @@
# Import an Android game backup
A SideQuest backup can contain a small APK and much larger `main.…obb` and
`patch.…obb` files. All of them belong to the same game. Installing only the APK
can leave the game unable to start because its assets are missing.
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the APK and its OBB files together.
You can also drop them anywhere in the window. In the desktop app, dropping the
APK alone finds matching OBB files beside it; dropping a folder containing the
APK and OBBs also works. Browsers can read only the files you explicitly choose.
The import screen shows the game app and its game-data files together. **Import
game & data** installs the APK, starts its own Steam library instance, waits for
Android and the installed package, copies the OBBs, and closes the game. Open it
again from the Steam library to play. The screen explains this before import;
choosing files alone never starts a game.
Files are matched using the package inside the APK and the standard
`main|patch.<version>.<package>.obb` name, not the APK's filename. Both main and
patch files are preserved. OBB version numbers need not equal the APK's current
version code: updates can reuse older expansion files. The copy uses the existing
SHA-256 transfer check inside Android and writes to that game's own
`/sdcard/Android/obb/<package>` folder.
If no data files are selected, the screen reminds you to include any OBBs in the
backup before offering **Install game app**. An APK cannot reliably tell Frame
Control whether separate expansion files are required. Game-data files are never
silently sent to Downloads by this flow.
If setup fails, the import screen keeps the error and retry instructions visible.
The APK may already be installed, but the game is not reported ready. Import the
same backup again, or choose just its OBB files to add data to an existing install.
The importer checks the installed package and version before writing data and
closes the instance after a failed copy. Unrelated or duplicate files, running
games that would be replaced, and downgrades are rejected. Uploaded staging files
are discarded after use or cancellation; abandoned imports expire after an hour
when the importer is next used, and dead-server staging folders are swept at
startup.
This flow handles one ordinary APK and standard OBB files for one game at a time.
Split APKs, XAPK/APKS archives, nested Android/data folders and private save
archives need their own import handling. PC ZIP/folder/EXE imports keep their
existing route. Quest USB and Meta library imports keep their existing workflow.
Validation covers actual APK parsing, the three-file backup layout, package
matching, sibling discovery, browser upload staging and cleanup, instance/version
guards and failure recovery with mocked headset operations. Browser verification
uses the real page and upload/inspection endpoints with simulated installation.
**Verified 2026-10-20 on a real Steam Frame**, BUILD_ID `20260925.6191901`:
F-Droid Basic 2.0.2 with two synthetic main/patch OBBs completed the automatic
install/start/copy/close sequence. Reopening its persistent instance confirmed
both SHA-256 hashes in `/sdcard/Android/obb/org.fdroid.basic`. The test app,
shortcut and instance were removed afterward, the shared device lock was
released, and the battery remained at 99%. See [device evidence](evidence/android-import/device.json).
The headset was unworn; this checks transfer and persistence, not rendering,
Resident Evil 4 gameplay, or a game that exits before data can be copied. This
flow does not change APK compatibility or remove licence checks.
-144
View File
@@ -1,144 +0,0 @@
# 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.
-197
View File
@@ -1,197 +0,0 @@
# APK repositories
Frame Control supports **F-Droid-format repositories**, including F-Droid,
F-Droid archive, IzzyOnDroid and user-provided HTTPS repositories. Repository
indexes are authenticated before their apps appear. Search lists builds with
Android API ≤30 and arm64-v8a or no native libraries, using the same streaming
reducer as the existing catalogue. This does not guarantee an app works in Lepton.
## Formats considered
| Format | Users and purpose | Support in this source |
|---|---|---|
| F-Droid v2 | F-Droid, IzzyOnDroid, self-hosted fdroidserver repositories; consumed by F-Droid clients including Droid-ify and Neo Store | Preferred: signed `entry.jar` authenticates `entry.json`; its SHA-256 authenticates `index-v2.json`, which supplies APK SHA-256 hashes |
| F-Droid v1 | Older F-Droid servers and clients | Fallback: verify `index-v1.jar`, then read its signed `index-v1.json` |
| Obtainium configurations / exports | Obtainium users share app URLs plus source-specific filters and update settings; exports can contain a list of app configuration objects | Not imported here: configurations describe how to find releases, not one signed repository index |
| SideQuest listings / custom feeds | SideQuest's own app discovery and installation service | No interoperable signed custom-repository specification was established from the public project documentation examined; SideQuest needs its own adapter |
| GitHub release lists | Developers publish APK assets on release pages; community lists link to projects | Not a repository standard: asset naming, build selection and publisher verification vary; handled separately from this F-Droid source |
| Minimal JSON list | A private list could contain package, title, APK URL and SHA-256 | Deliberately not introduced: unsigned hashes downloaded alongside files do not authenticate their publisher; another bespoke signing/update protocol would duplicate F-Droid |
Research references (checked 2026-09-28):
- [F-Droid APIs](https://f-droid.org/docs/All_our_APIs/) and
[repository setup](https://f-droid.org/docs/Setup_an_F-Droid_App_Repo/).
- [F-Droid signing keys](https://f-droid.org/docs/Release_Channels_and_Signing_Keys/)
and [IzzyOnDroid's repository page and fingerprint](https://apt.izzysoft.de/fdroid/).
- [Droid-ify](https://github.com/Droid-ify/client) and
[Neo Store](https://github.com/NeoApplications/Neo-Store).
- [Obtainium](https://github.com/ImranR98/Obtainium), its
[configuration/deep-link format](https://wiki.obtainium.imranr.dev/deep_links/),
and [community app configurations](https://apps.obtainium.imranr.dev/).
- [SideQuest's public client](https://github.com/SideQuestVR/SideQuest).
The absence of a specification in these materials is not proof that no
historical or private custom-feed format exists.
## Add a repository in Frame Control
From the Frame Control checkout, use its source-management CLI:
```sh
python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo?fingerprint=YOUR_64_HEX_CERTIFICATE_FINGERPRINT' --name 'My apps'
python3 ui/apk_sources/fdroid.py list
python3 ui/apk_sources/fdroid.py search SOURCE_ID 'music'
python3 ui/apk_sources/fdroid.py download SOURCE_ID org.example.app
python3 ui/apk_sources/fdroid.py remove SOURCE_ID
```
Replace `SOURCE_ID` with the `id` printed by `add` or `list`. `--fingerprint`
can also supply the pin. `fdroidrepos://example.org/fdroid/repo?fingerprint=…`
links are accepted and converted to HTTPS. Conflicting fingerprints are refused.
A URL must identify the repository directory, not its website or an index file.
Adding fetches and validates the complete index **before saving** the source.
Without a fingerprint, Frame Control verifies the JAR signature and remembers
its signer: trust on first use (TOFU). This establishes continuity with the
first server response, not independent publisher identity. Obtain the published
fingerprint through a trusted channel when possible; the store's Add a source
form shows the pinned one ("Trusted on first use: …") so you can compare it.
Re-adding an existing URL preserves its pin; changing it requires deliberately
removing and re-adding it.
### Repositories behind a login
Some self-hosted repositories sit behind HTTP basic auth. As in F-Droid's own
client, the login can be part of the address
(`https://user:pass@example.org/fdroid/repo`), or given separately: in the
store's **Add a source** form under "This repository needs a login", in the
server API (`username` and `password` beside `url`), or on the command line,
where the password is read from standard input so it stays out of shell history
and `ps`:
```sh
printf '%s\n' "$PASSWORD" | python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo' --username me --password-stdin
```
How the login is kept and used:
- **Kept out of the address.** The `user:pass@` part is removed before anything
is saved, so the saved repository URL, its id, the source list, job results
and the page never contain it. The store only says "Uses a login".
- **Stored separately.** Logins live in
`frame_host.data_dir('source-credentials.json')`
(`~/Library/Application Support/Frame Control/source-credentials.json` on
macOS), written with mode 0600 and keyed by source id. They are stored in
plain text there, protected by file permissions only (on Windows,
the folder's normal ACLs). Removing the repository deletes its login, and
the file goes when the last login is removed. Each change holds a lock
file (`source-credentials.lock`), so the app and the command line can't undo
each other's saves or removals.
- **HTTPS only.** An `http://` address with a login is refused before any request.
- **Inside the repository only.** The `Authorization` header is decided
separately for every request, redirects included: index, `entry.jar`,
APKs and the store's artwork proxy. It is sent only when the request's
scheme, host and port match the repository's and its path is inside the
repository's directory by whole path segments. A redirect to another host
or port, or to another path on the same server, gets no login, and that
request's own `Authorization` header is removed. Paths with `.` or `..`
segments (plain or percent-encoded), encoded `/` or `\`, double encoding
or `;` get no login, since a server could normalise them to a path
outside the repository. Index file names like that are refused outright.
- **Never echoed.** Logins are not written to logs, error messages, API
responses or the cache. The CLI prints only the saved source.
- **Checked before saving.** As with any repository, the whole index must
download and verify before anything is saved. HTTP 401 (or 403 when a login
was sent) stops the add with "check the username and password" and saves
nothing: no repository, no login, no rollback state. A 403 with a login is
not treated as rate limiting.
- **Same verification.** The login changes only who may download. Signature,
fingerprint pin or TOFU, index hash and rollback checks apply exactly as for
any other repository.
Re-adding the same address with a new login replaces the saved one. Re-adding
it without a login keeps the saved one.
The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
`add_repo(url, fingerprint=None, name=None, username=None, password=None)`, `remove_repo(source_id)`,
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
`sources`, `search`, `details`, and `download` from the shared source contract.
This change supplies the CLI and API; the integrated source-management UI is
separate work. Built-in sources can be disabled but cannot be removed.
Settings and pins live in `frame_host.data_dir('apk-repos.json')`
(`~/Library/Application Support/Frame Control/apk-repos.json` on macOS).
Authenticated reduced indexes and APKs live under
`frame_host.cache_dir('apk-sources')`; indexes refresh after 24 hours. An
expired index is still served (marked stale in the store) while it refreshes in
the background; a failed refresh is retried after 10 minutes.
Only the running Frame Control app prunes cached APKs (at start and after store
downloads); the command-line tools never do.
The existing catalogue's unverified index cache is never treated as authenticated.
Rollback protection: each repository's newest accepted signed index timestamp
is kept in `apk-repo-state.json` next to the settings, and an older index is
refused. Once a repository has served a v2 `entry.jar`, a missing `entry.jar`
is an error rather than a reason to fall back to `index-v1.jar`. `entry.jar`
must be signed with SHA-2 (SHA-1 is still accepted for legacy `index-v1.jar`).
Removing a repository clears its state.
## Publish your own repository
Only publish free APKs you own or have the developer's permission to distribute.
Do not publish paid app mirrors or bypass store licences. Check distribution
terms before adding someone else's repository; this module does not infer legal
permission from a signature or automatically audit a repository's terms.
Install a current [fdroidserver](https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools/)
and its documented Android/Java dependencies on the publishing machine, then:
```sh
mkdir my-fdroid
cd my-fdroid
fdroid init
# Set repo_url in config.yml to https://example.org/fdroid/repo
# Also set repo_name and repo_description; keep the generated signing key safe.
cp /path/to/your-free-app.apk repo/
fdroid update --create-metadata
# Review the generated metadata (name, summary, licence, source and website).
fdroid update
```
Serve the generated **repo directory** at that HTTPS URL, including APKs,
icons, `entry.jar`, `index-v2.json` and `index-v1.jar`. Do not publish the
private signing keystore or configuration passwords. Configure fdroidserver's
`serverwebroot` and run `fdroid deploy` for managed publication, or copy the
public directory with your existing deployment tool. Publish the SHA-256
repository certificate fingerprint displayed by fdroidserver in a link such as
`https://example.org/fdroid/repo?fingerprint=…`.
Keep the repository signing key backed up: changing it breaks existing pins.
For updates, add the new APK, edit metadata as needed, run `fdroid update` and
publish again. Test the published URL with Frame Control's `add`, `search` and
`download` commands. The above publisher setup is documented from fdroidserver;
it was not executed as part of this implementation.
## Verification and limits
The stdlib verifier supports one RSA PKCS#1 v1.5 JAR/CMS signer with a key of
2048–8192 bits; SHA-256/384/512 and legacy SHA-1 digest encodings are
recognized. It checks the signer certificate pin, the signature over `.SF`,
the whole-manifest digest, and the manifest's digest of the JSON member.
ECDSA, DSA, RSA-PSS, multiple signers and section-only `.SF` manifests are
rejected. Certificates are pinned identities, not validated as Web PKI chains.
HTTPS certificates are separately checked by Python's normal TLS validation.
v1 fallback occurs only when `entry.jar` returns HTTP 404 or 410. Signature,
fingerprint, index hash, TLS and server errors never trigger an unsigned
fallback. APKs are cached by SHA-256 and checked again before reuse. Here,
`verified: true` means the bytes match the signed repository's APK hash; it
is not an independent APK publisher-signature or runtime compatibility verdict.
There is no repository timestamp rollback/expiry policy or automated signing-key
rotation yet. An old correctly signed index can still validate.
Offline fixtures exercise v2, v1, TOFU, pin changes, disabled sources, cache
reuse, URL rejection and corruption of every signature/hash layer. On the Mac,
the real IzzyOnDroid repository was added with its published pin, searched for
Tiny Music Player, and its 16,520-byte APK downloaded with SHA-256
`d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a`.
No headset connection or installation was performed.
-116
View File
@@ -1,116 +0,0 @@
# Developer-consented APK sources
Surveyed 2026-09-28. Free access is not proof of redistribution permission or
Frame compatibility. These adapters fetch only public publisher releases or
link to publisher pages. They do not acquire store entitlements, defeat access
checks, install anything, or rehost APKs. See [VR compatibility](vr-apks.md).
| Source | Developer consent and automated-access position | API/feed; VR coverage | Decision |
|---|---|---|---|
| [itch.io](https://itch.io/docs/legal/terms) | Publishers warrant distribution rights (§4). Users may access content through the service; this is not blanket scraping permission. Main robots excludes `/game/download/`; author subdomains exclude `/*/download/`. No challenge bypass. | Public free Android RSS for `openxr` and `oculus-quest`; substantial indie VR. Server API is mostly authenticated publisher/account functionality, not a general anonymous store-download API. | Implement RSS search, artwork and page links; `downloadable: False`. The supplied free-download script follows keyed download pages excluded by robots, so it is not shipped. |
| [GitHub releases](https://docs.github.com/en/rest/releases/releases) | Maintainers publish assets; curated repositories below establish provenance. Public hosting or an open-source topic alone does not establish rights to every uploaded binary. Use supported REST API under [API terms](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service#h-api-terms), not HTML crawling. | Releases API includes APK assets and sometimes SHA-256. Topic search finds OpenXR/Quest projects. 60 unauthenticated requests/hour; authenticated user limits are generally 5,000/hour, with separate search/secondary limits. | Implement curated downloads and explicit topic discovery. Unreviewed topic results are page-only. |
| [Uptodown](https://www.uptodown.com/aboutus) | Developer distribution program exists, but that does not prove publisher authorization for every catalog item. [Privacy policy](https://www.uptodown.com/aboutus/privacy) explicitly describes protection against automated access. General automation permission was not established. | Broad Android catalog, limited VR focus; no supported public consumer-download API established in this survey. | Page links only; no downloader. Do not infer consent from an unchanged APK signature. |
| [APKPure](https://apkpure.com/terms) | Third-party APK catalog; individual publisher consent and automation rights were not established. Terms request returned HTTP 403; no bypass attempted. | Broad Android coverage, incidental VR; internal endpoints are not permission to automate. | Exclude automatic indexing/downloading; user may open site. |
| [APKMirror](https://www.apkmirror.com/faq/) | Publisher-signed files and a free-app policy are not a blanket developer-consent or automation grant. FAQ request returned HTTP 403, so current terms could not be confirmed. | General Android/version archive; APK bundles often need another installer; little VR focus. No supported consumer-download API established. | Page links only, no scraping or bundle conversion. |
| [Aptoide](https://en.aptoide.com/company/legal) | Terms define an app supplier as developer, owner or authorized distributor; user stores still require per-item provenance. API availability alone does not settle third-party access rights. | API ecosystem and general Android catalog; weak VR focus. | Defer until a publisher-owned store and its API terms can be approved. No blanket community-store downloader. |
| [Amazon Appstore](https://developer.amazon.com/docs/app-submission/understanding-submission.html) | Official developer submissions; store account, device and license rules apply. Publisher submission APIs do not authorize public binary extraction. | Fire-device distribution; Android-device Appstore support ended in 2025; little Quest relevance. | Official product links only; no account or entitlement extraction. |
| [PICO / ByteDance store](https://developer.picoxr.com/document/distribute) | Official publisher channel with store/device entitlements. No public unauthenticated binary-download grant established; documentation request encountered a redirect error. | Strong standalone VR; PICO builds may depend on PICO services/extensions. | Store links only. A developer's independently published GitHub/itch build can qualify separately. |
| [Meta Horizon Store / former App Lab](https://www.meta.com/experiences/) | Official developer submissions. A free store entitlement is still an entitlement; no license bypass or authenticated store extraction. App Lab was folded into the main store in 2024. | Strongest Quest coverage; no supported anonymous APK-download API established. | Store links only; independently distributed free builds use their publisher source. |
| [Khronos samples](https://github.com/KhronosGroup/OpenXR-SDK-Source) | Official upstream, Apache-2.0 sample; developer-published release APKs. GitHub API terms apply. | `hello_xr` Vulkan/OpenGL ES APKs; excellent OpenXR diagnostics. | Included in GitHub curated list, Vulkan variant selected. |
| [Meta OpenXR samples](https://github.com/meta-quest/Meta-OpenXR-SDK) | Official upstream; check each sample's license. Source availability does not imply a published APK, and some samples require Meta extensions/services. | Source/build examples, inconsistent ready-made APK releases. | Link to upstream; add specific free APKs only after release/provenance review. |
| [Godot XR demos](https://github.com/GodotVR/godot-xr-tools) | Official project source and publisher demo pages; licenses and dependencies vary by demo. | OpenXR examples on GitHub/itch. Older Godot builds can fail on Lepton's missing clipboard service. | Covered by source discovery; no compatibility promise from an OpenXR tag. |
The table distinguishes observed restrictions from unknown permission. An
unverified policy is a reason to defer automation, not a claim that a site is
unlawful. Only the two implemented source kinds are registered by their own
`sources()` functions; the other rows are recommendations, not new UI entries.
## Adapters
`ui/apk_sources/github.py` uses `github_curated.json`: Khronos `hello_xr`,
[Open Brush](https://github.com/icosa-foundation/open-brush), and
[SuperTux 3D](https://github.com/SgtBilko76/SuperTux-3D). These have official
OpenXR project/release evidence, not a blanket claim of headset compatibility.
Open Brush's compatibility evidence is recorded in [vr-apks.md](vr-apks.md).
Open Brush and SuperTux publish the selected builds as prereleases; curated
opt-ins preserve that label in version records. Exact APK filename patterns
avoid downloading desktop archives or alternate non-Quest builds.
[OpenSaberPlus](https://github.com/arpruss/OpenSaberPlus) was examined but not
curated: GitHub reports its license as `NOASSERTION`, and current OpenXR APK
provenance was not established in this pass.
Default GitHub search is offline against this small list. Queries
`topic:openxr`, `topic:oculus-quest`, and `topic:quest` explicitly call repository
search. Results outside the curated list stay page-only, even if a repository
claims an open-source license. This prevents an arbitrary tagged mirror from
becoming a trusted downloader. Extend the curated JSON after provenance review.
For a higher API quota, add a GitHub access token in the store's source
settings (the GitHub row's token field) or through the server API
(`POST /api/sources` with `action: "set-token"` or `"clear-token"` and
`source: "github"`). `FRAME_GITHUB_TOKEN` in the process environment still
works and wins over a saved token. A saved token is kept with repository
logins in `source-credentials.json` (mode 0600, see
[APK repositories](apk-repos.md#repositories-behind-a-login)). The source list
reports only whether a token is set, where it came from and whether GitHub
rejected it, never its value. Tokens are sent as `Authorization: Bearer` only to
`api.github.com`, never written to the cache, never sent to asset hosts (public
release downloads don't need them), and removed on redirects. If GitHub answers
401 to the token, the request is retried once without it, as Obtainium does.
The rejection is logged without the token and shown in source settings. The
token is not tried again until it is saved again or the app restarts. Rate-limit
backoff is kept per login (anonymous, or each token), so saving a token after
anonymous requests were limited lets the next request go out with it. The
adapter does not read `gh` credentials automatically. Metadata is cached for one hour under
`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request
fetches at most ten releases. Rate-limit errors are surfaced without retry
loops. Asset IDs and release tags are not Android version codes: metadata
leaves the latter unknown and rejects a requested `version_code` rather than
silently fetching a different build.
`itch.py` exposes separate OpenXR and Quest feed sources, so one feed's failure
does not suppress the other at the aggregator level. Queries filter the current
feed window locally: this is not an exhaustive historical itch search. Only
explicit zero-price Android entries are returned. Covers are exposed in
`images`; absent screenshots, APK version, ABI and minimum SDK stay unknown.
Curated GitHub entries include publisher artwork and plain-language summaries.
Repository image URLs are pinned to inspected commits. Open Brush screenshots
come from its README-linked Steam listing; SuperTux uses the upstream gameplay
preview embedded in the port's README (not a headset capture). The hello_xr
sample has a launcher icon and GitHub social banner; no published screenshot
was found in the inspected repository/README, so its screenshot list is empty.
Uncurated topic results use the owner's avatar and GitHub's repository social
preview. These are repository placeholders, not app screenshots. Itch's recorded
RSS includes only covers, so screenshot lists remain empty without page scraping. VR is
based on curated evidence or a VR-specific feed/topic, not a compatibility claim.
Downloads stream to unique temporary files, require an APK manifest entry,
restrict HTTPS origins and redirects, and enforce a 2 GiB ceiling. `verified`
means the downloaded SHA-256 matches GitHub's published digest. Without such a
digest, the computed SHA-256 is returned with `verified: False`; neither value
claims publisher-signature validation. Installation must inspect the APK as
usual. OBBs, split APKs, paid assets and external release-body download links
are unsupported.
## Evidence and limits
On this Mac, Python 3.9 downloaded the real Khronos Vulkan 1.1.63 APK through
the GitHub adapter, matched its published SHA-256
`f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34`, and
`python3 ui/frame_android.py info <apk>` exited 0: package
`org.khronos.openxr.hello_xr.vulkan`, version code 1063, minimum API 24,
arm64-v8a present, OpenXR detected. No Frame connection or installation occurred.
The itch OpenXR RSS was fetched successfully and recorded as a fixture.
Subsequent live adapter search encountered HTTP 429; it is not claimed as a
successful live end-to-end search. Fixture search finds Off Nominal and parses
nine Android entries from the ten-item feed (one has only an HTML platform).
A real itch download and APK inspection were deliberately not performed:
robots restrictions take precedence over that requested proof. No current
policy text is claimed verified where the table records failed access.
Tests use recorded, reduced API/RSS fixtures with network access blocked in
the new test class. They cover selection, prereleases, unknown topic results,
paid/non-Android exclusion, URL restrictions, redirect credential removal,
caching, rate limits, checksum mismatch, non-APK rejection and partial-file
cleanup. See `.claude/NOTES-more-sources.md` for commands and local evidence.
-479
View File
@@ -1,479 +0,0 @@
# 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, …).
Split APKs (a base plus `split_*.apk` files) install as one app:
`python3 ui/frame_android.py install base.apk split1.apk …`. Lepton runs one
APK, so splits that add native libraries or assets are merged into the base
and re-signed; density and language splits are refused. See
[quest-import.md](quest-import.md#split-apks). Not yet tried on a real Frame.
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).
## Store and Library in Frame Control
The Android tab has two views. **Store** finds apps across the sources (F-Droid,
IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on the
Frame as cards, with Play and a menu for Test, Report and Remove; the display
settings and recent reports are below it.
Store search cards and app details also show **Reported not working** or
**Reported issues** when saved compatibility reports say so. Details include
the report date, version and notes. These are advisories: Install remains
available unless the selected build has an existing compatibility blocker
(for example, no arm64-v8a build or Android API above 30). Reports never clear
those blockers. Apps reported not working are excluded from Home's featured
app and recommendation shelves; search and category results still show them.
An unrated install failure caused by the wrong APK architecture or Android API
is ignored, using the same rules as the catalogue. A person's rating takes
priority over automated results, with the latest rating winning.
Reports warm in a background worker at server start. Search and details read
only the last complete in-memory snapshot and schedule a single background
refresh after 60 seconds; they never wait for disk, Keychain, a subprocess or
the report database. During initial loading they show the usual build verdict;
after a refresh, the next search or detail request sees the reports. Failed
refreshes keep the previous snapshot and retry after another 60 seconds.
**Availability:** public copies benefit from their own saved reports, including
reports for apps outside the bundled catalogue, across every source with the
same package name. This does not give public users the private shared ratings.
The checked-in `apk-catalog/site/apps.js` contains predictions and no tested
report verdicts, so it cannot supply a useful public report snapshot. No private
reports, free-text notes or credentials are exported or published. The existing
maintainer loader still uses its configured database access in the background;
the search feature adds no sharing, endpoint or network destination. Tests leave
the hook off by default (`FRAME_COMPAT_SEARCH_REPORTS=0` also disables it in
server subprocesses) and use synthetic reports to exercise refresh behavior.
The store groups apps by what a headset is good for, not by F-Droid's list:
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
F-Droid's own categories decide, the finer one first (an app in *Multimedia* and
*Social Network* is Web & chat).
Apps built around a phone are hidden from browsing unless **Show phone-only
apps** is on; a search always finds them, labelled *Phone app*. Hidden: apps in
a category that only makes sense in a phone's own system (launchers, keyboards,
wallpapers, Xposed modules), apps in a phone-side category (calls and SMS, VPNs,
fitness, alarms, system tools) unless a useful category says otherwise
(FluffyChat is in Phone & SMS, but it's a messenger), apps named for a phone
part (a widget, a launcher, a battery tool), and apps that need hardware the
Frame lacks (an IR blaster, a watch, NFC, root). They would install and run in
Lepton, but do nothing useful on the Frame. In the October 2026 indexes that is
about 1,600 of F-Droid's 4,500 apps and 550 of IzzyOnDroid's 1,300.
Without a search, each source offers its best 300 for the chosen category:
apps with a banner first, then with screenshots, then the newest.
## The store in the headset: Frame Control's store as a panel (inferred, not yet run on the Frame)
The **APK Store** panel is the Store above, drawn for the laser: the same
sources, categories and artwork, with Install, Open, Stop and Remove, opened as
its own floating panel:
```sh
scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
scripts/store-on-frame.sh close
```
`open` copies the same bundle the iPhone app uses
(`ios/scripts/make_frame_bundle.py`) to
`~/.local/share/frame-control/store-server/<version>` on the Frame. It then
starts `ui/server.py` there with `FRAME_LOCAL=1` on `127.0.0.1:47812`, with a
fresh `X-Frame-UI` key. Chromium XR (or the `org.chromium.Chromium` Flatpak)
opens `/store` (`ui/store.html`) in a 1280×800 app window, tagged as panel
`2001947812` ([panels.md](panels.md)). Downloads, SHA-256 checks and installs
all run on the Frame, so the Mac can go away after `open`.
The page has a category rail (Home, the store's categories, Installed), eight
art cards a page, and a featured VR app on Home. It turns pages instead of
scrolling, because a tagged panel gets mouse clicks and a drag would select
text; the thumbstick (a mouse wheel) turns pages, and an on-screen keyboard is
there for search. **Checked on the Mac only** (2026-10-14, 1280×800, against
the real sources): Home, categories, Installed, the detail sheet. **Still to
check in the headset:** that the panel opens and takes laser clicks, that an
install completes when run on the Frame, and that **Open** brings up the app's
panel.
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
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.
## Expansion files and save backups
SideQuest-inspired CLI helpers install local OBB files into an already-running
app instance and back up/restore a stopped instance's private app data. See
[SideQuest features and limits](sidequest.md) for commands, archive scope and
verification status. These paths have offline coverage; real Frame storage and
permissions remain unverified. They do not change APK install or launch behavior.
## Local APK/OBB backups and Android bundles
In **Android apps → Import Android game → Choose game files…** (or **Send to Frame**), choose one
standalone APK and its matching `main.<version>.<package>.obb` and/or
`patch.<version>.<package>.obb` files together. The desktop app also discovers
matching OBB files beside a lone selected APK. Browser uploads use only the
files you selected. Installed apps can receive OBB-only imports.
You can instead select **one `.xapk`, `.apks`, or `.apkm` archive** containing
one standalone APK and optional matching OBBs. These extensions describe
containers, not a guarantee that their contents are supported. OBBs may be
at the archive root, under `<package>/`, or under `Android/obb/<package>/`;
one enclosing backup folder is allowed. Known manifest/icon/signature
sidecars are ignored; the APK manifest determines the package. Unknown files,
unsafe paths, links, encrypted entries, duplicate paths (including case
collisions), more than 4,096 entries, or more than 16 GiB unpacked are refused.
Staging also keeps 64 MiB of free disk space. Payloads still have the regular
one-APK/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
extract an Android ZIP yourself and select its APK/OBBs.
The importer previews the app and files before **Import game & data**.
Confirming authorizes installing the app, starting it to prepare Android,
checking the running package/version, copying OBBs through the existing
verified data helper, and closing the game. Open it from Steam afterwards.
If setup fails, read the error and retry the same backup; do not uninstall
first, since that deletes saves. An app that cannot start may also prevent
data setup. Uploaded and extracted staging is removed on cancellation,
inspection failure, or job completion/failure; abandoned imports expire
through the existing one-hour staging lifecycle. Original local files stay intact.
**Limits:** this backup importer refuses multiple APKs, split-only APKs and
bases marked as requiring splits. It does not select device variants or restore
`Android/data`, private app data, saves, or entitlements. For the separate
restricted native-library/asset split workflow see
[Quest split APKs](quest-import.md#split-apks) and the CLI above. Resource,
code and feature splits are not supported by that merger.
For issue #102, the RE4 report only establishes that an APK attempt failed.
Missing OBBs are a hypothesis; the edition/build, exact files, failing step,
and runtime compatibility remain unknown. Record those details, Frame Control
and SteamOS versions, and sanitized server/crash errors before diagnosing it.
`python3 ui/frame_android.py info /path/to/game.apk` inspects the APK locally.
Successful installation or fixture tests do not establish RE4 compatibility;
OBBs cannot fix ABI/API, VrApi/OpenXR, entitlement or Android-service failures.
-207
View File
@@ -1,207 +0,0 @@
# Backup and recovery
**Tools → Backup & recovery** copies what a factory reset would lose from the
Steam Frame to your computer, puts it back afterwards, and gets the headset out
of Desktop Mode. It runs from Frame Control on a computer, not from the iPhone
app or the Frame itself.
**Partly tested on a headset.** On a Frame running SteamOS 0.5.6 (build
20261015.6141207), a full backup saved 26 parts (40.7 MB): 14 games' Proton
saves, Linux game saves, Steam and desktop settings and 9 Android apps' data,
with no sign-in files or keys in the archives. Restoring one game's saves on top
of identical files wrote nothing and left nothing behind. The session check
reported Game Mode, so **Return to Game Mode** changed nothing. Not tested on a
headset: a restore after a real factory reset, a restore that replaces files,
Android data restore. Unit tests and the fake Frame cover those (see
[Verification](#verification)).
On 2026-10-24, the real-device reproduction in [issue #142](https://github.com/saphid/frame-control/issues/142)
confirmed full Plasma Desktop Mode on the same build: the Plasma session target
was active and the Game Mode target inactive. Desktop Mode also ran gamescope
for `steamvr-plasma.service`, so the old process check incorrectly reported
Game Mode. SteamOS's `steamosctl switch-to-game-mode` restored Game Mode and
reversed the target states; battery stayed at 99%, and the rollback timer and
shared lock were cleaned up. After #143 passed CI, the corrected code was
tested in another full Plasma session under a three-minute rollback timer.
`ui/frame_backup.py session` reported Desktop Mode despite gamescope, and
`ui/frame_backup.py game-mode` invoked the same guarded Return function used by
the button. Game Mode became active and a second call made no switch. Battery
remained at 99%; the timer and lock were removed. That test used the production
CLI rather than clicking the desktop button.
Later on 2026-10-24, the published **v0.4.17 Linux x86_64 AppImage** passed the
literal packaged-button check on the same Frame/build. With the shared lock and
a verified three-minute rollback timer, the headset entered full Plasma Desktop
Mode. In the packaged Electron window, **Tools → Backup & recovery → Check**
reported Desktop Mode despite gamescope. A trusted automated pointer click on
the visible **Return to Game Mode** button, accepting its confirmation, returned
`switched: true` and `steamosctl switch-to-game-mode`. The systemd targets then
confirmed Game Mode. A second confirmed button click returned `switched: false`;
the Game Mode service invocation and SteamVR PID stayed unchanged. **Check**
then reported Game Mode and disabled Return. The test app/server and virtual
display were stopped; the timer and shared lock were removed. Steam and SteamVR remained running
at 99% battery. [Commands, responses, cleanup and limits](evidence/return-game-mode-packaged-2026-10-24.json),
[before](evidence/return-game-mode-packaged-2026-10-24-before.png) and
[after](evidence/return-game-mode-packaged-2026-10-24-after.png).
This used the unchanged extracted AppImage payload with its bundled Python on
Xvfb (`--no-sandbox --disable-gpu`), following the packaged Export test in #156.
FUSE mounting, desktop integration, hardware rendering and the view inside a
worn headset were not checked. Older SteamOS builds remain fixture-tested.
## Back up
Choose **Game saves**, **Steam and desktop settings** and **Android app data**
(all three by default), then **Back up now**. **See what's included** lists each
part and its size first. Backing up only reads the headset.
Each backup is a folder named after the time it started:
```
~/Documents/Frame Control Backups/2026-10-23-142530/
manifest.json
saves/saves-1234.tar.gz one per game
saves/saves-linux.tar.gz
settings/settings-steam.tar.gz
settings/settings-system.tar.gz
android/org.example.game.tar.gz one per Android app
```
`manifest.json` records the headset's name, SteamOS version and BUILD_ID, every
file with its size and SHA-256, and each archive's SHA-256. It also lists what
was skipped and why. A backup is written under a hidden `.…partial` name and
renamed when complete, so a cut-off backup never shows up as one.
### What's saved
| Part | From the headset (`~` is `/home/steamos`) | Restored? |
|---|---|---|
| Windows game saves | `~/.local/share/Steam/steamapps/compatdata/<appid>/pfx/drive_c/users/steamuser/` → `AppData/Roaming`, `AppData/Local`, `AppData/LocalLow`, `Documents`, `Saved Games`. Wine's own folders in them (`Temp`, `Microsoft`, `D3DSCache`, `CrashDumps`) are left out. Sideloaded Windows games (non-Steam shortcuts) have prefixes here too | Yes |
| Linux game saves | `~/.config/unity3d`, `~/.local/share/godot/app_userdata`: where Unity and Godot games save by default | Yes |
| Steam controller layouts | `userdata/<account>/241100/remote/controller_config`, `steamapps/common/Steam Controller Configs/<account>/config` | Yes |
| Steam library settings | `userdata/<account>/config/localconfig.vdf`, `shortcuts.vdf`, `grid/` (custom artwork) | Kept for reference |
| Desktop settings | `~/.config/kdeglobals`, `kwinrc`, `kxkbrc` (keyboard layout), `konsolerc`, `dolphinrc` | Yes |
| SteamVR settings | `~/.config/openvr/config/steamvr.vrsettings` | Kept for reference |
| Android app data | Each Frame Control Android app's private data, through the existing `backup-data` helper ([sidequest.md](sidequest.md#private-app-data-backups)) | Yes |
"Kept for reference" files are in the backup but not put back automatically:
Steam and SteamVR rewrite them while they run, and an old `shortcuts.vdf` would
point at apps that aren't installed after a reset. Open the backup folder to
copy one by hand if you need it.
### What's never copied
- **Steam sign-in:** `steam.token`, `config/config.vdf`, `loginusers.vdf`,
`registry.vdf` and `ssfn*` files.
- **Keys and passwords:** `~/.ssh`, `~/.gnupg`, keyrings and KWallet, any
`*.token`, `*.key` or `*.pem` file, Tailscale state and proxy settings
(`secrets.env`). These are refused by name wherever they turn up, including
inside a game's folder, and a restore refuses them too.
- Games, Proton and the Steam runtimes: reinstall them from Steam.
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
screenshots (**Tools → Capture & Live** saves those to Pictures).
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
- Symbolic links (never followed), and single files over 2 GiB.
Backups aren't encrypted. Game folders can still hold a launcher's login, and
Android app data can hold an app's, so keep backups private.
Android apps that are open are skipped (the helper needs the app closed); close
them and back up again.
## Restore after a factory reset
1. Set up the headset again: Developer Mode, **Set Up Connection**, sign in to
Steam with the same account.
2. Reinstall your games, and **start each once** so Proton creates its folders.
3. Reinstall your Android apps from Frame Control.
4. **Tools → Backup & recovery → Restore…** on a backup. Frame Control checks
each part against the headset and says what it would do: how many files it
replaces and how many are new.
5. Tick the parts to restore and **Restore selected**. It asks before replacing
anything.
Parts that can't go back yet are skipped, not failed, with the reason:
| Reason | What to do |
|---|---|
| Install the game first | Install it from Steam, then restore |
| Start the game once | Proton hasn't made its folders yet; start the game, quit, restore |
| Sign in to Steam first | Controller layouts go under your account's folder |
| Install the app first / close the app first | Android app data needs the app installed and closed |
| The backup file doesn't match its checksum | The archive changed since it was made |
A restore writes only the files in the parts you chose, and only inside that
part's folders: it never deletes a file, and a save archive can't write outside
its game. Before replacing a file it copies the current one to
`~/.local/share/frame-control/restore-previous/<time>/` on the headset. Files
already identical are left alone. Android app data goes through the existing
`restore-data` helper, which keeps the previous data folder beside it.
## Stuck in Desktop Mode
On the Frame, Game Mode is gamescope running Steam, and the Linux desktop
normally runs *inside* it as one panel (`steamos-nested-desktop`, see
[how-the-frame-works.md](how-the-frame-works.md#the-layer-cake)). If that panel
is what you see, you're already in Game Mode: close it from the Steam dashboard.
Full Desktop Mode is a different session. **Check** reads the main user's
systemd session targets over SSH: `gamescope-session.target` and
`plasma-workspace-wayland.target`. Game Mode requires the former active and the
latter inactive; Desktop Mode requires the reverse. This takes precedence over
process names: full Desktop Mode can run gamescope for its VR overlay, and
Game Mode can run a nested Plasma panel. Check changes nothing.
Conflicting, transitional, failed or unreadable targets produce **unknown**.
On older images where both targets are explicitly absent, the fallback accepts
only gamescope without Plasma/nested desktop as Game Mode, or Plasma without
gamescope/nested desktop as Desktop Mode. Mixed processes remain unknown.
The SDDM autologin file is still reported as a login preference; it cannot
establish which session is running.
After confirmation, **Return to Game Mode** runs exactly
`steamosctl switch-to-game-mode` when available, or the older
`holo-session-select gamescope` / `steamos-session-select gamescope` command.
It closes the desktop and anything open in it. Frame Control never edits the
SDDM files itself, and runs no switch when the headset is in Game Mode, when
its mode is unknown, or when no supported switch command is installed. A
refused command reports an error; a request being sent does not verify that
the new session has started.
### Manual fallback
If the button can't help, or the headset isn't reachable:
1. **Restart it:** hold Power about 10 seconds until the light goes off, then
power on. On the Steam Deck, **Switch to Desktop** starts a one-shot
Desktop Mode session, so a restart brings Game Mode back. That the Frame
behaves the same is **inferred**, not tested.
2. **Over SSH** (Developer Mode on), run Valve's switch yourself:
`ssh frame steamosctl switch-to-game-mode` (verified on SteamOS 0.5.6).
Older images may instead provide `steamos-session-select gamescope` or
`holo-session-select gamescope`.
3. **Boot menu:** hold Power 10 seconds, then power on while holding the AUX
button on top of Power. Choose `Current`, or `Previous` to start the other
SteamOS slot; both keep your data. Details and the further options
(`Repair Steam Installation`, `Erase User Data`, a full re-image) are in
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing) and
[recovery-and-images.md](recovery-and-images.md). Back up first if you can:
`Erase User Data` is a factory reset.
## Verification
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
for real against a folder laid out like the Frame's home, with only SSH faked:
which paths are picked, that sign-in files and keys never are (also inside game
folders), links never followed, the manifest and its checksums, restore checks
after a reset (game not installed, no Proton folders yet, not signed in), that a
restore replaces only its own files and keeps the old ones, unsafe archives
(`..`, absolute paths, links, other games' folders) writing nothing, backup
names that try to leave the backups folder, and the exact Game Mode command.
`tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through
the server, deletes it and restores it. Neither involves a real headset.
Isolated session tests in the same suite reproduce full Plasma plus gamescope,
Game Mode with and without the nested desktop, conflicting/transitional targets,
missing targets on older builds, failed/timed-out systemd reads, selector
compatibility, and the confirmed Return action with SSH mocked. Run them with
`python3 -m unittest discover -s tests -p test_backup.py -v`.
-59
View File
@@ -1,59 +0,0 @@
# Battery and health monitoring
Open **Control Center → Battery & thermal health**, or expand **System
information**, to read the Frame's charge, charging state, signed battery
power, battery temperature and driver-reported health. Optional readings show
charge cycles, full charge capacity, design capacity and full / design capacity.
The existing battery chip also shows charging state and temperature.
These are read-only readings from the Linux host's `/sys/class/power_supply`,
not Android's container battery view. The probe prefers the documented Frame
fuel gauge, `max1720x_bat_7-36`, over other battery nodes, excludes peripheral
(`scope=Device`) and absent batteries, and otherwise uses a deterministic
battery fallback. The UI shows the selected source path.
| Reading | Linux attribute | Interpretation |
|---|---|---|
| Charge | `capacity` | Integer 0–100 percent; zero is a real reading |
| Charging state | `status` | Charging, Full, Discharging or Not charging; missing/unknown states stay unavailable |
| Battery power | `voltage_now × current_now` | Microvolts and microamps converted to signed watts; positive means charging on the Frame |
| Temperature | `temp` | Tenths of a degree Celsius, including valid negative readings |
| Battery health | `health` | Driver status such as Good or Overheat; not a wear percentage |
| Charge cycles | `cycle_count` | Driver-reported count, including zero |
| Full/design capacity | `charge_full`, `charge_full_design` | Microamp-hours converted to mAh |
| Full / design capacity | Ratio of those two capacities | Fuel-gauge estimate; not a diagnosis or a guaranteed runtime |
| Time estimate | `time_to_full_now`, `time_to_empty_now` | Seconds; displayed only for the corresponding charging/discharging state |
Unavailable means the field is absent, unreadable or invalid. One missing
sensor does not hide the others. Zero design capacity cannot produce a ratio. Time estimates outside 1 second
to 7 days and battery temperatures outside −273.15 to 200 °C are rejected
as invalid readings; these bounds are not thermal warning thresholds.
The ratio may exceed 100% because full capacity is a gauge estimate, so the app
does not clamp it or invent a wear warning. The app does not estimate health
from charge percentage or assign temperature thresholds. Existing thermal
alerts use the kernel's own hot/critical trip points.
A connected charger does not necessarily mean charging: the Frame can still
drain under load or sit idle near full charge. A 100% capacity reading alone
does not prove the driver reports Full. Missing status is explicitly unknown;
near-zero measured battery power with a charger is shown as not charging.
Failed, stale or disconnected status samples clear Control Center readings.
**No charge controls are provided.** Frame's SteamOS charge-limit backend is
unsupported; see [charge-limit research](charge-limit.md). Monitoring never
writes to power supplies, pauses charging, changes current or sets a limit.
## Verification
`tests/test_battery.py` uses temporary sysfs-like files for conversion,
malformed/missing values, permission failures, source selection and partial
sensor support. `tests/control_center.cjs` exercises charging states, optional
metrics and failed/stale/disconnected sample handling. These tests do not
connect to a headset. On 2026-10-24, the PR's uninstalled parser was also
piped over SSH to a real Frame running SteamOS 0.5.6 (build
`20261015.6141207`). Its read-only sysfs result selected
`max1720x_bat_7-36` and reported 99% charge, Not charging, 24.9 °C,
Good driver health, 14 cycles, 2,920 mAh full and 2,700 mAh design capacity
(108.1% fuel-gauge estimate). The Control Center UI was not run against this
headset; Windows and macOS were not tested. Optional fields are not claimed to
be available on every firmware build.
-9
View File
@@ -1,9 +0,0 @@
# Changelog
## Unreleased
- Battery monitoring now selects the Frame fuel gauge predictably, handles
missing or invalid readings, and shows optional cycle count, full/design
capacity and estimated capacity health in Control Center. Unknown charging
status stays explicit. Monitoring is read-only; charge limits remain
unsupported on Frame. The read-only parser was checked on a real Frame; the UI was not.
-168
View File
@@ -1,168 +0,0 @@
# Battery charge limit (80%): research
**Question:** can Frame Control set an 80% battery charge limit on the Frame
over SSH or adb, safely?
**Verdict: don't build it.** Valve's supported mechanism (the one Steam's own
charge-limit setting uses on the Deck) is not configured for the Frame. The
only control the Frame has is a vendor charger attribute that Valve's own
charging daemon also manages. Research as of 2026-10-23, confirmed with the
read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207):
- `steamosctl get-max-charge-level` fails with `UnknownInterface: ...BatteryChargeLimit1`.
- `/usr/share/steamos-manager/devices/` has `[battery_charge_limit]` only for
the Steam Deck, ROG Ally, ROG Xbox Ally and MSI Claw, not the Frame.
- Power supplies: `max1720x_bat_7-36` (Battery, driver `max1720x`),
`pm8550b-charger` (driver `qcom,pm8550b-charger`) and the USB-C `tcpm-source-psy-…`.
No `charge_control_end_threshold` anywhere; `pm8550b-charger` has
`charging_enable` and `charging_pause`, both `root`-only (`-rw-r--r--`).
- `deckard-charger.service` (Deckard Battery Charger service) is enabled and running.
For safe read-only readings in Control Center, see [battery and health
monitoring](battery-health.md). It does not provide charging controls.
## How Steam does it on other devices
- Steam's **Settings → Power → Battery Charge Limit** shipped to the Deck in
SteamOS 3.7.8 (beta client 2025-05-08)
([GamingOnLinux](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)).
- Steam calls SteamOS Manager on the session bus:
`com.steampowered.SteamOSManager1.BatteryChargeLimit1`, with properties
`MaxChargeLevel` (`i`, read/write; `-1` resets to the default) and
`SuggestedMinimumLimit` (`i`, read-only). The CLI is
`steamosctl get-max-charge-level` / `set-max-charge-level` /
`suggested-minimum-charge-limit`. Source: `data/interfaces/com.steampowered.SteamOSManager1.xml`
and `steamos-manager/src/bin/steamosctl.rs` in
[holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager),
read at commit `83981ab` (2026-10-21).
- The root daemon writes the value to sysfs only if the device's TOML in
`data/devices/` has a `[battery_charge_limit]` section. Two methods exist
(`steamos-manager/src/power.rs`, `BatteryChargeLimitMethod`):
- `hwmon_attribute`: the Deck writes `max_battery_charge_level` on
`steamdeck_hwmon` (`data/devices/steam-deck.toml`).
- `acpi_sb`: ROG Ally, MSI Claw and others write the standard
`charge_control_end_threshold` on the ACPI battery
(`/sys/bus/{platform,acpi}/drivers/…battery…/PNP0C0A:00/…/power_supply/*/`).
On ARM there's no ACPI, so this path can't match there either.
- With no `[battery_charge_limit]` section, the interface fails with
`UnknownInterface` (Steam can still show its toggle on unsupported hardware:
[steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044)).
## What the Frame has
- **Upstream `data/devices/steam-frame.toml`** (matched by
`dt.compatible = "qcom,sm8650"`, variant `Deckard`) configures GPU clocks
and performance profiles only. It has **no `[battery_charge_limit]`**, so
SteamOS Manager on the Frame has no charge-limit backend. Its git history
(2026-09-10 to 2026-10-12) never added one.
- **Battery driver:** the Frame's battery is
`/sys/class/power_supply/max1720x_bat_7-36`, and the charger is a
`tcpm-source-psy-…` USB-PD supply ([how-the-frame-works.md](how-the-frame-works.md)).
Both were verified 2026-09-25, and `ui/frame_status.py` `battery()` and
`power_source()` read them. The Maxim MAX1720x is a **fuel gauge** and
can't control charging. Mainline `drivers/power/supply/max1720x_battery.c`
exposes only read-only properties (capacity, voltage, current, temp,
charge_full…), with no `charge_control_end_threshold`. Valve's build is
patched: it reports `time_to_full_now`, which mainline doesn't have. Even
so, a charge stop would have to come from the separate charger IC, not this
node.
- **Not the qcom_battmgr path.** Some other SM8650/SM8750 handhelds get
`charge_control_end_threshold` from Qualcomm's `qcom_battmgr`
(pmic_glink) driver, clamped at 55% or above. A third-party distro enabled
Steam's limit on those by patching steamos-manager
([armada-os/armada#758](https://github.com/armada-os/armada/pull/758)). The
Frame's battery isn't exposed by `qcom_battmgr`, so that doesn't carry over.
(Unverified: whether a `qcom_battmgr` node also exists on the Frame. The
read-only checks below answer this.)
- **What third parties found:**
[y0usaf/frame-charge](https://github.com/y0usaf/frame-charge) reports that
the Frame has no `charge_control_end_threshold` and falls back to a vendor
`charging_pause` attribute. It also reports that `constant_charge_current`
changes on its own, which it attributes to Valve's `deckard_charge_daemon`
(`deckard-charger.service`). By its own README, that tool has only run its
read-only `status` on a Frame and has **not** been tested against real
charging. Two questions are still open: whether pausing stops the current,
and whether Valve's daemon undoes it.
## Why not build it now
- **No supported endpoint.** The only Valve-supported interface,
`BatteryChargeLimit1`, isn't backed on the Frame. Writing sysfs ourselves
would bypass Steam and SteamOS Manager, and it needs root (`sudo -S` with
the user's password).
- **A rival owner.** Valve's charge daemon already adjusts charging
parameters. Toggling `charging_pause` underneath it could be undone without
warning, fight it in a loop, or leave charging paused.
- **Stuck at a low charge.** If the app or the Frame dies while charging is
paused, the Frame could sit at a low charge on the charger. Whether a
reboot clears `charging_pause` is unknown.
- **The UI would mislead.** Steam's setting (if it shows at all) wouldn't
reflect our limit.
- **adb doesn't help.** Wi-Fi adb (`frame:5555`) reaches the Lepton Android
container only. USB-C adb reaches the Linux side as `steamos` (non-root, the
same as SSH, [ssh.md](ssh.md)). Neither adds a charging-control route.
Android's `dumpsys battery` in Lepton is a container view, not the hardware.
**Revisit when** a SteamOS update adds `[battery_charge_limit]` to
`steam-frame.toml` (or `steamosctl get-max-charge-level` stops returning
`UnknownInterface` on the Frame). Then the feature is just a SteamOS Manager
call:
- Read: `steamosctl get-max-charge-level`
- Set: `steamosctl set-max-charge-level 80`
- Undo: `steamosctl set-max-charge-level -- -1`
That runs as `steamos` with no sudo. It's the same value Steam's own
Settings → Power shows, so both stay in sync.
## Headset checks (read-only first)
Run these over SSH as `steamos`. Steps 1–4 only read.
```sh
# 1. Does SteamOS Manager offer the interface on this build?
steamosctl get-max-charge-level; steamosctl suggested-minimum-charge-limit
busctl --user introspect com.steampowered.SteamOSManager1 \
/com/steampowered/SteamOSManager1 | grep -i -A3 ChargeLimit
grep -n battery_charge_limit /usr/share/steamos-manager/devices/*frame* 2>/dev/null
# 2. Which power supplies exist, and which drivers back them?
for d in /sys/class/power_supply/*; do
echo "== $d ($(cat $d/type)) driver=$(basename "$(readlink -f $d/device/driver)" 2>/dev/null)"
ls $d
done
# 3. Any charge-control attributes, with their values and permissions?
ls -l /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
grep -H . /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
# 4. What is Valve's charger daemon, and what does it touch?
systemctl status deckard-charger.service --no-pager
systemctl cat deckard-charger.service
journalctl -b -u deckard-charger.service --no-pager | tail -50
```
If step 1 succeeds on some build, test `steamosctl set-max-charge-level 80`
on a charger at a charge above 80%. Watch `current_now` on the
`max1720x_bat_*` node fall to about 0, then `steamosctl set-max-charge-level -- -1` and
confirm charging resumes. Do this on a supervised Frame only. If only step 3
finds a vendor attribute, **stop**: don't write it, and leave the question
with Valve.
## Sources
- steamos-manager source, commit `83981ab` (2026-10-21):
`data/devices/steam-frame.toml`, `data/devices/steam-deck.toml`,
`data/interfaces/com.steampowered.SteamOSManager1.xml`,
`steamos-manager/src/power.rs`, `steamos-manager/src/manager/user.rs`.
[gitlab.steamos.cloud/holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager)
- Mainline [`max1720x_battery.c`](https://github.com/torvalds/linux/blob/master/drivers/power/supply/max1720x_battery.c)
- [ValveSoftware/steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044):
the Steam toggle shows on unsupported hardware
- [armada-os/armada#758](https://github.com/armada-os/armada/pull/758):
`qcom_battmgr` threshold on other SM8650/SM8750 devices
- [y0usaf/frame-charge](https://github.com/y0usaf/frame-charge): third-party,
untested on real charging
- [GamingOnLinux: SteamOS 3.7.7 beta charge limit](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)
- Repo: [how-the-frame-works.md](how-the-frame-works.md) (battery and
charger nodes), [ssh.md](ssh.md) (adb scope), `ui/frame_status.py`
-67
View File
@@ -1,67 +0,0 @@
# 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.
-247
View File
@@ -1,247 +0,0 @@
# Headsets, addresses and the connection
Frame Control can manage more than one Steam Frame, and each headset can be
reached at more than one address: a LAN IP at home, another at the office, its
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
tab (key 5) lists them. The connection pill in the header says whether the one in
use is connected and how (Tailscale, or the network's name); click it to add,
edit or reorder that headset's addresses, or to see each step of connecting.
The code is in three modules, all stdlib-only Python on your computer:
| Module | What it does |
|---|---|
| `ui/frame_devices.py` | The registry: headsets, their addresses, networks; importing and updating `~/.ssh/config`; pinned host keys |
| `ui/frame_network.py` | Which network this computer is on, and Tailscale's state |
| `ui/frame_link.py` | The connector: finds the headset, keeps the SSH connection, publishes each stage; the Devices API |
## Headsets
Each headset keeps its own SSH alias, as Set Up Connection has always written
it: the first is `frame`, the next `frame-2`, and so on. Terminal's
`ssh frame-2` and the helper scripts (`FRAME_ALIAS=frame-2 scripts/push.sh …`)
work for each one.
- **Nothing to migrate by hand.** On first start, the app imports every
`# >>> steam-frame (ALIAS) >>>` block in `~/.ssh/config` as a headset, with
the block's HostName as its first address. It also copies the host key your
`known_hosts` already trusts for that address into the headset's own
known_hosts file, `~/.ssh/frame-control-hosts/<id>`, so nobody is asked to trust it again.
- **Add a headset** runs Set Up Connection (`scripts/connect.sh` on macOS,
`ui/frame_connect.py --alias NAME` elsewhere) in a terminal with a new alias.
When it writes its block, the app picks the headset up by itself. If Set Up
Connection runs again and finds a headset somewhere new, that address is added
at the top of its list.
- **Use this headset** (or the switcher in the header, or the app's
**Frame → Headset** menu) moves the whole app to another headset; every panel
reloads from it. From that moment no command goes to the previous headset, even
if the new one never answers. It waits while an install is running, since an
install reads the SSH settings step by step.
- **Remove** forgets a headset. Its `~/.ssh/config` block stays unless you tick
the box; either way it isn't imported again unless Set Up Connection changes it.
- A plain `FRAME_ALIAS` that Set Up Connection never configured still works: the
app shows it as not set up and lets ssh's own config decide where it goes.
## Addresses
Each address has a kind (LAN, mDNS, Tailscale or Other, guessed from the address
and changeable), an optional label, the networks it has worked on, and when it
last worked with its round-trip time.
When connecting, the app **tries all addresses at once** (TCP to the SSH port)
and ranks them:
1. addresses that worked on the network this computer is on now;
2. mDNS names;
3. Tailscale addresses, if Tailscale is running here;
4. addresses not tried on this network yet;
5. addresses that only ever worked on other networks;
6. Tailscale addresses while Tailscale is off.
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
still trying. If SSH to the winner fails in a way another address could fix
(a different device answered there, or the link dropped), the next one that
answered is tried. Every success records the network on that address, so next
time on that network it's tried first.
**Test now** probes every address and tries SSH on each one that answers, without
disturbing the connection in use: "SSH works", "answered as a different
headset", "refused this computer's key", or why it didn't answer. **Find on
Tailscale** lists your tailnet's devices (likely headsets first, from `tailscale
status --json`, including the Mac app's own CLI) with buttons to add their
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
services and checks `ALIAS.local` and `frame.local`.
**The pill's dialog** lists the same addresses, with what each one answered, and
can add, edit, reorder and remove them without leaving the page you're on. When
the headset reports a LAN IP on the same network as this computer and that IP
isn't saved, it offers to add it. The offer puts the address first in the list,
so on that network it wins over the Tailscale name; away from home the Tailscale
name still leads. A new or edited address is tested straight away. While
connected, **Reconnect** applies your changes now rather than at the next
connection: it tries the addresses again, ranked as above, and the best-ranked
one that answers promptly wins. It doesn't pick a particular address.
## Networks
A network is told apart by its default gateway: the router's IP address plus its
hardware (MAC) address, read with `route`/`arp` (macOS), `ip route`/`ip neigh`
(Linux) or `route print`/`arp -a` (Windows). That works on wired networks, and
on macOS 14 and later, which hides the Wi-Fi name from apps without Location
permission. Where the system does share the Wi-Fi name, it's shown, and you can
name any network yourself ("Home Wi-Fi") on the Devices tab.
The app rereads the gateway every 5 seconds and Tailscale's state every
30 seconds. Changing networks reconnects.
## The connection, stage by stage
When a connection fails, the header pill keeps its compact offline state and
retry countdown; its tooltip and connection dialog contain the full reason.
Devices shows a short reason in the headset row (with the full reason in its
tooltip) and a wrapping recovery explanation in the selected headset's details.
**Use this headset** also selects that headset's details.
The connector runs in the server (`frame_link.Link`) and moves through:
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
2. **Finding the headset**: each address resolving, trying, answered in N ms,
no answer, refused, or can't be found.
3. **Opening SSH** to the address that answered.
4. **Checking the headset's identity**: the host key must match the one pinned
for this headset.
5. **Logging in** as the headset's user.
6. **Connected** via network N, address A, round trip T; or **failed** at a stage
with the reason in plain words and a countdown to the next try (5, 10, 20,
then every 30 seconds). Retry now skips the wait.
Stages 3 to 5 come from following `ssh -v` as it runs. On macOS and Linux the
connection is an SSH ControlMaster that every command shares; when it dies (the
headset slept or left the network) the connector notices and starts again. On
Windows, where OpenSSH can't share a connection, the same handshake runs once
and each command then connects on its own; a command that can't reach the
headset makes the connector start again. There ssh's stderr goes to a file that
the connector reads as it grows, not a pipe: Windows' OpenSSH can block writing
to a redirected pipe.
Once connected, every `ssh`, `scp` and `rsync` the app runs gets
`-o HostName=<address> -o HostKeyAlias=frame-control-<id>
-o UserKnownHostsFile=~/.ssh/frame-control-hosts/<id> -o HashKnownHosts=no -o User=… -o Port=…`. The
alias's block in `~/.ssh/config` is also updated to the last address that
worked (and to the user and port you set), so Terminal's `ssh frame` and the
scripts follow. Edits to `~/.ssh/config` take a lock file
(`~/.ssh/config.frame-control.lock`) that Set Up Connection takes too, and never
write over a change someone else made since the app last read the file.
**Host keys are pinned per headset, not per address.** Your own `known_hosts`
is keyed by address, so a different device answering at a remembered IP (a DHCP
lease that moved) would look like a new host there. The app keeps one known_hosts
file per headset instead, so saving or forgetting one headset's key never touches
another's: a different device answering at one of its
addresses is refused, and the pill says so. A headset's first connection trusts
the key it shows, as Set Up Connection does: while a headset has never had an
identity here, every ssh command gets `StrictHostKeyChecking=accept-new` (the
first contact saves it, whichever command makes it), and from then on
`StrictHostKeyChecking=yes`. `accept-new` never replaces a saved key, so it
can't hide a change. The saved key's fingerprint is also recorded in
`devices.json` (`identity`), along with a flag that never clears
(`identity_established`), as soon as ssh saves the key: even if the login is then
refused. A headset counts as having had an identity if the flag is set, a
fingerprint is recorded, its pin file exists at all, or a connection to it ever
worked (older files get the flag from that when loaded, so editing or removing
the address that worked doesn't undo it); then
a pin file that's deleted, empty, unreadable or not parseable **fails closed**
(*Checking the headset's identity* fails, "missing or can't be read") rather than
trusting whatever answers, and only **Check its key…** saves a key again. It
says when the key it reads matches the recorded fingerprint.
**When the key changes** (SteamOS reinstalled or reset, a new headset at the
same address, or a different device answering there), nothing is accepted by
itself. The connection fails at *Checking the headset's identity*; the banner
and the headset on the Devices tab say so, with **Check the new key…** (also
**Check its key…** on the Devices tab at any time). That reads the key the
headset shows now, and nothing more: ssh writes it to a throwaway known_hosts
file during the key exchange, before any login, and is stopped as soon as it
has. Every other kind of contact is ruled out on the command line, ahead of
anything `~/.ssh/config` says: no authentication of any kind, no agent
(`IdentityAgent=none`, `ForwardAgent=no`), no X11 or port forwarding, no local
or remote command (`-N`), no terminal, and `-F none`. Through a ProxyJump host
it builds that hop itself, as a ProxyCommand whose ssh forwards nothing and runs
no local command (ProxyJump's own ssh would take `ForwardAgent` and
`LocalCommand` from `~/.ssh/config`); it still logs in to the jump host as
configured. Every word of that ProxyCommand is quoted for the shell that runs it
(and `%` doubled for ssh's own tokens), the forward is `[%h]:%p` so an IPv6
headset works, and an IPv6 jump host is passed bare with its port. Through a ProxyCommand from `~/.ssh/config`, or several jump hosts,
it refuses, since those can't be made safe from here: add an address it can
reach directly, or compare the key by hand. It shows the key's SHA256 fingerprint next to
the saved one, with the command that prints it on the headset
(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`, in Konsole on its desktop).
**This is my headset: trust its new key** then saves exactly that key, replacing
the old one, and reconnects. The server keeps the key it showed for 10 minutes
and saves it only if the page sends back the same fingerprint, and only while
the headset's port, user and that address are unchanged; it never saves whatever
answers next. The API's old `forget-identity`, which deleted the pin so that the
next contact saved any key unseen, is refused. Set Up Connection can't fix a changed key: it checks your
own `known_hosts`, not the headset's file.
**When the headset refuses this computer's key** (`Permission denied
(publickey,…)`), the connection stops at *Logging in*, and the banner and the
Devices tab offer **Set Up Connection…**, which pairs this computer again.
Neither is reported as a fault: the diagnostics report each kind at most once
a session ([privacy.md](privacy.md)).
## One server at a time
Only one Frame Control server runs per user (a lock file, `server.lock`, in the app's
data folder). Two would each connect, reconnect and edit the headsets on their own, and
one could move the other's install to a different headset. A second one, say
`scripts/frame-ui.sh` while the app is open, exits with "Frame Control is already
running". `FRAME_CONTROL_DATA_DIR` gives a separate one, with its own headsets.
## API
All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
| Request | Returns |
|---|---|
| `GET /api/connection` | The connection state: `phase` (connecting, connected, failed), `device`, `network`, `stages`, `probes`, `via`, `error`, `retry_at`, `tests`, `version` |
| `GET /api/connection/events` | The same as server-sent events, one each time it changes (the page reads it with `fetch`, since `EventSource` can't send the header) |
| `GET /api/devices` | Headsets, the current network, known networks, the next free alias |
| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first |
| `GET /api/devices/mdns?id=` | Headsets found on this network |
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `identity-scan` (read the key the headset shows: `scan` with `type`, `fingerprint`, `host`, `saved`, `recorded`, `matches`, `recorded_match`), `identity-trust` (`fingerprint`: save the key `identity-scan` showed), `forget-identity` (refused: use the two before), `name-network`, `setup` (alias, optional host), `retry` |
Every host, alias and user is checked against strict patterns before it's
stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes
through a shell.
## The registry file
`devices.json` in the app's data folder (`~/Library/Application Support/Frame
Control` on macOS, `%APPDATA%\Frame Control` on Windows,
`~/.local/share/frame-control` on Linux). It's plain JSON so the iPhone app can
share the format later (it still connects to one host; see
[iphone.md](iphone.md)):
```json
{"version": 1, "active": "f67f8b7e",
"devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
"identity_files": ["~/.ssh/id_ed25519_frame"], "identity": ["SHA256:…"], "identity_established": true,
"addresses": [{"host": "frame.local", "kind": "mdns", "label": "",
"networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}],
"networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1",
"gateway_mac": "b4:fb:e4:b5:67:55", "wifi": true, "last_seen": 1790593550.0}}}
```
A network id is `n-` and the first 10 hex digits of SHA-1 of `gateway|mac`.
## Tests
`tests/test_devices.py`, `tests/test_network.py` and `tests/test_link.py` run
with the other unit tests. They use a stand-in `ssh` (`tests/fakessh/ssh`) that
prints what `ssh -v` prints and plays a ControlMaster, real sockets on this
computer for the addresses, and temporary folders for `~/.ssh`
(`FRAME_CONTROL_SSH_DIR`) and the app data (`FRAME_CONTROL_DATA_DIR`), so they
never touch yours.
-34
View File
@@ -1,34 +0,0 @@
{
"date": "2026-10-20T03:53:25Z",
"build_id": "20260925.6191901",
"app": "F-Droid Basic 2.0.2 (org.fdroid.basic, version code 2000052)",
"input": "One real APK plus synthetic main and patch OBBs named with older version code 2000050",
"sibling_discovery": true,
"automatic_import": "passed",
"closed_after_import": true,
"independent_readback": [
"5023b41e7ac80f8b26a868db1f66884976bf864fab81a070ad37539f3481b352 /sdcard/Android/obb/org.fdroid.basic/main.2000050.org.fdroid.basic.obb",
"aecbed7433e81aaeef40942069d03ad23130f09d93b7e78bbe224865a9573157 /sdcard/Android/obb/org.fdroid.basic/patch.2000050.org.fdroid.basic.obb"
],
"cleanup": {
"test_install_removed": true,
"running_instances_before": {},
"running_instances_after": {},
"battery_before": [
"100",
"Full",
"Charging"
],
"battery_after": [
"99",
"Discharging",
"Charging"
],
"shared_lock_released": true
},
"limits": [
"No headset rendering or gameplay assertion; headset unworn.",
"Synthetic expansion files verify transport and persistence, not Resident Evil 4 compatibility.",
"A game that exits before its Lepton instance can accept data was not tested."
]
}
@@ -1,38 +0,0 @@
{
"date_utc": "2026-10-24T07:54:37Z",
"frame_control_tag": "v0.4.16",
"steamos_version": "0.5.6",
"steamos_build": "20261015.6141207",
"host": "lxso2",
"interface": "source UI served by a temporary private Frame Control server, opened in T3 Code's desktop browser",
"action": "Clicked the visible Export button for Steam's retained real saved clip in Capture & Live",
"clip_id": "10230640/clips/clip_13009719723623972864_20261024_072115",
"api_observed": {
"post_clips_export_http": 200,
"get_job_http": 200,
"get_clips_http": 200
},
"ui_result": "Saved clip_13009719723623972864_20261024_072115.mp4 to ~/Videos/SteamFrame; button changed to Export again",
"output": {
"path": "~/Videos/SteamFrame/clip_13009719723623972864_20261024_072115.mp4",
"bytes": 159829,
"sha256": "f8a476eefb2bde7ba5cb464632ec9b952eea3ab1610b5bed2098bf1f11d62cf1",
"matches_previously_fully_decoded_export": true,
"duration_seconds": 10.288532,
"video": "H.264 1920x1080",
"audio": "AAC stereo"
},
"cleanup": {
"temporary_server_stopped": true,
"shared_device_lock_released": true,
"battery_percent_after": 99,
"desktop_mode_active_after": false,
"test_title_created": false,
"steam_source_clip_retained": true
},
"limits": [
"The test used the source UI, not a packaged desktop installer.",
"The retained source clip was nearly black; visible gameplay capture remains unverified.",
"Perceptual audio/video sync, long clips, trims and automatic copying were not tested."
]
}
-29
View File
@@ -1,29 +0,0 @@
{
"date_utc": "2026-10-24",
"version": "v0.4.17",
"steam_os_version": "0.5.6",
"steam_os_build": "20261015.6141207",
"result": "PASS: the real Steam-saved clip exported beyond 60 seconds",
"method": "Steam manual recording of a reserved test title, then Frame Control's production frame_clips.export_clip path; no packaged GUI button click",
"source": {
"kind": "saved Steam clip",
"duration_seconds": 100.801,
"chunks": 68,
"gaps": 0
},
"export": {
"format": "MP4",
"bytes": 1377849,
"sha256": "82ef31f65e6933d122bc326d664beb2eb2e3ba6969f26c0032552e80fbe8d823",
"duration_seconds_ffprobe": 100.833335,
"video": "H.264, 1920x1080, 6049 frames",
"audio": "AAC, 48000 Hz, stereo",
"full_ffmpeg_decode": "passed without errors"
},
"cleanup": "Restored Steam recording mode to its prior off setting; stopped and removed only the reserved title and its processes; no recording or running Steam apps remained; Game Mode active, battery 100%, shared test lock released",
"limits": [
"This exercised the production export code path, not a packaged GUI Export click.",
"Visible gameplay quality, perceptual audio/video sync, trims and longer recordings were not checked.",
"The newly saved Steam source clip was retained; no user recording was deleted."
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Loaded 100 of 667 files, more files were not shown because too many files have changed in this diff. Show more