Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fb00b263c | ||
|
|
4937caf310 | ||
|
|
128e7a4c94 | ||
|
|
1a439f05c2 | ||
|
|
016d2b8c2d | ||
|
|
42ec68ed51 | ||
|
|
fa49fab5bf | ||
|
|
ce9e476ec7 | ||
|
|
dcf9689f64 | ||
|
|
224340edc9 | ||
|
|
c814cb95d0 | ||
|
|
ff2c4ebfe0 | ||
|
|
03322d3166 | ||
|
|
2d08486278 | ||
|
|
f780ab2c6a | ||
|
|
396f2a830a | ||
|
|
59761ae015 | ||
|
|
48eedbc2eb | ||
|
|
2b89eeba1d | ||
|
|
75e92db2fa | ||
|
|
10f96656e3 | ||
|
|
a1fa4ce140 | ||
|
|
a7663b3a5e | ||
|
|
a6fff434a4 | ||
|
|
cd40a194ed | ||
|
|
0037b1ef4b | ||
|
|
50405ccf88 | ||
|
|
32196b4260 | ||
|
|
7d328e20a5 | ||
|
|
fbe7ba9575 | ||
|
|
5e12245932 | ||
|
|
df1810bae3 | ||
|
|
3f0e7b198a | ||
|
|
9110557e23 | ||
|
|
0016d9200c | ||
|
|
fe87a9d826 | ||
|
|
1b26c4f92c | ||
|
|
3db311da13 | ||
|
|
e1d138470f | ||
|
|
c711e136d4 | ||
|
|
19b9bc2dbe | ||
|
|
b768162139 | ||
|
|
56bbac4ddb | ||
|
|
93aebb5019 | ||
|
|
0181071b83 | ||
|
|
ca2a991f03 | ||
|
|
10bf18fd50 | ||
|
|
d65f7130d5 | ||
|
|
b1fa3afd40 | ||
|
|
aafd2dbda8 | ||
|
|
db75d1ca71 | ||
|
|
fd3a25434d | ||
|
|
14790ac768 | ||
|
|
a910f83ac1 | ||
|
|
13187e8f6a | ||
|
|
0f770dc88a |
No files matched your search
@@ -27,6 +27,9 @@ desktop or panels.
|
||||
| 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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# GitHub handles approved to bypass contribution auto-close
|
||||
# Format: <username> <capability>
|
||||
# capability:
|
||||
# issue future issues stay open
|
||||
# pr future issues and PRs stay open
|
||||
# Maintainers add people by replying `lgtmi` or `lgtm` on an issue
|
||||
# (.github/workflows/approve-contributor.yml); editing this file by hand works too.
|
||||
|
||||
fbl100 pr
|
||||
@@ -0,0 +1 @@
|
||||
ko_fi: alexsouthwell
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Bug report
|
||||
description: Report something that's broken
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Before you start:** read [CONTRIBUTING.md](https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md).
|
||||
|
||||
Issues from new contributors are auto-closed by default. A maintainer reviews them and reopens worthwhile ones. The [website feedback form](https://frame-control.pages.dev/feedback/) skips that queue.
|
||||
|
||||
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Be specific. Include error messages and the last lines of the server log (Frame → Show Server Log).
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Minimal steps to trigger the bug.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Frame Control version
|
||||
description: e.g. v0.3.1
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Computer and SteamOS build
|
||||
description: e.g. Windows 11, SteamOS 20260922.6101926 (Steam Settings → System)
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feedback form (no GitHub account needed, skips the queue)
|
||||
url: https://frame-control.pages.dev/feedback/
|
||||
about: Bugs, ideas and questions from the website become issues here without being auto-closed.
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Idea or contribution proposal
|
||||
description: Propose a change or feature (required for new contributors before opening a PR)
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Before you start:** read [CONTRIBUTING.md](https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md).
|
||||
|
||||
Issues from new contributors are auto-closed by default. A maintainer reviews them and reopens worthwhile ones.
|
||||
|
||||
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What do you want to change?
|
||||
description: Be specific and concise.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why?
|
||||
description: What problem does this solve?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: how
|
||||
attributes:
|
||||
label: How? (optional)
|
||||
description: Brief technical approach, and whether you'd like to implement it yourself.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,238 @@
|
||||
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
|
||||
# See CONTRIBUTING.md for how it works.
|
||||
|
||||
name: Approve Contributor
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
# Only maintainers' comments that might approve someone join the queue, and
|
||||
# they run one at a time so two lgtm replies can't race on APPROVED_CONTRIBUTORS.
|
||||
# (The script below still checks for write access.)
|
||||
if: >-
|
||||
contains(github.event.comment.body, 'lgtm') &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
|
||||
concurrency:
|
||||
group: approve-contributor
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Update contributor approval
|
||||
id: update
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
|
||||
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
const commenter = context.payload.comment.user.login;
|
||||
const commentBody = (context.payload.comment.body || '').trim();
|
||||
|
||||
const approvalAtStartPattern = /^[\s.]*(?:@[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\s*,\s*|[.:]\s*|\s+))*(lgtmi|lgtm)(?=$|[\s]|[^\p{L}\p{N}_\s])/iu;
|
||||
const approvalAtEndPattern = /(?:^|[\s.])(lgtmi|lgtm)\s*(?:[^\p{L}\p{N}_\s])?\s*$/iu;
|
||||
const approvalMatch = commentBody.match(approvalAtStartPattern) ?? commentBody.match(approvalAtEndPattern);
|
||||
|
||||
if (!approvalMatch) {
|
||||
console.log('Comment does not start or end with lgtm or lgtmi');
|
||||
core.setOutput('status', 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetCapability = approvalMatch[1].toLowerCase() === 'lgtmi' ? 'issue' : 'pr';
|
||||
|
||||
try {
|
||||
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: commenter,
|
||||
});
|
||||
|
||||
if (!['admin', 'maintain', 'write'].includes(permissionLevel.permission)) {
|
||||
console.log(`${commenter} does not have write access`);
|
||||
core.setOutput('status', 'skipped');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
console.log(`${commenter} does not have collaborator access`);
|
||||
core.setOutput('status', 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
function parseMentionedUsers(body) {
|
||||
const users = [];
|
||||
const seenUsers = new Set();
|
||||
const mentionPattern = /(^|[^A-Za-z0-9_])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)(?![A-Za-z0-9-]|\/)/g;
|
||||
|
||||
for (const match of body.matchAll(mentionPattern)) {
|
||||
const username = match[2];
|
||||
const normalizedUser = username.toLowerCase();
|
||||
if (seenUsers.has(normalizedUser)) {
|
||||
continue;
|
||||
}
|
||||
seenUsers.add(normalizedUser);
|
||||
users.push(username);
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
function parseApprovedUsers(content) {
|
||||
const lines = content.split('\n');
|
||||
const entries = [];
|
||||
const users = new Map();
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
entries.push({ type: 'other', line });
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length !== 2) {
|
||||
console.log(`Skipping malformed line: ${line}`);
|
||||
entries.push({ type: 'other', line });
|
||||
continue;
|
||||
}
|
||||
|
||||
const [username, capability] = parts;
|
||||
const normalizedCapability = capability.toLowerCase();
|
||||
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
|
||||
console.log(`Skipping line with invalid capability: ${line}`);
|
||||
entries.push({ type: 'other', line });
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedUser = username.toLowerCase();
|
||||
const entry = { type: 'user', username, normalizedUser, capability: normalizedCapability };
|
||||
entries.push(entry);
|
||||
users.set(normalizedUser, entry);
|
||||
}
|
||||
|
||||
return { entries, users };
|
||||
}
|
||||
|
||||
function stringifyApprovedUsers(entries) {
|
||||
const normalizedEntries = [...entries];
|
||||
|
||||
while (normalizedEntries.length > 0) {
|
||||
const lastEntry = normalizedEntries[normalizedEntries.length - 1];
|
||||
if (lastEntry.type !== 'other' || lastEntry.line.trim() !== '') {
|
||||
break;
|
||||
}
|
||||
normalizedEntries.pop();
|
||||
}
|
||||
|
||||
return `${normalizedEntries
|
||||
.map((entry) => (entry.type === 'user' ? `${entry.username} ${entry.capability}` : entry.line))
|
||||
.join('\n')}\n`;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(APPROVED_FILE, 'utf8');
|
||||
const { entries, users } = parseApprovedUsers(content);
|
||||
const mentionedUsers = parseMentionedUsers(commentBody);
|
||||
const approvalTargets = mentionedUsers.length > 0 ? mentionedUsers : [issueAuthor];
|
||||
const changedTargets = [];
|
||||
const alreadyTargets = [];
|
||||
|
||||
for (const username of approvalTargets) {
|
||||
const normalizedUser = username.toLowerCase();
|
||||
const existingEntry = users.get(normalizedUser);
|
||||
const existingCapability = existingEntry?.capability ?? null;
|
||||
|
||||
if (existingCapability === 'pr' || existingCapability === targetCapability) {
|
||||
alreadyTargets.push(existingEntry?.username ?? username);
|
||||
console.log(`${username} is already approved for ${existingCapability}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingEntry) {
|
||||
existingEntry.capability = targetCapability;
|
||||
changedTargets.push(existingEntry.username);
|
||||
} else {
|
||||
const entry = { type: 'user', username, normalizedUser, capability: targetCapability };
|
||||
entries.push(entry);
|
||||
users.set(normalizedUser, entry);
|
||||
changedTargets.push(username);
|
||||
}
|
||||
|
||||
console.log(`Set ${username} capability to ${targetCapability}`);
|
||||
}
|
||||
|
||||
core.setOutput('capability', targetCapability);
|
||||
core.setOutput('changed_targets', JSON.stringify(changedTargets));
|
||||
core.setOutput('already_targets', JSON.stringify(alreadyTargets));
|
||||
|
||||
if (changedTargets.length === 0) {
|
||||
core.setOutput('status', 'already');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(APPROVED_FILE, stringifyApprovedUsers(entries));
|
||||
core.setOutput('status', 'changed');
|
||||
|
||||
- name: Commit and push
|
||||
if: steps.update.outputs.status == 'changed'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add .github/APPROVED_CONTRIBUTORS
|
||||
git diff --staged --quiet || git commit -m "chore: approve contributors from issue #${{ github.event.issue.number }}"
|
||||
# main may have moved since checkout; replay the approval on top of it.
|
||||
git pull --rebase origin "${{ github.event.repository.default_branch }}"
|
||||
git push
|
||||
|
||||
- name: Comment on issue
|
||||
if: steps.update.outputs.status == 'changed' || steps.update.outputs.status == 'already'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
CAPABILITY: ${{ steps.update.outputs.capability }}
|
||||
CHANGED_TARGETS: ${{ steps.update.outputs.changed_targets }}
|
||||
ALREADY_TARGETS: ${{ steps.update.outputs.already_targets }}
|
||||
with:
|
||||
script: |
|
||||
const capability = process.env.CAPABILITY;
|
||||
const changedTargets = JSON.parse(process.env.CHANGED_TARGETS || '[]');
|
||||
const alreadyTargets = JSON.parse(process.env.ALREADY_TARGETS || '[]');
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
const formatTargets = (targets) => targets.map((target) => `@${target}`).join(', ');
|
||||
const bodyLines = [];
|
||||
|
||||
if (changedTargets.length > 0) {
|
||||
if (capability === 'issue') {
|
||||
bodyLines.push(`${formatTargets(changedTargets)} approved for issues. Future issues will not be auto-closed. PRs still require \`lgtm\` at the start of a maintainer reply (optionally after one or more \`@username\` mentions) or at the end.`);
|
||||
} else {
|
||||
bodyLines.push(`${formatTargets(changedTargets)} approved for issues and PRs. Future issues and PRs will not be auto-closed.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (alreadyTargets.length > 0) {
|
||||
const verb = alreadyTargets.length === 1 ? 'is' : 'are';
|
||||
bodyLines.push(`${formatTargets(alreadyTargets)} ${verb} already approved.`);
|
||||
}
|
||||
|
||||
bodyLines.push('', `See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`);
|
||||
const body = bodyLines.join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ jobs:
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
- name: Script syntax
|
||||
run: |
|
||||
sh -n ui/local-bin/ssh
|
||||
for f in scripts/*.sh frame/*/*.sh; do
|
||||
case "$(head -n 1 "$f")" in
|
||||
*zsh*) zsh -n "$f" ;;
|
||||
@@ -28,13 +29,15 @@ jobs:
|
||||
done
|
||||
- name: Python compiles
|
||||
run: |
|
||||
python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.py
|
||||
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
|
||||
- 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.
|
||||
@@ -57,3 +60,28 @@ jobs:
|
||||
python-version: ${{ matrix.python }}
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
|
||||
# The iPhone app: builds for the Simulator and runs its unit tests.
|
||||
ios:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Generate the project
|
||||
run: brew install xcodegen && cd ios && xcodegen generate
|
||||
- name: Build and test
|
||||
run: |
|
||||
cd ios
|
||||
udid=$(xcrun simctl list devices available -j | python3 -c 'import json,sys; d=json.load(sys.stdin)["devices"]; print(next(x["udid"] for r in d for x in d[r] if x["name"].startswith("iPhone")))')
|
||||
xcodebuild -project FrameControl.xcodeproj -scheme FrameControl -destination "platform=iOS Simulator,id=$udid" CODE_SIGNING_ALLOWED=NO test
|
||||
|
||||
# End-to-end tests against the fake Frame (tests/fakeframe): Arch Linux ARM
|
||||
# in Docker, on a native arm64 runner like the headset. See docs/testing.md.
|
||||
e2e:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
- name: End-to-end tests
|
||||
run: scripts/e2e.sh
|
||||
@@ -0,0 +1,134 @@
|
||||
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
|
||||
# See CONTRIBUTING.md for how it works.
|
||||
|
||||
name: Issue Gate
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
check-contributor:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Check issue author
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
|
||||
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
|
||||
const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']);
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
const isBotAuthor = issueAuthor.endsWith('[bot]');
|
||||
|
||||
if (TRUSTED_BOT_AUTHORS.has(issueAuthor)) {
|
||||
console.log(`Skipping trusted bot: ${issueAuthor}`);
|
||||
return;
|
||||
}
|
||||
|
||||
async function getPermission(username) {
|
||||
try {
|
||||
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username,
|
||||
});
|
||||
return permissionLevel.permission;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTextFile(path) {
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path,
|
||||
ref: defaultBranch,
|
||||
});
|
||||
|
||||
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
|
||||
throw new Error(`Expected file content for ${path}`);
|
||||
}
|
||||
|
||||
return Buffer.from(fileContent.content, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
function parseApprovedUsers(content) {
|
||||
const users = new Map();
|
||||
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length !== 2) {
|
||||
console.log(`Skipping malformed line: ${rawLine}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [username, capability] = parts;
|
||||
const normalizedCapability = capability.toLowerCase();
|
||||
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
|
||||
console.log(`Skipping line with invalid capability: ${rawLine}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
users.set(username.toLowerCase(), normalizedCapability);
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
const permission = await getPermission(issueAuthor);
|
||||
if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) {
|
||||
console.log(`${issueAuthor} is a collaborator with ${permission} access`);
|
||||
return;
|
||||
}
|
||||
|
||||
const approvedContent = await getTextFile(APPROVED_FILE);
|
||||
const approvedUsers = parseApprovedUsers(approvedContent);
|
||||
const capability = approvedUsers.get(issueAuthor.toLowerCase());
|
||||
|
||||
if (!isBotAuthor && (capability === 'issue' || capability === 'pr')) {
|
||||
console.log(`${issueAuthor} is approved for ${capability}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = [
|
||||
'This issue was auto-closed. All issues from new contributors are auto-closed by default.',
|
||||
'',
|
||||
`Maintainers review auto-closed issues regularly and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
|
||||
'',
|
||||
'Just want to report a bug or share an idea? The [website feedback form](https://frame-control.pages.dev/feedback/) skips this queue.',
|
||||
'',
|
||||
'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open. The command must be at the start of the reply (optionally after one or more `@username` mentions) or at the end.',
|
||||
'',
|
||||
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: message,
|
||||
});
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['untriaged'],
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
|
||||
# See CONTRIBUTING.md for how it works.
|
||||
|
||||
name: Issue Triage Labels
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [reopened, labeled]
|
||||
|
||||
jobs:
|
||||
update-labels:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Update triage labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const UNTRIAGED_LABEL = 'untriaged';
|
||||
const NO_ACTION_LABEL = 'no-action';
|
||||
const LAST_READ_LABEL = 'last-read';
|
||||
const TO_DISCUSS_LABEL = 'to-discuss';
|
||||
const INPROGRESS_LABEL = 'inprogress';
|
||||
|
||||
function issueHasLabel(issue, labelName) {
|
||||
return (issue.labels ?? []).some((label) => label.name === labelName);
|
||||
}
|
||||
|
||||
async function removeLabelIfPresent(issueNumber, issue, labelName) {
|
||||
if (!issueHasLabel(issue, labelName)) {
|
||||
console.log(`Issue #${issueNumber} does not have ${labelName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
name: labelName,
|
||||
});
|
||||
console.log(`Removed ${labelName} from #${issueNumber}`);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
console.log(`Label ${labelName} was already absent from #${issueNumber}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (context.payload.action === 'reopened') {
|
||||
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
|
||||
await removeLabelIfPresent(context.issue.number, context.payload.issue, NO_ACTION_LABEL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.payload.action === 'labeled' && context.payload.label?.name === NO_ACTION_LABEL) {
|
||||
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.payload.action !== 'labeled' || context.payload.label?.name !== LAST_READ_LABEL) {
|
||||
console.log('Not a last-read label event');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIssueNumber = context.issue.number;
|
||||
const lastReadIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'all',
|
||||
labels: LAST_READ_LABEL,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const previousIssueNumbers = lastReadIssues
|
||||
.filter((issue) => !issue.pull_request)
|
||||
.map((issue) => issue.number)
|
||||
.filter((issueNumber) => issueNumber !== currentIssueNumber);
|
||||
|
||||
if (previousIssueNumbers.length === 0) {
|
||||
console.log('No previous last-read issue found');
|
||||
return;
|
||||
}
|
||||
|
||||
const previousIssueNumber = Math.max(...previousIssueNumbers);
|
||||
if (currentIssueNumber <= previousIssueNumber) {
|
||||
console.log(
|
||||
`Last-read was added to old issue #${currentIssueNumber}; latest last-read is #${previousIssueNumber}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const untriagedIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'all',
|
||||
labels: UNTRIAGED_LABEL,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const issuesToMark = untriagedIssues
|
||||
.filter((issue) => !issue.pull_request)
|
||||
.filter((issue) => issue.number >= previousIssueNumber && issue.number <= currentIssueNumber)
|
||||
.sort((a, b) => a.number - b.number);
|
||||
|
||||
if (issuesToMark.length === 0) {
|
||||
console.log(`No untriaged issues found from #${previousIssueNumber} to #${currentIssueNumber}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const issue of issuesToMark) {
|
||||
if (issueHasLabel(issue, TO_DISCUSS_LABEL)) {
|
||||
console.log(`Skipped ${NO_ACTION_LABEL} for #${issue.number} because it has ${TO_DISCUSS_LABEL}`);
|
||||
} else {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [NO_ACTION_LABEL],
|
||||
});
|
||||
console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`);
|
||||
}
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
console.log(`Closed #${issue.number} as not planned`);
|
||||
|
||||
await removeLabelIfPresent(issue.number, issue, INPROGRESS_LABEL);
|
||||
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
name: UNTRIAGED_LABEL,
|
||||
});
|
||||
console.log(`Removed ${UNTRIAGED_LABEL} from #${issue.number}`);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
|
||||
# See CONTRIBUTING.md for how it works.
|
||||
|
||||
name: PR Gate
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
check-contributor:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check if contributor is approved
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
|
||||
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
|
||||
const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']);
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
const isBotAuthor = prAuthor.endsWith('[bot]');
|
||||
|
||||
if (TRUSTED_BOT_AUTHORS.has(prAuthor)) {
|
||||
console.log(`Skipping trusted bot: ${prAuthor}`);
|
||||
return;
|
||||
}
|
||||
|
||||
async function getPermission(username) {
|
||||
try {
|
||||
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username,
|
||||
});
|
||||
return permissionLevel.permission;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTextFile(path) {
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path,
|
||||
ref: defaultBranch,
|
||||
});
|
||||
|
||||
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
|
||||
throw new Error(`Expected file content for ${path}`);
|
||||
}
|
||||
|
||||
return Buffer.from(fileContent.content, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
function parseApprovedUsers(content) {
|
||||
const users = new Map();
|
||||
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length !== 2) {
|
||||
console.log(`Skipping malformed line: ${rawLine}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [username, capability] = parts;
|
||||
const normalizedCapability = capability.toLowerCase();
|
||||
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
|
||||
console.log(`Skipping line with invalid capability: ${rawLine}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
users.set(username.toLowerCase(), normalizedCapability);
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
async function closePullRequest(message) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body: message,
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
|
||||
const permission = await getPermission(prAuthor);
|
||||
if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) {
|
||||
console.log(`${prAuthor} is a collaborator with ${permission} access`);
|
||||
return;
|
||||
}
|
||||
|
||||
const approvedContent = await getTextFile(APPROVED_FILE);
|
||||
const approvedUsers = parseApprovedUsers(approvedContent);
|
||||
const capability = approvedUsers.get(prAuthor.toLowerCase());
|
||||
|
||||
if (!isBotAuthor && capability === 'pr') {
|
||||
console.log(`${prAuthor} is approved for PRs`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${prAuthor} is not approved, closing PR`);
|
||||
|
||||
const message = [
|
||||
'This PR was auto-closed. Only contributors approved with `lgtm` can open PRs. Open an issue first and ask a maintainer for approval.',
|
||||
'',
|
||||
`Maintainers review auto-closed issues regularly. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
|
||||
'',
|
||||
'If a maintainer replies `lgtmi`, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open. The command must be at the start of the reply (optionally after one or more `@username` mentions) or at the end.',
|
||||
'',
|
||||
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
|
||||
].join('\n');
|
||||
|
||||
await closePullRequest(message);
|
||||
@@ -0,0 +1,34 @@
|
||||
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
|
||||
# See CONTRIBUTING.md for how it works.
|
||||
|
||||
name: Remove In Progress Label On Close
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
remove-label:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove inprogress label
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const labelName = 'inprogress';
|
||||
const labels = context.payload.issue.labels ?? [];
|
||||
const hasLabel = labels.some((label) => label.name === labelName);
|
||||
|
||||
if (!hasLabel) {
|
||||
console.log(`Issue does not have ${labelName} label`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: labelName,
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
apk-catalog/data/cache/
|
||||
apk-catalog/data/index-v2.json*
|
||||
apk-catalog/data/index-v2*.json*
|
||||
compat-db/.env.lakebed.server
|
||||
compat-db/.lakebed/
|
||||
tests/smoke/results/
|
||||
@@ -0,0 +1,71 @@
|
||||
# Contributing to Frame Control
|
||||
|
||||
This guide exists to save both sides time. The process is borrowed from
|
||||
[pi](https://github.com/badlogic/pi-mono/blob/main/CONTRIBUTING.md).
|
||||
|
||||
## Just want to report something?
|
||||
|
||||
Use the [feedback form](https://frame-control.pages.dev/feedback/). It needs no
|
||||
GitHub account, and what you send becomes an issue here that stays open.
|
||||
|
||||
## The One Rule
|
||||
|
||||
**You must understand your code.** If you can't explain what your change does
|
||||
and how it interacts with the rest of the app, your PR will be closed.
|
||||
|
||||
Using AI to write code is fine. Submitting AI-generated slop you don't
|
||||
understand is not.
|
||||
|
||||
## Contribution gate
|
||||
|
||||
Issues and PRs opened on GitHub by new contributors are auto-closed by default.
|
||||
A maintainer reviews auto-closed issues regularly and reopens worthwhile ones.
|
||||
Issues that don't meet the quality bar below won't be reopened or get a reply.
|
||||
|
||||
Approval happens through maintainer replies on issues:
|
||||
|
||||
- `lgtmi`: your future issues won't be auto-closed
|
||||
- `lgtm`: your future issues and PRs won't be auto-closed
|
||||
|
||||
The word must be at the start of the reply (optionally after one or more
|
||||
`@username` mentions) or at the end. Only `lgtm` lets you open PRs. Approved
|
||||
people are listed in [`.github/APPROVED_CONTRIBUTORS`](.github/APPROVED_CONTRIBUTORS).
|
||||
|
||||
## Quality bar for issues
|
||||
|
||||
Use one of the issue templates, and keep it short, concrete and worth reading.
|
||||
|
||||
- If it doesn't fit on one screen, it's too long.
|
||||
- Write in your own voice. If you must use an LLM, say so in a clearly labelled
|
||||
follow-up comment.
|
||||
- State the bug or request clearly, and why it matters.
|
||||
- For bugs, include your OS, your SteamOS build (Steam Settings → System), and
|
||||
the server log (**Frame → Show Server Log** in the app).
|
||||
- If you want to implement the change yourself, say so.
|
||||
|
||||
## Before opening a PR
|
||||
|
||||
Don't open a PR until a maintainer has approved you with `lgtm`. Open an
|
||||
[idea or contribution proposal](https://github.com/saphid/frame-control/issues/new?template=idea.yml)
|
||||
first.
|
||||
|
||||
Then check your change:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests # server tests; no headset needed
|
||||
node --test site/test/*.test.mjs # website feedback function
|
||||
```
|
||||
|
||||
Say what you tested, and whether you tried it on a real Steam Frame.
|
||||
|
||||
## Blocking
|
||||
|
||||
If you ignore this document twice, or spam the tracker with agent-generated
|
||||
issues, your GitHub account will be blocked from the repo.
|
||||
|
||||
## Why auto-close?
|
||||
|
||||
This is a hobby project with one maintainer. Auto-closing is a buffer against
|
||||
burnout and tracker spam: issues get reviewed on the maintainer's schedule, and
|
||||
the good ones are reopened. Short, concrete, reproducible reports and thoughtful
|
||||
contributions are welcome.
|
||||
@@ -12,11 +12,16 @@ See what the headset sees, install games and Android apps, move files and text a
|
||||
[](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
|
||||
[](LICENSE)
|
||||
|
||||
[**Download**](#install) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
|
||||
[**Website**](https://frame-control.pages.dev) · [**Download**](#install) · [Trailer](#trailer) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
|
||||
|
||||
<br>
|
||||
|
||||
<img src="docs/img/frame-control.png" alt="Frame Control showing the headset view, battery and status, and the Steam library" width="900">
|
||||
<img src="docs/img/frame-control.png" alt="Frame Control's Games tab: installed games, sideloaded titles, and your Steam library with Frame ratings" width="900">
|
||||
|
||||
<a id="trailer"></a>
|
||||
<a href="https://github.com/saphid/steam-frame/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
|
||||
|
||||
<sub>The trailer: 66 seconds, with sound. Downloads the MP4 from the trailer release.</sub>
|
||||
|
||||
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
|
||||
|
||||
@@ -98,6 +103,9 @@ already ships (sideloading a game copies Valve's own devkit scripts to
|
||||
| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
|
||||
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
|
||||
|
||||
**iPhone and iPad:** the same features from your phone, with nothing to install on
|
||||
a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/iphone.md).
|
||||
|
||||
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
|
||||
Google doesn't publish `adb` for arm64 Linux, so that build uses your
|
||||
distribution's. If you already have `adb`, the app uses yours.
|
||||
@@ -167,13 +175,17 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
|
||||
## Feedback
|
||||
|
||||
This is a first public test, so reports are really useful, especially from
|
||||
Windows and Linux. Please [open an issue](https://github.com/saphid/steam-frame/issues/new)
|
||||
with:
|
||||
Windows and Linux. The quickest way is the
|
||||
[feedback form](https://frame-control.pages.dev/feedback/): no GitHub account
|
||||
needed, and it opens an issue here. Please include:
|
||||
|
||||
- what you tried and what happened
|
||||
- your computer's OS and your SteamOS build (Steam Settings → System)
|
||||
- the server log: **Frame → Show Server Log** in the app
|
||||
|
||||
Issues and PRs opened directly on GitHub by new contributors are auto-closed
|
||||
until a maintainer approves them; see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Going further
|
||||
|
||||
This repo also holds the scripts behind the app and field notes on how the
|
||||
@@ -190,6 +202,10 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [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 |
|
||||
| [Eye tracking and heart rate](docs/tracking.md) | Our OpenXR → OSC bridge, BlueZ heart-rate panel and optional local session log; SlimeVR feasibility notes |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
|
||||
| [Open questions](docs/open-questions.md) | What's still unchecked |
|
||||
|
||||
<details>
|
||||
@@ -216,6 +232,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
|
||||
@@ -243,6 +243,7 @@ function fromUi(e) {
|
||||
}
|
||||
|
||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||
|
||||
// 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),
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
||||
// as a title without zipping it (the local server reads it from there).
|
||||
// It can open Set Up Connection when the headset can't be reached.
|
||||
// It also receives frame-control://install links (docs/web-install.md): only
|
||||
// what the link asked for, never an install; the page asks the user first.
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
||||
onInstallLink: (cb) => {
|
||||
ipcRenderer.removeAllListeners("install-link");
|
||||
|
||||
@@ -46,6 +46,33 @@ 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
|
||||
|
||||
@@ -17,6 +17,15 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
|
||||
## Features
|
||||
|
||||
The window has four tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports) and **Tools** (sending files and text,
|
||||
Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
|
||||
anywhere in the window. When the Frame can't be reached, one banner says why in
|
||||
plain words and the app retries every few seconds, filling everything in once it
|
||||
answers. Flatpak and Android installs run in the background; the bottom bar
|
||||
counts them while they run.
|
||||
|
||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
||||
floating panels, dashboard and controllers). Shows the left eye, like pointing
|
||||
a camera into one lens, or both eyes, as a single shot; saves as PNG. **Live**
|
||||
|
||||
@@ -20,6 +20,15 @@ SteamVR (vrserver, vrcompositor, vrdashboard) ← renders the room + pane
|
||||
Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 3056000
|
||||
```
|
||||
|
||||
## Tracking additions (verified 2026-09-28)
|
||||
|
||||
On SteamOS 0.4.1, build `20260925.6191901`, SteamVR exposes combined gaze
|
||||
through `XR_EXT_eye_gaze_interaction` in a headless OpenXR 1.0 session. Our
|
||||
reader obtained valid tracked samples and sent OSC to a configured loopback
|
||||
receiver. BlueZ LE discovery works; GTK4/GI can render our heart-rate panel.
|
||||
No BLE strap or SlimeVR trackers were attached. See [tracking](tracking.md)
|
||||
for the evidence, privacy defaults and untested integration boundaries.
|
||||
|
||||
## Facts worth knowing
|
||||
|
||||
| Fact | Where it matters |
|
||||
@@ -51,7 +60,13 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
|
||||
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
|
||||
| Power actions need `sudo`, which asks for the Developer Mode password over SSH. | Frame Control's power buttons |
|
||||
| **Boot / recovery menu.** Hold Power ~10 s until the LED goes off, then power on while holding the **AUX button on top of the Power button** (not the volume keys) until a text menu appears. Entries: `Current` (SteamOS-A/B + build), `Previous` (the other A/B slot), `Boot from USB`, `Repair Steam Installation`, `Erase User Data` (factory reset), `ADB mode`, `Battery Ship Mode`. It auto-boots `Current` after a ~15 s countdown. **Volume Up/Down (left side) move, AUX (right side) selects.** For a boot loop, Valve says pick `Previous` (keeps user data); then `Repair Steam Installation`; `Erase User Data` wipes `~` (SSH keys, Tailscale, Flatpaks, T3 setup). Last resort is a full re-image, two ways: (1) USB: write `steamframe-repair-latest.img.bz2` to an 8 GB+ USB-C stick (Balena Etcher on the Mac), pick `Boot from USB`, then use "Wipe Device & Install SteamOS" / "Repair SteamOS" (keeps games and personal content) from the recovery desktop; (2) cable/EDL: `steamframe-repair-qdl-latest.tar.gz`, run `flash.sh` (Linux) or `flash.cmd` (Windows), then with the Frame off for 10 s hold Power + Vol Up + Vol Down for 10 s and plug it in; it reflashes and reboots. Both images: `https://steamdeck-images.steamos.cloud/recovery/` (build 20260922.5153644, 0.3.0, ~4 GB each, no published checksums); local copies in `~/Downloads/steam-frame-recovery/`. Source: Valve's [SteamOS Recovery FAQ](https://help.steampowered.com/en/faqs/view/1B71-EDF2-EB6D-2BB3) and [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227), plus a menu photo in [EloiStree/HelloSteamFrame#9](https://github.com/EloiStree/HelloSteamFrame/issues/9). **Inferred** (Valve docs, 2026-09-26); not yet tried on our Frame. | Recovering from a boot loop |
|
||||
| **SSH server:** OpenSSH 9.7p1. It offers `publickey,password` (keyboard-interactive is off, PAM on) and also asks `userdbctl ssh-authorized-keys` for keys. OpenSSH ≥ 8.8 rejects SHA-1 `ssh-rsa` signatures by default, so a client whose RSA support is SHA-1 only (the Swift library Citadel, for one) can't log in with the RSA key that devkit pairing installs; use ed25519 (**inferred** from OpenSSH defaults). **Verified 2026-09-27**, BUILD_ID 20260922.6101926. | [iphone.md](iphone.md), `ui/frame_connect.py` |
|
||||
| **Tools on the image:** Python 3.12.3, `ffmpeg`, `openssl`, `curl`, `rsync`, `zip`/`unzip`, `flatpak`, `wpctl`, `podman`. **No `adb`.** `steamos` is uid 1000, in `wheel`, and sudoers has `%wheel ALL=(ALL) ALL`, so `sudo -S` takes the Developer Mode password on stdin. **Verified 2026-09-27.** | Running Frame Control's server on the Frame (`FRAME_LOCAL=1`, [iphone.md](iphone.md)) |
|
||||
| **Each Lepton instance is a podman container** named `lepton-steamlaunch-<instance id>`, labelled with its ADB port (`podman ps --format '{{.Names}} {{.Labels.adb_port}}'`). `podman exec <container> /system/bin/sh -c '…'` runs Android's shell inside it with no adb at all (used for `pidof` and `logcat` by the app tester). Running `wm size`/`wm density` that way is untested. **Verified 2026-09-27.** | `ui/frame_android.py`, the iPhone app's display settings |
|
||||
| **Asleep means off the network.** In standby the Frame stops answering on its LAN address, `frame.local` and Tailscale alike (`Host is down`, `No route to host`, timeouts), and ping fails. It was unreachable for about 2.5 hours until woken. Nothing over SSH can wake it. **Verified 2026-09-27.** | Frame Control's offline banner and retries |
|
||||
| **Battery at full on a charger** can read `Discharging` at about 0 W (for example 99 %, 0.0 W, USB-C PD 18 W). Treat under 0.5 W on a charger as "not charging", not "draining". **Verified 2026-09-27.** | Frame Control's battery card |
|
||||
| **The OS image is downloadable.** Valve's recovery images for the Frame are at `https://steamdeck-images.steamos.cloud/recovery/`. The root filesystem inside is btrfs, and it runs as an SSH test target on ARM64 Linux without the headset (`tests/frame-container/frame-image.sh`). **Verified 2026-09-27.** | [recovery-and-images.md](recovery-and-images.md) |
|
||||
| **Boot / recovery menu.** Hold Power ~10 s until the LED goes off, then power on while holding the **AUX button on top of the Power button** (not the volume keys) until a text menu appears. Entries: `Current` (SteamOS-A/B + build), `Previous` (the other A/B slot), `Boot from USB`, `Repair Steam Installation`, `Erase User Data` (factory reset), `ADB mode`, `Battery Ship Mode`. It auto-boots `Current` after a ~15 s countdown. **Volume Up/Down (left side) move, AUX (right side) selects.** For a boot loop, Valve says pick `Previous` (keeps user data); then `Repair Steam Installation`; `Erase User Data` wipes `~` (SSH keys, Tailscale, Flatpaks, T3 setup). Last resort is a full re-image, two ways: (1) USB: write `steamframe-oobe-repair-<build>.img.bz2` to an 8 GB+ USB-C stick (Balena Etcher on the Mac), pick `Boot from USB`, then use "Wipe Device & Install SteamOS" / "Repair SteamOS" (keeps games and personal content) from the recovery desktop; (2) cable/EDL: `steamframe-oobe-repair-qdl-<build>.tar.gz`, run `flash.sh` (Linux) or `flash.cmd` (Windows), then with the Frame off for 10 s hold Power + Vol Up + Vol Down for 10 s and plug it in; it reflashes and reboots. Both images: `https://steamdeck-images.steamos.cloud/recovery/` (build 20260922.5153644, 0.3.0, 3.8 GiB each, no published checksums); local copies in `~/Downloads/steam-frame-recovery/`. File names, checksums and what's inside: [recovery-and-images.md](recovery-and-images.md). Source: Valve's [SteamOS Recovery FAQ](https://help.steampowered.com/en/faqs/view/1B71-EDF2-EB6D-2BB3) and [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227), plus a menu photo in [EloiStree/HelloSteamFrame#9](https://github.com/EloiStree/HelloSteamFrame/issues/9). **Inferred** (Valve docs, 2026-09-26); not yet tried on our Frame. | Recovering from a boot loop |
|
||||
| **Boot loop cause: the SteamVR health check.** `steamvr.service` runs `/usr/share/deckard/steamvr-health-check`, which appends `frog:glasses:` to `$XDG_RUNTIME_DIR/steamvr-short-session-tracker` on every failed or <10 s SteamVR run. At 3 it runs `steam-health-check --repair-now`, which **deletes all of `~/.local/share/Steam` (games, login, Developer Mode) and `~/.steam`**, keeping only `registry.vdf`. At 4 it also tries `steamos-bootconf set-mode reboot-other` (fails as the user: `bootenv: Permission denied`). SteamVR normally fails 1–2 times per boot while it waits for the Steam client (`SteamAPI_InitEx failed … Steam is probably not running`, then `fatal stalled cross-thread pipe`). Once Steam has been wiped, it has to re-download a ~210 MB client on every boot, so SteamVR keeps failing, Steam keeps getting wiped and the Frame reboots, in a loop. Also, the Steam updater can deadlock at `Installing update...` (main process blocked writing to the `-child-update-ui` process, which is stuck in `drm_syncobj_array_wait_timeout`). Killing only the `-child-update-ui` process lets the install finish (`package/*.installed` appears). **Fix without sudo:** over USB-C ADB (`adb -s frame shell` works as `steamos` while the Frame is looping; SSH is refused once Developer Mode is lost), truncate both `/run/user/1000/steam{,vr}-short-session-tracker` files and `chmod 444` them (the health check then logs `Permission denied` and does nothing; this is tmpfs, so it resets on reboot). Unstick the updater if needed, let Steam finish installing, then hold Power 10 s and start the Frame normally. `systemctl reboot` over ADB needs interactive auth. After the fix, sign in to Steam and turn Developer Mode back on. **Verified 2026-09-26**, BUILD_ID 20260922.6101926, slot B (clean boot: 0 SteamVR failures, SSH and Tailscale back). | Diagnosing a boot loop |
|
||||
|
||||
## Debug recipes
|
||||
@@ -79,4 +94,6 @@ ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashbo
|
||||
- Installing and buying Steam games: [steam-games.md](steam-games.md)
|
||||
- Remote access from anywhere: [tailscale.md](tailscale.md)
|
||||
- Floating windows in space: [panels.md](panels.md)
|
||||
- Recovery images, what's in them, testing without the headset: [recovery-and-images.md](recovery-and-images.md)
|
||||
- Frame Control on iPhone (the server running on the Frame itself): [iphone.md](iphone.md)
|
||||
- What's still unverified: [open-questions.md](open-questions.md)
|
||||
|
Before Width: | Height: | Size: 892 KiB After Width: | Height: | Size: 824 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 125 KiB |
@@ -0,0 +1,109 @@
|
||||
# Frame Control for iPhone
|
||||
|
||||
The iPhone (and iPad) app does what the desktop app does, from the phone:
|
||||
headset view and live video, battery and status, screenshots, Steam games,
|
||||
Android apps and their display settings, sideloading, files, clipboard,
|
||||
Flatpaks, and power. Source: [`ios/`](../ios).
|
||||
|
||||
## How it works
|
||||
|
||||
An iPhone can't run Python or `ssh`, but the Frame can. So the app:
|
||||
|
||||
1. connects to the Frame over SSH itself (the [Citadel](https://github.com/orlandos-nl/Citadel)
|
||||
Swift SSH library), with its own ed25519 key from the Keychain;
|
||||
2. copies Frame Control's server and helpers (`ios/scripts/make_frame_bundle.py`,
|
||||
under 1 MB) to `~/.cache/frame-control/<version>` on the Frame, once per version;
|
||||
3. starts `ui/server.py` there with `FRAME_LOCAL=1`. It listens only on the
|
||||
Frame's own 127.0.0.1, and it stops when the phone disconnects (`--exit-on-eof`);
|
||||
4. tunnels to it through the SSH session and shows the same page as the desktop
|
||||
app, in a web view. The page carries a fresh key each session, which the
|
||||
server requires on every request.
|
||||
|
||||
With `FRAME_LOCAL=1`, every `ssh frame COMMAND` the server runs goes to
|
||||
`ui/local-bin/ssh`, which runs the command on the Frame directly (rsync uses it
|
||||
as its transport too), so the desktop and phone share one code path. Android
|
||||
display settings use `podman exec` into each Lepton container instead of adb,
|
||||
which the Frame doesn't have.
|
||||
|
||||
Nothing is left running on the Frame after the phone disconnects; the copied
|
||||
files stay in `~/.cache/frame-control` (delete it any time).
|
||||
|
||||
## Pairing
|
||||
|
||||
On the Frame, turn on Developer Mode and set a user password (Steam Settings →
|
||||
System, then Developer → Set User Password). In the app, enter the headset's
|
||||
address (`frame.local`, its IP, or its Tailscale name) and that password once.
|
||||
The app adds its own key to `~/.ssh/authorized_keys` and remembers the Frame's
|
||||
host key; the password isn't saved. If you already reach the Frame over SSH,
|
||||
**Or add the key yourself** shows the phone's key to paste into
|
||||
`authorized_keys`, and connects without a password.
|
||||
|
||||
Valve's tap-to-approve devkit pairing isn't used: it only takes RSA keys, and
|
||||
the Frame's OpenSSH 9.7 rejects the SHA-1 RSA signatures the Swift SSH library
|
||||
makes.
|
||||
|
||||
## What's different on the phone
|
||||
|
||||
| Desktop | iPhone |
|
||||
|---|---|
|
||||
| Drop files anywhere | Tap **Send to Frame** (or Add a game) and pick files; folders need zipping |
|
||||
| Screenshots save to `~/Pictures/SteamFrame` | Save opens the share sheet: Save Image puts it in Photos |
|
||||
| SSH and SFTP open a terminal | They open an app that handles `ssh://` / `sftp://` (Blink Shell, Termius) |
|
||||
| Steam Link, remote desktop | Open the Steam Link and Windows App apps |
|
||||
| Sleep, restart, shut down ask in a terminal | The page asks for the Developer Mode password |
|
||||
| Compatibility reports kept on the computer | Kept on the Frame (`~/.local/share/Frame Control`) |
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
cd ios
|
||||
xcodegen generate # after changing project.yml
|
||||
open FrameControl.xcodeproj
|
||||
```
|
||||
|
||||
The build packs the Frame bundle from the checkout, so the phone always runs
|
||||
the page and server from the same commit. Running on a phone needs your own
|
||||
signing team in Xcode (Signing & Capabilities).
|
||||
|
||||
## Verified
|
||||
|
||||
<img src="img/iphone-tabs.jpg" alt="The four tabs in the iPhone app, connected to a Frame" width="900">
|
||||
|
||||
In the iOS Simulator (iOS 26.5) against a real Frame, 2026-09-27: the app connected
|
||||
with its key, copied the bundle over SFTP, started the server on the Frame and
|
||||
showed all four tabs with live data. In the app's web view, Capture returned a
|
||||
headset still and Live played H.264 video at 31 fps (WebCodecs works in
|
||||
WKWebView). Through the app's tunnel: status, games, Steam library, Android apps,
|
||||
screenshots, a file upload (checked on the Frame), a background install job, and
|
||||
the power password check (a wrong password is refused). The server on the Frame
|
||||
exits within seconds of the app closing.
|
||||
|
||||
Against Valve's own Steam Frame OS (SteamOS 0.3.0 build 20260922.5152327, the
|
||||
`rootfs-A` partition of the Frame recovery image, run with its own sshd; see
|
||||
[tests/frame-container](../tests/frame-container)), and a Holo Core stand-in:
|
||||
pairing with the password (key added with the right
|
||||
permissions, host key pinned, password stored nowhere), the power password
|
||||
check (a wrong or missing password refused; the right one reaches `systemctl`),
|
||||
a changed host key refused with "Pair with the Frame again", and a wrong
|
||||
pairing password reported the same way.
|
||||
|
||||
Also verified in the Simulator against the Frame (2026-09-27): the setup screen
|
||||
found the Frame by itself over Bonjour (`frame · 192.168.1.237`); a paired app
|
||||
waiting for a sleeping Frame connected 4 s after it answered; an upload from the
|
||||
app's web view landed in `~/Downloads`; the share sheet offers Save Image
|
||||
(needs `NSPhotoLibraryAddUsageDescription`, now declared); an install link opens
|
||||
the confirm dialog and downloads nothing until Install; Steam Link without the
|
||||
app installed opens its App Store page.
|
||||
|
||||
Things iOS asks the first time: **Local Network** (tap Allow, or the app can't
|
||||
see the Frame), and **Paste** when you send the iPhone's clipboard (tap Allow
|
||||
Paste, or set Settings → Apps → Frame Control → Paste from Other Apps → Allow).
|
||||
Sending text to the Frame's clipboard needs the desktop panel open in the
|
||||
headset, as on the desktop app.
|
||||
|
||||
Not yet exercised: Android display changes through podman (no Android app was
|
||||
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
|
||||
|
||||
Debug builds have Simulator test hooks (`FRAME_TEST_HOST`, `FRAME_TEST_PAGE`,
|
||||
`FRAME_TEST_JS`, and the tunnel URL in the app's Caches folder); release builds
|
||||
don't.
|
||||
@@ -33,14 +33,19 @@ build 20260922.6101926, kernel 6.18, aarch64):
|
||||
- **10.** `install-apps.sh remmina --vnc-host <mac>.local` installed Remmina as
|
||||
a `--user` Flatpak over SSH and wrote the profile. The desktop's
|
||||
`XDG_DATA_DIRS` includes the user Flatpak exports, so it shows up in the menu.
|
||||
The Frame can reach the Mac's Screen Sharing port (5900). The Remmina
|
||||
connection itself hasn't been tried in the headset yet (part of 11).
|
||||
The Frame can reach the Mac's Screen Sharing port (5900).
|
||||
- **11.** Answered 2026-09-27 (BUILD_ID 20260925.6191901, macOS 27.0): the
|
||||
pre-seeded profile connects and shows the Mac in its own panel. It asks for
|
||||
the Mac account login rather than the VNC password, needs scale-to-fit at
|
||||
Retina resolutions, and doesn't show the Mac cursor without
|
||||
`scripts/mac-cursor-ring.lua`. It's usable but noticeably laggy. See
|
||||
[streaming.md](streaming.md).
|
||||
|
||||
- **Panels.** An X11 window on gamescope's `:0` with its own `STEAM_GAME` id
|
||||
gets its own SteamVR overlay (`valve.steam.desktopgame.<id>`). Three were
|
||||
created side by side with `panel-on-frame.sh`. See [panels.md](panels.md).
|
||||
|
||||
Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a reboot), 17–21.
|
||||
Still open: 4, 6, 7, 12–15, 16 (off-LAN and after a reboot), 17–21.
|
||||
|
||||
## Check on the headset (in order)
|
||||
|
||||
@@ -70,12 +75,10 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a r
|
||||
`ssh frame 'command -v wl-copy xclip rsync flatpak'`.
|
||||
10. **Can Flatpaks be installed `--user` over SSH, and do they appear in the
|
||||
headset's desktop?** Test with `./scripts/install-apps.sh remmina`.
|
||||
11. **Remmina → macOS Screen Sharing:** does it connect, and is it usable at
|
||||
Retina resolutions? Is the pre-seeded profile path
|
||||
(`~/.var/app/org.remmina.Remmina/data/remmina/`) the one Remmina
|
||||
actually reads?
|
||||
12. **Moonlight Flatpak (aarch64) + Sunshine on macOS:** worth trying only if
|
||||
VNC is too slow.
|
||||
11. ~~**Remmina → macOS Screen Sharing**~~: answered 2026-09-27; see above
|
||||
and [streaming.md](streaming.md).
|
||||
12. **Moonlight Flatpak (aarch64) + Sunshine on macOS:** VNC works but is
|
||||
noticeably laggy, so this is worth trying.
|
||||
13. **KDE Connect**: is it preinstalled or installable on the Frame, and does
|
||||
it pair with KDE Connect for macOS?
|
||||
14. **Bluetooth keyboard pairing** on the Frame, for the rare times you do need
|
||||
@@ -86,8 +89,9 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a r
|
||||
runs as a lingering user service with no sudo; see [tailscale.md](tailscale.md).
|
||||
Still open: reaching the Frame from outside the home network, and the service
|
||||
starting after a reboot.
|
||||
17. **Floating panels in the headset** (see [panels.md](panels.md)): do the
|
||||
panels from `panel-on-frame.sh` show up, take input, and offer **Float in
|
||||
17. **Floating panels in the headset** (see [panels.md](panels.md)): panels
|
||||
from `panel-on-frame.sh` show up and take controller input (verified
|
||||
2026-09-27 with `mac-screen`). Still open: do they offer **Float in
|
||||
World** / **Move** / **Size**? Do floating positions survive closing and
|
||||
reopening the app, or a reboot?
|
||||
18. **`LEPTON_NO_CLEANUP=1 %command%`** as Lepton Development's launch
|
||||
@@ -103,12 +107,32 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a r
|
||||
the colour-coded test clips play in 3D (red left eye, cyan right) for both
|
||||
H.264 and H.265? Does the DLNA browser find a server on the Mac?
|
||||
|
||||
## Verified 2026-09-27
|
||||
|
||||
- **Recovery images exist** for the Frame at
|
||||
`https://steamdeck-images.steamos.cloud/recovery/`; the root filesystem inside
|
||||
is btrfs and runs, as a userland, on ARM64 Linux. See
|
||||
[recovery-and-images.md](recovery-and-images.md).
|
||||
- **Frame Control's server runs on the Frame itself** (the iPhone app does
|
||||
this), including headset capture, 31 fps live video and file uploads. See
|
||||
[iphone.md](iphone.md).
|
||||
- **Password pairing and `sudo -S`** work against the recovery image's own
|
||||
sshd and sudo (not yet against the headset, whose password we don't hold).
|
||||
|
||||
## Still open (2026-09-27)
|
||||
|
||||
- Does `podman exec <lepton container> /system/bin/sh -c 'wm size'` change an
|
||||
instance's display the way `adb shell wm size` does?
|
||||
- Can the recovery image, or its kernel, boot in a VM at all?
|
||||
- Does a real sleep, restart or shut down from the iPhone app work (via
|
||||
`sudo -S systemctl`)?
|
||||
- The Mac EDL flashing script in `~/Downloads/steam-frame-recovery/` hasn't
|
||||
been run against a Frame.
|
||||
|
||||
## Unconfirmed claims made in these docs
|
||||
|
||||
- `/home` and `/etc` persist across Frame OS updates. This is inferred from
|
||||
Steam Deck behaviour.
|
||||
- The whole Mac → Frame desktop path (VNC → Remmina). Each part is documented
|
||||
separately, but the combination is untested.
|
||||
- Steam Remote Play with a Mac as host is broken. That's based on community
|
||||
reports, not tested with the Frame.
|
||||
- `connect.sh --harden`, `serve-bootstrap.sh` and
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Recovery images and OS images for the Frame
|
||||
|
||||
Where to get the Steam Frame's operating system, what's inside it, and how to
|
||||
run it for testing without the headset. For recovering a Frame that won't boot,
|
||||
see the boot menu and boot-loop entries in
|
||||
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing).
|
||||
|
||||
## Downloads
|
||||
|
||||
Valve's SteamOS download page (`store.steampowered.com/steamos/download`)
|
||||
redirects to the [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227),
|
||||
which offers the Steam Deck image. The **Steam Frame images are on the same
|
||||
server** but aren't linked from that page:
|
||||
**https://steamdeck-images.steamos.cloud/recovery/** (a plain directory
|
||||
listing, checked 2026-09-27).
|
||||
|
||||
| File | Size | Use |
|
||||
|---|---|---|
|
||||
| `steamframe-oobe-repair-20260922.5153644-0.3.0.img.bz2` (or `.img.zip`) | 3.8 GiB | Write to an 8 GB+ USB-C stick, then **Boot from USB** in the Frame's boot menu |
|
||||
| `steamframe-oobe-repair-qdl-20260922.5153644-0.3.0.tar.gz` (or `.zip`) | 3.8 GiB | Flash over a USB-C cable in Qualcomm EDL mode with `flash.sh` (Linux) or `flash.cmd` (Windows), which use [qdl](https://github.com/linux-msm/qdl). **Wipes everything** |
|
||||
|
||||
All four are dated 2026-09-22. Everything else there is for the Steam Deck
|
||||
(`steamdeck-…`, x86-64), which won't run on the Frame. Valve publishes **no
|
||||
checksums**. These are the SHA-256s of our downloads (2026-09-26), which passed
|
||||
`bzip2 -t` and `tar -t`:
|
||||
|
||||
```
|
||||
3a4a077f1b1f40688ab3279affcb56776bd97c54db1573e7c65fc52a97106676 steamframe-oobe-repair-20260922.5153644-0.3.0.img.bz2
|
||||
d3323bfa8efe9ece1954948421cdf5f705e8942eb50c960e2916d935d1b850ab steamframe-oobe-repair-qdl-20260922.5153644-0.3.0.tar.gz
|
||||
```
|
||||
|
||||
Our copies, with a Mac EDL flashing script built on qdl (untested), are in
|
||||
`~/Downloads/steam-frame-recovery/` on the Mac.
|
||||
|
||||
## What's inside the USB image
|
||||
|
||||
A GPT disk with 512-byte sectors and one A slot (a Frame has A and B slots;
|
||||
the installer makes the rest). **Verified 2026-09-27** from
|
||||
`steamframe-oobe-repair-20260922.5153644-0.3.0.img.bz2`:
|
||||
|
||||
| # | Name | Start sector | Size | Type GUID |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `esp` | 34 | 256 MiB | `c12a7328-f81f-11d2-ba4b-00a0c93ec93b` (EFI system) |
|
||||
| 2 | `efi-A` | 524322 | 64 MiB | `ebd0a0a2-b9e5-4433-87c0-68b6b72699c7` |
|
||||
| 3 | `rootfs-A` | 655394 | 5120 MiB | `4f68bce3-e8cd-4db1-96e7-fbcaf984b709` |
|
||||
| 4 | `var-A` | 11141154 | 256 MiB | `4d21b016-b534-45c2-a9fb-5c16e091fd2d` |
|
||||
| 5 | `home` | 11665442 | 100 MiB | `933ac7e1-2eb4-4f13-b844-0e14e2aef915` |
|
||||
|
||||
The partitions start at sector 34, not on MiB boundaries, so compute offsets
|
||||
from the table (sector × 512), not from rounded sizes. `rootfs-A` is **btrfs**
|
||||
(label `rootfs-A`, 9.2 GB of files), mounted read-only on the Frame.
|
||||
Its `/etc/os-release` says `NAME="SteamOS"`, `ID=steamos`, `ID_LIKE=arch`,
|
||||
`VERSION_CODENAME=holo`; the running system reports version 0.3.0, variant
|
||||
`vr`, build **20260922.5152327**, which is a different number from the
|
||||
`5153644` in the file name. Our headset reports build 20260922.6101926.
|
||||
|
||||
Inside, it matches a real Frame:
|
||||
|
||||
- User `steamos` (uid 1000) is in `wheel` (gid 998), and sudoers has
|
||||
`%wheel ALL=(ALL) ALL`, so sudo asks for the Developer Mode password.
|
||||
- `sshd_config` includes `sshd_config.d/*.conf`, uses `.ssh/authorized_keys`
|
||||
plus `AuthorizedKeysCommand /usr/bin/userdbctl ssh-authorized-keys %u`,
|
||||
and sets `KbdInteractiveAuthentication no` and `UsePAM yes`. So sshd offers
|
||||
`publickey,password`, the same as the headset.
|
||||
- `/usr/bin` has `sshd`, `sudo`, `python3` and `podman`.
|
||||
|
||||
Get just the root filesystem without unpacking the whole 5.8 GB image (the
|
||||
partition's start and size, in sectors, come from the table above):
|
||||
|
||||
```sh
|
||||
bzcat steamframe-oobe-repair-*.img.bz2 | tail -c +$((655394 * 512 + 1)) | head -c $((10485760 * 512)) > rootfs-A.img
|
||||
```
|
||||
|
||||
A Mac can't mount btrfs; a Linux machine or VM can (`mount -o ro -t btrfs`).
|
||||
|
||||
## Running it without the headset
|
||||
|
||||
The image can't boot in a generic virtual machine: its kernel and bootloader
|
||||
are built for the Frame's Qualcomm Snapdragon 8 Gen 3 (**inferred**; not
|
||||
attempted). Its **userland** runs fine on any ARM64 Linux, which covers
|
||||
anything that talks to the Frame over SSH.
|
||||
|
||||
[`tests/frame-container/frame-image.sh`](../tests/frame-container/frame-image.sh)
|
||||
extracts `rootfs-A`, mounts it read-only with a throwaway writable layer, and
|
||||
starts the image's own `sshd` on port 2223 (user `steamos`, a test password;
|
||||
`systemctl` only records requests). On a Mac, run it in Colima's ARM64 VM (see
|
||||
[tests/frame-container/README.md](../tests/frame-container/README.md)).
|
||||
**Verified 2026-09-27:** the iPhone app paired with it by password (the image's
|
||||
sshd logged `Accepted password`, then `Accepted publickey … ED25519`), ran
|
||||
Frame Control's server on the image's Python, and the image's sudo rejected a
|
||||
wrong power password and passed the right one to `systemctl`. Without the
|
||||
Frame's hardware there's no SteamVR, Steam client, battery or Lepton, so those
|
||||
parts stay untested this way.
|
||||
|
||||
## Holo Core aarch64 (Valve and Collabora)
|
||||
|
||||
The ARM64 port of Arch Linux that the Frame's SteamOS is built on, published as
|
||||
a preview in July 2026 ([Collabora's announcement](https://www.collabora.com/news-and-blog/news-and-events/building-an-arch-linux-aarch64-port-for-holo-core.html)).
|
||||
It's a base system and build environment, not the Frame's OS:
|
||||
|
||||
- Source: `https://gitlab.steamos.cloud/holo/holo-core-aarch64-preview`
|
||||
- Packages: `https://holo-packages.steamos.cloud/holo-core-aarch64-preview/mash-20251118`
|
||||
- Container: `registry.gitlab.steamos.cloud/holo/holo-core-aarch64-preview/base-devel:latest`
|
||||
(1.7 GB; `/etc/os-release` says "Holo core Aarch64 port (preview)"; `pacman`
|
||||
installs OpenSSH 10.2, Python 3.13 and sudo from its repositories. Checked 2026-09-27.)
|
||||
|
||||
[`tests/frame-container/Dockerfile`](../tests/frame-container/Dockerfile) builds a
|
||||
lighter Frame stand-in on it (a `steamos` user with a password and sudo, sshd
|
||||
with keys and passwords), handy when you don't have the 4 GB image.
|
||||
@@ -93,7 +93,8 @@ controls to place each panel. See [docs/panels.md](panels.md).
|
||||
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
|
||||
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
|
||||
| `scripts/install-apk.sh` | Mac → Frame | Install APKs, each as its own persistent Lepton instance with a Steam library shortcut (`--dev`: old ADB path into Lepton Development) (**verified**; see [docs/apks.md](apks.md)) |
|
||||
| `scripts/panel-on-frame.sh` | Mac → Frame | Start an app as its own floating VR panel, outside the desktop (**verified**: overlays created; in-headset placement not yet checked) |
|
||||
| `scripts/panel-on-frame.sh` | Mac → Frame | Start an app as its own floating VR panel, outside the desktop (**verified**, including `mac-screen` in the headset) |
|
||||
| `scripts/mac-cursor-ring.lua` | Mac | Hammerspoon script: a ring around the Mac pointer so it shows in the VNC mirror (**verified**) |
|
||||
| `scripts/run-on-frame.sh` | Mac → Frame | Start an app on the headset desktop, e.g. `mac-screen` opens Remmina straight into the Mac (**verified**) |
|
||||
| `scripts/frame-ui.sh` | Mac | Start the Frame Control web UI (`ui/server.py`) and open it (**verified**) |
|
||||
| `scripts/apk-catalog.sh` | Mac | Refresh the rated F-Droid catalogue that Frame Control's Android section shows (**verified**) |
|
||||
|
||||
@@ -21,7 +21,8 @@ desktop app, which knows where a dropped folder lives; in a plain browser, zip
|
||||
it.) A dialog shows:
|
||||
|
||||
- **Name**: what Steam shows. Steam uses the title id as the name, so it's
|
||||
limited to letters, digits, `_` and `-`; the dialog shows the result.
|
||||
limited to letters, digits and `_`, and can't start with a digit; the
|
||||
dialog shows the result.
|
||||
- **Launches**: the program picked to start the game, with the other
|
||||
candidates in the list.
|
||||
- **Runtime**: picked from the program, see below. Windows programs can switch
|
||||
@@ -133,10 +134,15 @@ splits that string is **not checked**.
|
||||
with the same rule, because `scp -r` would follow a link out of the folder
|
||||
and upload whatever it points at.
|
||||
- Installs run one at a time, and Remove is refused while one runs.
|
||||
- The title id is limited to `[A-Za-z0-9_-]`, at most 64 characters. Valve's
|
||||
scripts pass it to a shell (`steamos-delete` runs `rm -r` on it). Valve's
|
||||
- The title id is limited to letters, digits and `_`, doesn't start with a
|
||||
digit (one that would gets `_` in front), and is 2 to 64 characters. That's
|
||||
what Steam's `create-shortcut` accepts: on the Frame it refused
|
||||
`fc-smoke-exe` with `missing/invalid arguments` and registered the same
|
||||
program as `FCSmokeProbe` (2026-09-27, BUILD_ID 20260922.6101926), and
|
||||
Valve's client only allows `^[A-Za-z_][A-Za-z0-9_.]+$`. Valve's scripts
|
||||
also pass the id to a shell (`steamos-delete` runs `rm -r` on it). Valve's
|
||||
reserved sideload names (`steam`, `steamvr`, and their `deckard` forms,
|
||||
which would replace the Steam client itself) get `-game` added.
|
||||
which would replace the Steam client itself) get `_game` added.
|
||||
- Nothing needs `sudo`; everything goes to your home folder on the Frame.
|
||||
- In the app, a dropped folder is read from its local path by the app's own
|
||||
server, which only accepts requests from its own page (see
|
||||
|
||||
@@ -102,6 +102,18 @@ started. `curl http://<frame-ip>:32000/properties.json` shows whether the servic
|
||||
`~/.ssh/authorized_keys` lives under `/home`, which SteamOS keeps across OS
|
||||
updates (inferred from Deck; the Frame uses the same A/B image scheme).
|
||||
|
||||
## From an iPhone or iPad
|
||||
|
||||
The iPhone app ([iphone.md](iphone.md)) makes its own ed25519 key and adds it
|
||||
with the Developer Mode password, once, over a password login; the Frame's sshd
|
||||
offers `publickey,password` (OpenSSH 9.7p1, keyboard-interactive off). It can't
|
||||
use the devkit pairing above: that installs an RSA key, and the Swift SSH
|
||||
library signs RSA only with SHA-1, which OpenSSH 8.8 and later refuse by default.
|
||||
The app pins the Frame's host key on first use and asks you to pair again if it
|
||||
changes. **Verified 2026-09-27** against the Frame's recovery image
|
||||
([recovery-and-images.md](recovery-and-images.md)); on the headset, the add-the-key-yourself
|
||||
route was used.
|
||||
|
||||
## Keeping `sshd` enabled across updates
|
||||
|
||||
- **Frame**: SSH is tied to the Developer Mode toggle, so it should survive
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Screen and desktop streaming
|
||||
|
||||
This covers two directions:
|
||||
This covers three directions, plus input:
|
||||
|
||||
- **A. Frame → Mac**: see and control the headset from the Mac.
|
||||
- **B. Mac → Frame**: use the Mac's desktop inside the headset.
|
||||
- **C. iPhone → Frame**: mirror the phone inside the headset.
|
||||
- **Input**: type and point in the Frame from the Mac or iPhone.
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
|
||||
@@ -32,7 +34,7 @@ flat 2D desktop streaming into a window on the Frame's Linux desktop.
|
||||
|
||||
| Option | Setup | Confidence | Verdict |
|
||||
|---|---|---|---|
|
||||
| **macOS Screen Sharing (VNC) → Remmina on the Frame** | **Mac:** System Settings → General → Sharing → Screen Sharing on → (i) → enable "VNC viewers may control screen with password". **Frame:** `./scripts/install-apps.sh remmina` from the Mac, then open Remmina in the headset and connect to `vnc://<mac>.local` | **Inferred.** Remmina is on Flathub for **aarch64** with VNC and RDP ([Flathub](https://flathub.org/apps/org.remmina.Remmina)). The Frame desktop runs Flatpaks ([UploadVR](https://www.uploadvr.com/flatpaks-open-source-steam-frame/)). macOS VNC is built in. | **Recommended.** Nothing to install on the Mac, and it's easy to set up. Latency is fine for productivity but not for games. You'll type the Mac's hostname once in Remmina on the headset, then save the profile. To avoid even that, the script can pre-seed a Remmina profile over SSH (see below). |
|
||||
| **macOS Screen Sharing (VNC) → Remmina on the Frame** | **Mac:** System Settings → General → Sharing → Screen Sharing on → (i) → enable "VNC viewers may control screen with password". **Frame:** `./scripts/install-apps.sh remmina` from the Mac, then open Remmina in the headset and connect to `vnc://<mac>.local` | **Verified 2026-09-27** (Frame BUILD_ID 20260925.6191901, macOS 27.0), in its own panel via `panel-on-frame.sh mac-screen`. Remmina is on Flathub for **aarch64** with VNC and RDP ([Flathub](https://flathub.org/apps/org.remmina.Remmina)). The Frame desktop runs Flatpaks ([UploadVR](https://www.uploadvr.com/flatpaks-open-source-steam-frame/)). macOS VNC is built in. | **Recommended.** Nothing to install on the Mac, and it's easy to set up. Noticeable lag, even at lower Remmina quality settings on a good 5 GHz link, where neither Wi-Fi nor the Frame's CPU was the bottleneck. Usable for reading and coding, but not for games. You'll type the Mac's hostname once in Remmina on the headset, then save the profile. To avoid even that, the script can pre-seed a Remmina profile over SSH (see below). |
|
||||
| Sunshine (Mac) → Moonlight (Frame Flatpak) | `brew install` Sunshine on the Mac, then `./scripts/install-apps.sh moonlight` | Moonlight Flatpak supports **aarch64** ([Flathub](https://flathub.org/apps/com.moonlight_stream.Moonlight)). **Sunshine on macOS is poorly supported**: install problems on Apple Silicon/Sequoia, and no virtual gamepads ([LizardByte discussion #777](https://github.com/orgs/LizardByte/discussions/777)). | Try it if VNC is too laggy. Expect some friction. |
|
||||
| Steam Remote Play with the Mac as host | Steam on the Mac, Steam Link/Remote Play on the Frame | macOS-hosted Remote Play is reported broken or flaky in 2024–2026 ([Steam discussion](https://steamcommunity.com/groups/homestream/discussions/1/574921459914429988/)) | Not recommended. It's only for games, if it works at all. |
|
||||
| Immersed / Virtual Desktop | Vendor apps | Immersed has a Mac agent but no known Frame client. Virtual Desktop's developer said he'd "try" to port it ([NewsBreak](https://www.newsbreak.com/news/4892834783961-virtual-desktop-dev-says-he-ll-try-to-bring-the-app-to-steam-frame)). | Not available as of 2026-09-25. Check again later. |
|
||||
@@ -45,20 +47,70 @@ them on the Frame in DeoVR instead: see [vr-video.md](vr-video.md).
|
||||
|
||||
`scripts/install-apps.sh remmina --vnc-host <your-mac>.local` writes
|
||||
`~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina` on the Frame over
|
||||
SSH. The profile then appears in Remmina's list, and you just click it. You'll
|
||||
still be asked for the VNC password in the headset the first time, unless you
|
||||
choose to save it. Remmina stores passwords encrypted with a per-install key,
|
||||
so the script doesn't try to write the password. (The Remmina file format is
|
||||
standard; the Flatpak data path is inferred.)
|
||||
SSH. The profile then appears in Remmina's list, and you just click it. It
|
||||
scales the Mac's desktop to fit the window (`scale=1`, `viewmode=1`). Without
|
||||
that, Remmina shows a Retina Mac's native pixels 1:1, so you see a zoomed-in
|
||||
corner. (Verified 2026-09-27.)
|
||||
|
||||
## Input and text entry without the virtual keyboard
|
||||
**Expect a Mac login prompt, not the VNC password.** macOS offers Apple's own
|
||||
authentication (RFB security type 30) ahead of plain VNC auth (type 2), and
|
||||
Remmina picks it. So Remmina asks for your **Mac account name and login
|
||||
password**; the "VNC viewers may control screen" password isn't used. To store
|
||||
the password without typing it in the headset, run on the Frame:
|
||||
|
||||
- **A Bluetooth keyboard and mouse** paired to the Frame is the obvious way to
|
||||
avoid the virtual keyboard. Road to VR says there are "only a few things
|
||||
you'd actually want to do" on the Linux desktop unless you connect a
|
||||
keyboard and mouse.
|
||||
(Pairing a BT keyboard on the Frame is inferred from SteamOS; not verified.)
|
||||
- **Clipboard from the Mac**: `scripts/paste-to-frame.sh` (see
|
||||
[file-transfer.md](file-transfer.md#clipboard)).
|
||||
```sh
|
||||
printf '%s' "$PASSWORD" | flatpak run org.remmina.Remmina \
|
||||
--update-profile ~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina \
|
||||
--set-option password
|
||||
```
|
||||
|
||||
Remmina encrypts it into the profile with its own key, because there's no
|
||||
secret service in the SSH session. (Verified 2026-09-27.)
|
||||
|
||||
### The Mac's cursor
|
||||
|
||||
The mirror doesn't show the Mac's pointer, with either `showcursor` value.
|
||||
macOS keeps the pointer out of the picture it sends, and Remmina's cursor mode
|
||||
draws the cursor shape only at the Frame's own pointer, which doesn't follow
|
||||
the Mac trackpad. `scripts/mac-cursor-ring.lua` works around this: a
|
||||
[Hammerspoon](https://www.hammerspoon.org/) script that draws a ring around the
|
||||
Mac pointer as a real window, so it's part of the mirrored picture. Setup is in
|
||||
its header. (Verified 2026-09-27.)
|
||||
|
||||
Going the other way, pointing a controller at the panel moves the Mac's mouse,
|
||||
because Remmina forwards input (`viewonly=0`).
|
||||
|
||||
## C. Show the iPhone's screen inside the Frame
|
||||
|
||||
iOS only shares its screen two ways: **AirPlay** (Screen Mirroring in Control
|
||||
Centre) or a **ReplayKit broadcast extension** in an app. Nothing else can
|
||||
capture it.
|
||||
|
||||
| Option | What it takes | Confidence | Verdict |
|
||||
|---|---|---|---|
|
||||
| **UxPlay** (an open-source AirPlay receiver) on the Frame | Build it for aarch64 (no Flathub package; there's a Snap and distro packages), run it in `~` or a podman container, and advertise it over mDNS. The iPhone *and* the Mac then see "Frame" in Screen Mirroring, with nothing to install on either | **Inferred.** It runs on ARM64 Linux such as the Raspberry Pi ([UxPlay](https://github.com/FDH2/UxPlay)). Not tried on the Frame: needs mDNS registration and its ports (7000, 7001, 7100 and a UDP range) reachable | **Recommended to try first.** It's the only receiver-side option, and it covers the Mac too. The window shows in the Frame's Linux desktop panel |
|
||||
| A broadcast extension in Frame Control | ReplayKit sends the screen to a small extension (50 MB memory limit), which encodes H.264 and sends it through the app's SSH tunnel to the page, shown the same way as the Frame's live view in reverse | **Inferred** from Apple's ReplayKit docs | Full control and no network setup, but several days' work, and the picture only shows where Frame Control's page is open in the headset |
|
||||
|
||||
## Input: type and point in the Frame from the Mac or iPhone
|
||||
|
||||
**Verified 2026-09-27** on the headset: `steamos` is in the `input` group and
|
||||
`/dev/uinput` is `crw-rw-r-- root input`, so **our own code can create a
|
||||
virtual keyboard and mouse without sudo**. The Frame has no `python-evdev`,
|
||||
`ydotool`, `wtype` or KDE Connect; `kwin_wayland` and `plasmashell` run only
|
||||
while the desktop panel is open in the headset.
|
||||
|
||||
| Option | Mac | iPhone | Notes |
|
||||
|---|---|---|---|
|
||||
| **A uinput keyboard and mouse in Frame Control's server** | ✓ | ✓ | **Recommended.** The server opens `/dev/uinput` with `ctypes` (standard library only) and the page sends key and pointer events through the tunnel it already has. On the phone: a trackpad area (drag to move, tap to click, two fingers to scroll) and the iOS keyboard for typing. On the Mac: a "control the Frame" mode that captures the keyboard and pointer (Esc to release). Uinput devices look like real hardware to the kernel, so libinput, KWin and gamescope should take them; [frame-voice](https://github.com/DeeJanuz/frame-voice) already types into a Frame through a uinput keyboard. **Untested**: which surfaces in VR (desktop panel, SteamVR dashboard, games, Android apps in Lepton) accept the pointer. About a day or two of work |
|
||||
| **Bluetooth keyboard and mouse** | – | – | Real hardware paired in SteamOS settings. The iPhone can't pretend to be a Bluetooth keyboard: iOS won't advertise the HID service ([Apple forums](https://developer.apple.com/forums/thread/733916)) |
|
||||
| **Deskflow** (formerly Input Leap / Barrier) | ✓ | – | Moves the Mac's own mouse and keyboard onto the Frame's screen edge. Flathub has an aarch64 build ([Flathub](https://flathub.org/apps/org.deskflow.deskflow)); on Wayland it needs the InputCapture/libei portal, and only works while Plasma is running. No iPhone client |
|
||||
| **KDE Connect** | ~ | ✓ | Its iOS app has a remote touchpad and keyboard, but the Frame would need KDE Connect installed (not on Flathub; `pacman` on a read-only root). More moving parts than the uinput route |
|
||||
| **Remmina / Steam Link / RDP** | ✓ | – | Input only reaches the streamed session, not the headset's own apps |
|
||||
|
||||
Other ways to get text in:
|
||||
|
||||
- **Clipboard from the Mac**: `scripts/paste-to-frame.sh`, or Frame Control's
|
||||
clipboard box (see [file-transfer.md](file-transfer.md#clipboard)). Needs
|
||||
the desktop panel open.
|
||||
- **RDP session**: Windows App syncs the clipboard with xrdp, but only inside
|
||||
that RDP session.
|
||||
@@ -0,0 +1,190 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Frame Control 0.3.1: features by OS</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #1b2838; --panel: #16202d; --line: #2a3f5a; --text: #c7d5e0; --dim: #8f98a0;
|
||||
--tested: #5ba32b; --partial: #d9a33a; --auto: #4b8bbe; --built: #3d4f63; --no: #6b2b2b;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: linear-gradient(#171a21, var(--bg) 320px); color: var(--text);
|
||||
font: 15px/1.5 "Motiva Sans", -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
main { max-width: 1180px; margin: 0 auto; padding: 40px 24px 80px; }
|
||||
h1 { color: #fff; font-size: 30px; margin: 0 0 4px; font-weight: 600; }
|
||||
h2 { color: #fff; font-size: 18px; margin: 40px 0 12px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: .06em; }
|
||||
.sub { color: var(--dim); margin: 0 0 28px; }
|
||||
a { color: #66c0f4; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 14px; }
|
||||
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 16px 18px; }
|
||||
.card h3 { margin: 0 0 8px; color: #fff; font-size: 16px; }
|
||||
.card dl { margin: 0; display: grid; grid-template-columns: 76px 1fr; gap: 3px 10px; font-size: 13.5px; }
|
||||
.card dt { color: var(--dim); }
|
||||
.card dd { margin: 0; }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 10px 20px; margin: 0 0 14px; font-size: 13.5px; }
|
||||
.legend span { display: inline-flex; align-items: center; gap: 7px; }
|
||||
table { width: 100%; border-collapse: collapse; background: var(--panel);
|
||||
border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
|
||||
th, td { padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; text-align: left; }
|
||||
thead th { background: #0e141b; color: #fff; font-weight: 600; position: sticky; top: 0; z-index: 1; }
|
||||
thead th.os { width: 150px; text-align: center; }
|
||||
tr.group td { background: #203044; color: #fff; font-weight: 600; font-size: 13px;
|
||||
text-transform: uppercase; letter-spacing: .05em; }
|
||||
td.os { text-align: center; }
|
||||
td .feat { color: #fff; }
|
||||
td .note { color: var(--dim); font-size: 13px; }
|
||||
.pill { display: inline-block; min-width: 92px; padding: 2px 9px; border-radius: 999px;
|
||||
font-size: 12.5px; font-weight: 600; color: #fff; white-space: nowrap; }
|
||||
.t { background: var(--tested); }
|
||||
.p { background: var(--partial); color: #1b1b1b; }
|
||||
.a { background: var(--auto); }
|
||||
.b { background: var(--built); color: #c7d5e0; }
|
||||
.n { background: var(--no); }
|
||||
.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
|
||||
ul { margin: 6px 0 0; padding-left: 20px; }
|
||||
li { margin: 3px 0; }
|
||||
footer { color: var(--dim); font-size: 13px; margin-top: 36px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Frame Control 0.3.1: features by OS</h1>
|
||||
<p class="sub">Which features are built for each OS, and which were tested against a real Steam Frame
|
||||
(SteamOS 0.3.0, build 20260922.6101926). Status as of 26 September 2026, for
|
||||
<a href="https://github.com/saphid/steam-frame/pull/2">PR #2</a> (0.3.1). Every build now bundles its own
|
||||
Python 3.12, <code>adb</code> and CA certificates, so nothing else needs installing (only <code>ssh</code> on Linux,
|
||||
plus the system <code>adb</code> on arm64 Linux).</p>
|
||||
|
||||
<h2>Builds and test machines</h2>
|
||||
<div class="cards">
|
||||
<div class="card"><h3>macOS</h3><dl>
|
||||
<dt>Built</dt><dd>Apple Silicon (arm64): <code>.dmg</code>, <code>.zip</code>. No Intel build.</dd>
|
||||
<dt>Signing</dt><dd>Ad-hoc signed, not notarised</dd>
|
||||
<dt>Tested on</dt><dd>Apple Silicon Mac, macOS 26, using a local 0.3.1 build with the bundled Python and <code>adb</code>. All 15 calls it made to the Frame at startup returned OK.</dd>
|
||||
</dl></div>
|
||||
<div class="card"><h3>Windows</h3><dl>
|
||||
<dt>Built</dt><dd>x64: NSIS installer <code>.exe</code> and <code>.zip</code></dd>
|
||||
<dt>Signing</dt><dd>Unsigned. SmartScreen shows a warning.</dd>
|
||||
<dt>Tested on</dt><dd>Windows 11 x64 VM. Real-Frame results below are from 0.3.0. The 0.3.1 installer from CI installs cleanly (31 s) and reinstalls over itself (38 s). The server starts on the bundled Python, and HTTPS to Steam and F-Droid works. The Frame went offline before its 0.3.1 run on the headset.</dd>
|
||||
</dl></div>
|
||||
<div class="card"><h3>Linux</h3><dl>
|
||||
<dt>Built</dt><dd>x86_64 and arm64: <code>AppImage</code> and <code>.deb</code></dd>
|
||||
<dt>Signing</dt><dd>n/a</dd>
|
||||
<dt>Tested on</dt><dd>x86_64 Ubuntu 26.04 with no <code>adb</code> and no clipboard tools, using the 0.3.1 AppImage under Xvfb with the bundled Python and <code>adb</code>. The arm64 builds and the <code>.deb</code> packages weren't run; the arm64 package was only checked to contain an ARM Python.</dd>
|
||||
</dl></div>
|
||||
</div>
|
||||
|
||||
<h2>Features</h2>
|
||||
<div class="legend">
|
||||
<span><i class="dot" style="background:var(--tested)"></i><b>Tested</b>: worked against the real Frame on that OS</span>
|
||||
<span><i class="dot" style="background:var(--partial)"></i><b>Partial</b>: only part of the feature was tested (see note)</span>
|
||||
<span><i class="dot" style="background:var(--auto)"></i><b>Automated</b>: covered by CI tests on that OS, not tried on a real Frame</span>
|
||||
<span><i class="dot" style="background:var(--built)"></i><b>Built</b>: in the build, not tested</span>
|
||||
<span><i class="dot" style="background:var(--no)"></i><b>Not built</b></span>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Feature</th><th class="os">macOS</th><th class="os">Windows</th><th class="os">Linux</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="group"><td colspan="4">Connection</td></tr>
|
||||
<tr><td><div class="feat">Set Up Connection</div><div class="note">Finds the Frame, writes the <code>frame</code> SSH alias, copies your key using the Frame's password. macOS runs <code>connect.sh</code> in Terminal; Windows and Linux run <code>frame_connect.py</code>.</div></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Existing alias used, script not re-run</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Shared SSH connection</div><div class="note">A single SSH connection is reused, so each request takes about 0.3 s. Windows OpenSSH can't do this, so there each request opens its own connection (about 0.5 to 1 s).</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td>
|
||||
<td class="os"><span class="pill n">Not built</span><div class="note">OpenSSH limitation</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
|
||||
<tr class="group"><td colspan="4">Headset view</td></tr>
|
||||
<tr><td><div class="feat">Capture headset view</div><div class="note">The left eye or both eyes as the lenses show them, saved as PNG</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Capture desktop panel</div><div class="note">gamescope's flat layer</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Live view</div><div class="note">720p H.264 at about 30 fps, decoded with WebCodecs</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Headset screenshots</div><div class="note">Browse the screenshots you took with Steam's shortcut, and save them to <code>~/Pictures/SteamFrame</code></div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Listed (5 found); saving not tried</div></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Listed with thumbnails; saving not tried</div></td></tr>
|
||||
|
||||
<tr class="group"><td colspan="4">Status</td></tr>
|
||||
<tr><td><div class="feat">Battery and charging</div><div class="note">Percentage, watts, time to full or empty, charger type, temperature</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">System status</div><div class="note">Storage, memory, temperature, Wi-Fi, uptime, running services</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Volume and mute</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Read only</div></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Read only</div></td></tr>
|
||||
|
||||
<tr class="group"><td colspan="4">Games</td></tr>
|
||||
<tr><td><div class="feat">Owned games with Frame ratings</div><div class="note">Verified, Playable, Unsupported or Unknown</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Install a game on the Frame</div><div class="note">Uses the headset's Steam client, with live progress</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
<tr><td><div class="feat">Store search, Buy, Store on Frame</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
<tr><td><div class="feat">Library shelf and Play button</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
|
||||
<tr class="group"><td colspan="4">Android apps</td></tr>
|
||||
<tr><td><div class="feat">Installed Android apps list</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">F-Droid catalogue search</div><div class="note">About 4,500 apps with Frame ratings, bundled with the app</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Install, launch, stop, test, remove an app</div><div class="note">Each app runs as its own Lepton instance, using the bundled <code>adb</code>. APK files are read by a built-in parser (no <code>aapt2</code>) that matched <code>aapt2</code> on 9 F-Droid APKs.</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span><div class="note">Diary: read, install, launch, test, remove</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Launch and stop</div></td></tr>
|
||||
<tr><td><div class="feat">Report an APK</div><div class="note">Reports are saved on your computer; the shared database is maintainer-only</div></td>
|
||||
<td class="os"><span class="pill a">Automated</span></td><td class="os"><span class="pill a">Automated</span></td><td class="os"><span class="pill a">Automated</span></td></tr>
|
||||
<tr><td><div class="feat">Android display settings</div><div class="note">Resolution, UI scale, text size</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span><div class="note">Density and text size set, then reset</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Read over the bundled adb</div></td></tr>
|
||||
|
||||
<tr class="group"><td colspan="4">Transfer</td></tr>
|
||||
<tr><td><div class="feat">Send files to ~/Downloads</div><div class="note">Test files had non-English characters in their names (é, ✓). macOS and Linux copy with rsync; Windows uses scp.</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
<tr><td><div class="feat">Drop an APK to install it</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
<tr><td><div class="feat">Send text or clipboard to the Frame</div><div class="note">Needs the headset desktop open. The app reads your clipboard through Electron, so no extra tools are needed.</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span><div class="note">Reading the clipboard retested in 0.3.1</div></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Reached the Frame; desktop was closed</div></td>
|
||||
<td class="os"><span class="pill p">Partial</span><div class="note">Clipboard read with no xclip; Frame desktop was closed</div></td></tr>
|
||||
<tr><td><div class="feat">Flatpak install and remove</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
|
||||
<tr class="group"><td colspan="4">One-click tools</td></tr>
|
||||
<tr><td><div class="feat">SSH or SFTP in a terminal</div><div class="note">macOS: Terminal. Windows: cmd. Linux: GNOME Terminal, Konsole, xterm and others.</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
<tr><td><div class="feat">Steam Link</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
<tr><td><div class="feat">Remote desktop</div><div class="note">macOS: Windows App. Windows: Remote Desktop. Linux: Remmina or FreeRDP.</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
<tr><td><div class="feat">Sleep, restart, shut down</div><div class="note">Opens a terminal because SteamOS asks for the sudo password</div></td>
|
||||
<td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td><td class="os"><span class="pill b">Built</span></td></tr>
|
||||
|
||||
<tr class="group"><td colspan="4">App</td></tr>
|
||||
<tr><td><div class="feat">Local server test suite</div><div class="note">Runs in GitHub Actions on every push (Python 3.12 on macOS and Windows, Python 3.13 on Ubuntu), including the APK reader tests</div></td>
|
||||
<td class="os"><span class="pill a">Automated</span></td><td class="os"><span class="pill a">Automated</span></td><td class="os"><span class="pill a">Automated</span></td></tr>
|
||||
<tr><td><div class="feat">Mac or PC wording</div><div class="note">The UI says Finder or File Explorer, and Mac or PC, to match your system</div></td>
|
||||
<td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td><td class="os"><span class="pill t">Tested</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Notes</h2>
|
||||
<ul>
|
||||
<li><b>Tested</b> means the app, running on that OS, got a successful response from the real Frame: for example, a PNG from a capture, 868 owned games, or the Android apps listed.</li>
|
||||
<li>The Windows VM tests ran in its desktop session. <code>ssh.exe</code> hangs when it's started from a remote SSH session, but a normal desktop user won't hit that.</li>
|
||||
<li>The macOS test from 25 September also covered the capture shown when the headset is in standby, input validation, and using the clipboard with the headset desktop open.</li>
|
||||
<li>Everything marked <b>Built</b> runs a command that works on its own. It just hasn't been tried end to end from the app on that OS yet.</li>
|
||||
</ul>
|
||||
<footer>Frame Control is an unofficial tool, not made by Valve. MIT licence.</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,157 @@
|
||||
# Testing
|
||||
|
||||
Frame Control is tested in three layers, from fast and fake to slow and real.
|
||||
A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/steam-frame/issues/6)).
|
||||
|
||||
| Layer | Runs | Needs | Covers |
|
||||
|---|---|---|---|
|
||||
| Unit tests (`tests/*.py`) | `python3 -m unittest discover -s tests` | Nothing | Parsing, validation, request guards; SSH and HTTP are mocked |
|
||||
| Fake Frame (`tests/e2e`) | `scripts/e2e.sh` | Linux with Docker | The real server and scripts against a container that behaves like a Frame |
|
||||
| Headset smoke test | `scripts/frame-smoke.sh` | A Frame on the `frame` alias | Install, launch and remove on the real device, recorded with its BUILD_ID |
|
||||
|
||||
## Unit tests
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
About 120 tests, a few seconds, on Python 3.9 and newer. GitHub Actions runs
|
||||
them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
|
||||
|
||||
## The fake Frame
|
||||
|
||||
`tests/fakeframe/` builds a container that stands in for the headset, and a
|
||||
second one for the computer Frame Control runs on. `scripts/e2e.sh` builds
|
||||
both, starts them with `docker compose`, runs `tests/e2e` in the host
|
||||
container and takes everything down, exiting with the tests' status:
|
||||
|
||||
```sh
|
||||
scripts/e2e.sh # everything, about 2 minutes plus the first build
|
||||
scripts/e2e.sh test_titles # one module
|
||||
scripts/e2e.sh test_faults.Faults.test_disk_full # one test
|
||||
FAKEFRAME_KEEP=1 scripts/e2e.sh # leave it running afterwards
|
||||
```
|
||||
|
||||
It needs a Linux host with Docker and `docker compose`, and zsh. The images
|
||||
are `fakeframe-frame` and `fakeframe-host`; the compose project, network and
|
||||
volumes are `fakeframe-e2e*`. CI runs it on a native arm64 runner
|
||||
(`ubuntu-24.04-arm`, the `e2e` job in `.github/workflows/checks.yml`).
|
||||
|
||||
The host container exists because OpenSSH reads `~/.ssh/config` from the
|
||||
passwd home directory, not `$HOME`. There, `ssh frame` reaches the fake Frame
|
||||
through the same `Host frame` block `ui/frame_connect.py` writes, the
|
||||
repository is mounted read-only at `/repo`, and each test module starts the
|
||||
real `ui/server.py` (Python 3.9) and talks to it over HTTP with the headers
|
||||
its guards want.
|
||||
|
||||
### What's real and what's fake
|
||||
|
||||
| On the fake Frame | |
|
||||
|---|---|
|
||||
| Arch Linux (`archlinux:base`, or Valve's Holo Core aarch64 preview on arm64), user `steamos`, `/etc/os-release` with BUILD_ID 20260922.6101926 | Real OS, Frame's identity |
|
||||
| `sshd` with key and password logins, `rsync`, `python3` | Real |
|
||||
| Valve's steamos-devkit-service on port 32000 and its hooks, vendored unmodified in `tests/fakeframe/steamos-devkit-service` | Real; only its `dbus` import (for mDNS through systemd-resolved) is a stand-in that logs the registration |
|
||||
| Valve's devkit-utils, copied over by Frame Control itself | Real |
|
||||
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs | Fake |
|
||||
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF | The JS engine is real; the objects are fake |
|
||||
| `steam`, `wpctl`, `flatpak`, `podman`, `nmcli`, `qdbus6`, `gamescopectl`, SteamOS's `steamos-enable-sshd` helper, and Lepton's launcher | Stubs that record their calls |
|
||||
| Battery, charger and thermal zones under `/sys/class` | Files the supervisor writes. `/sys` is read-only in a container and Docker's AppArmor profile refuses writes under it, so each folder is a volume mounted twice: over `/sys/class/...` for `frame_status.py` to read, and under `/var/lib/fakeframe/sys` for the supervisor to write |
|
||||
| `vrserver` and `plasmashell` | Renamed `sleep` processes, so the status page and the clipboard find them |
|
||||
|
||||
Every fake behaviour copied from the device has a comment citing the doc or
|
||||
observation and the BUILD_ID it came from; anything not seen on a headset is
|
||||
marked as a guess. The fake keeps its state in `/var/lib/fakeframe/state.json`
|
||||
(shortcuts, devkit titles, compat tool mapping, launches, pairing requests,
|
||||
Lepton instances, volume, Flatpaks, clipboard) and logs stub calls to
|
||||
`calls.jsonl` beside it.
|
||||
|
||||
Native programs really run: a launched aarch64 title executes on an arm64
|
||||
host, and an x86-64 one on x86-64 (the container shares the host's kernel).
|
||||
Proton titles are recorded with the command Steam would run, not run.
|
||||
|
||||
### Fault switches
|
||||
|
||||
`fakeframe-ctl` works over SSH (`ssh frame fakeframe-ctl help`) and from the
|
||||
host container (`FAKEFRAME_CTL=http://fakeframe:9999`), so a test can flip a
|
||||
switch while SSH is down:
|
||||
|
||||
| Command | Effect |
|
||||
|---|---|
|
||||
| `pairing on\|off` | Steam's **Pair new host** screen open or not; off gives the device's 403 text |
|
||||
| `answer approve\|deny\|timeout` | How the pairing prompt is answered |
|
||||
| `steam on\|off` | Steam client running (pid file, pipe, DevTools) |
|
||||
| `sleep on\|off` | Headset asleep: ports 22 and 32000 accept and never answer, so SSH times out |
|
||||
| `sshd on\|off` | sshd stopped: new connections are refused, open ones stay |
|
||||
| `devkit-service on\|off` | Port 32000 closed |
|
||||
| `disk-full on\|off` | Fills the small (64 MB) filesystem on `~/devkit-game` |
|
||||
| `runtime NAME installed\|missing` | Proton, the Steam Linux Runtimes, Lepton |
|
||||
| `battery KEY=VALUE...` | e.g. `capacity=15 status=Discharging current_now=-900000` |
|
||||
| `keys harness\|none`, `authorized-keys` | Set or read `~/.ssh/authorized_keys` |
|
||||
| `reset`, `state`, `calls [TOOL]` | Start over; read the state and call log |
|
||||
|
||||
### What the fake can't show
|
||||
|
||||
- Rendering: the headset view, desktop capture content, live video, SteamVR,
|
||||
gamescope and panels. The capture stub returns a placeholder PNG.
|
||||
- Proton and FEX: whether a Windows or x86-64 program actually runs.
|
||||
- Android: there's no Android in the Lepton stand-in, so no ADB, display
|
||||
settings, probes or app crashes.
|
||||
- The real Steam client's UI and anything it does that isn't modelled, and
|
||||
mDNS discovery.
|
||||
- `sudo` and the power buttons, Tailscale, and the Windows and macOS sides of
|
||||
the app (the host container is Linux, so the `rsync` paths are tested and the
|
||||
`scp` fallback isn't).
|
||||
|
||||
## Headset smoke test
|
||||
|
||||
```sh
|
||||
scripts/frame-smoke.sh # needs `ssh frame` to work without a password
|
||||
scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset
|
||||
```
|
||||
|
||||
It checks `properties.json` and the status, then installs, launches and
|
||||
removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
|
||||
(an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and
|
||||
an x86-64 `.exe` that exits at once). A launch passes only with fresh evidence:
|
||||
the ARM64 program running, the `.exe` started (its process or Steam's log),
|
||||
and the x86-64 program running or Steam logging that its runtime isn't
|
||||
installed, which is what the Frame does today. Steam's log lines about each
|
||||
title are kept.
|
||||
|
||||
Everything it installs is removed again, also after a failure: the titles and
|
||||
their Steam shortcuts, a paired key, and `~/devkit-utils` if it wasn't there
|
||||
before (if it was, it stays, synced to this checkout as Frame Control always
|
||||
does). A cleanup that fails counts as a failed step. Results go to
|
||||
`tests/smoke/results/<time>-<BUILD_ID>.json` (not committed) with a summary on
|
||||
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
|
||||
isn't reachable.
|
||||
|
||||
`--pair` asks the devkit service to pair a new RSA key, which needs someone
|
||||
in the headset to open **Settings → Developer → Pair new host** and approve
|
||||
it; the key is checked and then taken out of `authorized_keys` again.
|
||||
|
||||
## When the device disagrees with the fake
|
||||
|
||||
The fake is only as good as what's been seen on a headset. When the smoke
|
||||
test (or anyone) finds the Frame doing something else:
|
||||
|
||||
1. Record what the device did, with the date and BUILD_ID, in the doc that
|
||||
covers it (`docs/sideloading.md`, `docs/ssh.md` and so on).
|
||||
2. Change the fake to match, with a comment citing that observation. The
|
||||
behaviours are in `tests/fakeframe/rootfs/usr/local/lib/fakeframe/`
|
||||
(`fakesteam.py` for Steam, `cef_shim.js` for DevTools, `init.py` for the
|
||||
switches, the stubs in `rootfs/usr/local/bin`).
|
||||
3. Run `scripts/e2e.sh`. If the app is wrong, the tests now fail the way the
|
||||
device did; fix the app and add a unit test.
|
||||
|
||||
For example, on 2026-09-27 the smoke test found that Steam's `create-shortcut`
|
||||
refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
|
||||
accepted. The fake now refuses them the same way, and Frame Control makes ids
|
||||
Steam accepts.
|
||||
|
||||
## Tracking protocols and fake BlueZ
|
||||
|
||||
`tests/test_tracking.py` exercises our gaze conversion, OSC sender, HRS parser
|
||||
and BlueZ lifecycle with an in-memory fake object tree. It runs in the normal
|
||||
unit suite without Bluetooth, GTK or OpenXR. Real Frame results and the absent
|
||||
strap/tracker boundaries are recorded in [tracking](tracking.md).
|
||||
@@ -0,0 +1,329 @@
|
||||
# Eye tracking and heart rate
|
||||
|
||||
Frame Control's own tools run on the Frame, using OpenXR and BlueZ. No
|
||||
VRCFaceTracking, LunaHR, Pulsoid or other tracking app is required. This is a
|
||||
command-line first version; it does not add a desktop app tab.
|
||||
|
||||
## What was checked
|
||||
|
||||
**Verified 2026-09-28**, on a real aarch64 Frame running SteamOS 0.4.1,
|
||||
BUILD_ID `20260925.6191901`:
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| OpenXR gaze | SteamVR advertises `XR_EXT_eye_gaze_interaction`, `XR_MND_headless` and `XR_KHR_convert_timespec_time`. `supportsEyeGazeInteraction=1`. A headless session reached FOCUSED and produced 269 valid, tracked orientations in the first ten-second probe. |
|
||||
| Our gaze → OSC bridge | A separate ten-second run produced 280 valid samples and 280 correctly padded 44-byte `/tracking/eye/CenterPitchYaw` messages at an explicitly configured loopback receiver. Only counters and packet-layout checks were retained. |
|
||||
| Bluetooth stack | BlueZ active, adapter powered, central/peripheral roles available. LE discovery started and stopped successfully. No pairing or adapter power settings changed. |
|
||||
| Our heart-rate panel | GTK4/GI runs on the stock image. A **synthetic 72 BPM** notification displayed in our X11 window, tagged `STEAM_GAME=2000000027`. Window capture checked; no real heart-rate measurement was taken. |
|
||||
| SlimeVR, separate feasibility check | Native aarch64 server v21.1.0 ran with an isolated Temurin 21 JRE, created its driver sockets and accepted a local TCP connection on port 21110. Driver v6.0.0 loaded with all shared libraries resolved; `HmdDriverFactory("IServerTrackedDeviceProvider_004")` returned a non-null provider and error 0. |
|
||||
|
||||

|
||||
|
||||
The image is a capture of our own Frame window using a fake notification,
|
||||
not a real sensor reading.
|
||||
|
||||
**Untested:** a real BLE strap's notifications, physical fit/contact behaviour,
|
||||
end-to-end heart-rate display/OSC/log with a strap, avatar response in VRChat,
|
||||
gaze accuracy/calibration, coexistence with every immersive app, in-headset
|
||||
panel placement, SlimeVR tracker/calibration data and the SlimeVR driver running
|
||||
inside SteamVR. No trackers or strap are attached. The driver was loaded in a
|
||||
separate process; it was **not registered or activated in SteamVR**. Steam and
|
||||
SteamVR were not stopped or restarted.
|
||||
|
||||
**Verified blocker resolved:** importing `tkinter` fails because `libtk8.6.so`
|
||||
is absent. The panel uses the installed GTK4/GI bindings instead. The Frame's
|
||||
OpenXR headers advertise a newer API version than the runtime accepts; our
|
||||
reader requests OpenXR 1.0 explicitly.
|
||||
|
||||
## Install our tools
|
||||
|
||||
From this checkout on your computer, while the Frame is awake:
|
||||
|
||||
```sh
|
||||
python3 scripts/tracking-on-frame.py install
|
||||
```
|
||||
|
||||
This copies our Python code and compiles our small C OpenXR reader into
|
||||
`~/.local/share/frame-control/tracking/` on the Frame. It uses the Frame's
|
||||
existing compiler, OpenXR headers/loader, Python, dbus-python, GI and GTK4.
|
||||
Nothing is downloaded, and no sudo, driver registration, system setting,
|
||||
service or autostart is added. `FRAME_ALIAS` can select another SSH alias.
|
||||
The desktop app/server keeps its existing stdlib-only dependency set.
|
||||
|
||||
## Eye tracking → OSC
|
||||
|
||||
Start with a ten-second capability/data-availability check:
|
||||
|
||||
```sh
|
||||
python3 scripts/tracking-on-frame.py gaze --seconds 10
|
||||
```
|
||||
|
||||
This prints support, session-state numbers and sample counters. It opens no
|
||||
OSC socket and prints no gaze coordinates. Exit 0 means at least one valid
|
||||
sample, 3 means no valid sample was observed, and 1 means an API/runtime error.
|
||||
If there are no valid samples, wake/wear the headset and check its tracking
|
||||
setup; a successful capability check alone does not prove usable gaze.
|
||||
|
||||
To send to VRChat running **on the Frame**, explicitly enable OSC in VRChat
|
||||
and choose its local UDP endpoint:
|
||||
|
||||
```sh
|
||||
python3 scripts/tracking-on-frame.py gaze --seconds 3600 --osc 127.0.0.1 9000
|
||||
```
|
||||
|
||||
For a receiver on another computer, replace `127.0.0.1` with that computer's
|
||||
IP address and choose its listening port. Addresses are IP literals (IPv4 or
|
||||
IPv6); there is no discovery or default destination. Loopback here always
|
||||
means **the Frame**, not the computer running the SSH command. OSC uses
|
||||
unencrypted UDP: configure only a receiver you intend to receive this data.
|
||||
|
||||
**Documented:** [VRChat's eye OSC interface](https://docs.vrchat.com/docs/osc-eye-tracking)
|
||||
accepts `/tracking/eye/CenterPitchYaw` with two floats in degrees, positive down
|
||||
and right. We locate OpenXR's combined gaze pose relative to VIEW (the head),
|
||||
rotate its -Z forward vector and convert that direction to these angles.
|
||||
Only active, orientation-valid **and tracked** samples are sent, at up to
|
||||
30 Hz. No eyelid/blink, individual-eye or face values are invented. We do not
|
||||
send neutral gaze on tracking loss; VRChat's documented timeout restores its
|
||||
automatic eye behaviour after input stops.
|
||||
|
||||
**Privacy:** gaze is personal data. It stays in process memory and a private
|
||||
pipe between our reader and bridge. There is no gaze log option, telemetry,
|
||||
OSC receiver or raw gaze on stdout/stderr. Only an explicit `--osc IP PORT`
|
||||
opens an output socket. Runtime diagnostics and validity counters are not
|
||||
measurements. Stop with Ctrl-C or let `--seconds` expire (maximum 24 hours).
|
||||
A lost headless session ends the run; it does not silently reconnect.
|
||||
|
||||
## BLE heart rate → our panel, OSC and optional log
|
||||
|
||||
First discover/pair your strap in SteamOS's Bluetooth settings. Select that
|
||||
strap's Bluetooth address explicitly; our tool does not scan for or connect
|
||||
to arbitrary nearby devices.
|
||||
|
||||
```sh
|
||||
python3 scripts/tracking-on-frame.py heart \
|
||||
--device AA:BB:CC:DD:EE:FF --panel --seconds 3600
|
||||
```
|
||||
|
||||
This uses BlueZ's standard Heart Rate Service (`180d`) and Heart Rate
|
||||
Measurement (`2a37`) notifications. It finds the characteristic only beneath
|
||||
the selected device's HRS service. The reader handles 8- and 16-bit BPM,
|
||||
contact flags and optional energy/RR fields; energy and RR intervals are
|
||||
validated for length but discarded. Zero BPM, reported loss of skin contact,
|
||||
malformed packets and readings older than five seconds are not shown as a
|
||||
current measurement. A disconnect stops the run; reconnect and start again.
|
||||
This is a social/fitness readout, not a medical monitor.
|
||||
|
||||
The panel is our GTK4 window on gamescope's X display. Use SteamVR's panel
|
||||
controls to float/dock it (see [panels](panels.md)). **Stop**, closing the panel,
|
||||
Ctrl-C, SSH hangup or the duration limit ends our subscription. A connection
|
||||
that was already open when we started is preserved; a connection we opened
|
||||
is disconnected on exit. No Bluetooth power or pairing state is changed.
|
||||
|
||||
Add either output explicitly:
|
||||
|
||||
```sh
|
||||
python3 scripts/tracking-on-frame.py heart \
|
||||
--device AA:BB:CC:DD:EE:FF --panel --seconds 3600 \
|
||||
--osc 127.0.0.1 9000 --address /avatar/parameters/HeartRate \
|
||||
--log /home/steamos/heart-session.csv
|
||||
```
|
||||
|
||||
The OSC value is integer BPM. `HeartRate` is a chosen avatar parameter, **not a
|
||||
built-in VRChat heart-rate feature**; your avatar/receiver must define the
|
||||
matching parameter. `--address` can select another literal OSC path. The local
|
||||
panel works without OSC, a log or an avatar integration.
|
||||
|
||||
The optional CSV contains only `unix_seconds,bpm`. It is created privately
|
||||
(mode 0600), refuses existing files/symlinks, and lives **on the Frame** at the
|
||||
path you specify. Nothing is logged by default, and heart-rate values are not
|
||||
printed to the terminal. Delete your session file when you no longer need it.
|
||||
|
||||
### Checking heart rate against a reference
|
||||
|
||||
`scripts/heart-check.py` runs on your computer. `listen` shows our OSC
|
||||
readings live as they arrive, so you can watch them next to another device:
|
||||
|
||||
```sh
|
||||
python3 scripts/heart-check.py listen --port 9000 --out ours.csv
|
||||
```
|
||||
|
||||
Point the Frame at it with `--osc <your computer's IP> 9000`. `compare` lines
|
||||
up two recordings by time and reports the mean difference, bias, the share
|
||||
within ±5 BPM and the delay between them. It passes when the mean difference
|
||||
is at most 5 BPM, at least 80% of reference readings are matched and nothing
|
||||
was shown while the sensor reported lost skin contact:
|
||||
|
||||
```sh
|
||||
python3 scripts/heart-check.py compare ours.csv reference.csv
|
||||
python3 scripts/heart-check.py compare ours.csv ~/Downloads/export.zip
|
||||
```
|
||||
|
||||
The reference can be a CSV (`time,bpm[,flags]`, time in unix seconds or ISO
|
||||
8601) or an Apple Health export (`export.zip` or `export.xml`). Only heart-rate
|
||||
records within the recording's time range are read. Everything stays on your
|
||||
computer.
|
||||
|
||||
`scripts/heart-test-strap.swift` turns a Mac into a synthetic strap. It
|
||||
advertises the standard Heart Rate Service and sends a fixed, known sequence
|
||||
(8-bit and 16-bit values and a skin-contact loss), printing each sent value, so
|
||||
`compare` can check that the Frame shows exactly what was sent. It needs
|
||||
Bluetooth permission for the process that runs it. **Untested on 2026-09-29:**
|
||||
it compiled, but on this Mac, launched from an agent session, macOS never
|
||||
delivered a Bluetooth state and no permission prompt appeared, so it never
|
||||
advertised.
|
||||
|
||||
## Pulse from the eye cameras (experimental)
|
||||
|
||||
The Frame has no heart-rate sensor. **Verified 2026-09-29** (SteamOS 0.4.1,
|
||||
build `20260925.6191901`): its sensors are an ambient light/proximity sensor
|
||||
(`vcnl4000`), a hall sensor (`als31300`), two passthrough cameras
|
||||
(`arcimx616`), two tracking cameras (`og01a1b`) and two IR eye cameras
|
||||
(`og0ve10`). There is no optical heart-rate (PPG) sensor.
|
||||
|
||||
The experiment asks whether the eye cameras can see a pulse anyway. With each
|
||||
heartbeat, the blood volume in the skin around the eye changes slightly and
|
||||
its IR reflectance changes with it. This is camera-based photoplethysmography;
|
||||
near-IR works, though the signal is weaker than in green light.
|
||||
|
||||
```sh
|
||||
python3 scripts/tracking-on-frame.py pulse --seconds 60 --show
|
||||
```
|
||||
|
||||
How it works:
|
||||
|
||||
- **Capture (verified).** SteamVR ships `eyetracking --calib N`, which saves
|
||||
both eye cameras for N seconds as 400×400 8-bit IR PNGs with a monotonic
|
||||
timestamp per frame, at about 90 fps per eye. SteamVR's live eye tracker,
|
||||
part of `steamvr.service`, gets its frames from the DSP and stops its
|
||||
cameras when the headset is off. Unworn captures ran alongside it: its PID
|
||||
and log were unchanged and our OpenXR gaze session still started
|
||||
afterwards. **Untested:** whether the capture and the live tracker coexist
|
||||
while the headset is worn and tracking.
|
||||
- **Privacy.** Each image is reduced to a 16×16 grid of patch averages as
|
||||
soon as it is complete, then deleted. Three worker processes do this beside
|
||||
the capture. If more than 900 images (about five seconds) ever wait, we stop
|
||||
reading them and delete them undecoded until the capture ends, and report
|
||||
an error. The capture directory is removed on exit, even after errors. No image is kept or leaves the Frame. The estimate
|
||||
is printed only with `--show`, and sent or saved only with `--osc` or
|
||||
`--log`, as for the strap.
|
||||
- **Estimate.** Patch traces are averaged down to 15 Hz and turned into
|
||||
relative change. A 2-second moving median removes drift and blinks.
|
||||
Patches with frequent spikes (the eyeball and eyelid) are dropped, as are
|
||||
dark or saturated ones. The 20% of patches with the clearest rhythm between
|
||||
42 and 180 BPM are combined in the frequency domain. Output is an overall
|
||||
estimate plus one estimate per second over 15-second windows. A result
|
||||
counts as **clear** only when the top patches agree and the combined signal
|
||||
stands out from the noise. Otherwise the command exits 3 and sends no OSC.
|
||||
`--log` still records the per-second estimates, so a comparison shows how
|
||||
far off an unclear result was.
|
||||
The thresholds are provisional until checked on real wearers.
|
||||
|
||||
**Verified on the Frame, unworn, 2026-09-29:** captures of 3,600-5,400 eye
|
||||
frames never had more than 8 images on disk, finished a few seconds after the
|
||||
capture ended and left no capture directory. **The estimator alone gave a
|
||||
false "clear" pulse.** With nobody wearing the headset, five runs reported a
|
||||
steady, self-consistent rhythm (90, 90, 93, 94 and 96 BPM; patch agreement
|
||||
100%, signal/noise 0.63-0.70), and earlier runs reported 127-129 BPM at lower
|
||||
signal/noise. It is a periodic camera or illumination artifact, and its
|
||||
frequency drifts between runs. A wearer-less scene cannot contain a pulse, so
|
||||
the signal/noise gate cannot tell this artifact from one. Because of that,
|
||||
`pulse` reads the Frame's proximity sensor (`vcnl4000`) before and after the
|
||||
capture. It reads about 3 unworn (**verified**). If either reading is below 20
|
||||
the result is never called clear, nothing is sent over OSC, and the command
|
||||
exits 3. **Inferred, unmeasured:** that a worn reading is well above 20; the
|
||||
cut-off is provisional until someone wears the headset. If the sensor can't be
|
||||
read, the guard is skipped. Confirming a real pulse also needs a reference
|
||||
(below).
|
||||
|
||||
**Do not interrupt the capture. Verified on the Frame, 2026-09-29:** sending
|
||||
SIGTERM to `eyetracking --calib` left the DSP service's eye camera (OV6211)
|
||||
stuck "streaming": its log had no "Stopping streaming" line, and every later
|
||||
request failed with "Failed to start streaming". Head tracking kept working.
|
||||
Clearing it needs the DSP service restarted or the Frame rebooted, so `pulse`
|
||||
never signals the tool. After Ctrl-C or a failure it keeps deleting images
|
||||
until the tool ends by itself (at most the `--seconds` plus a few seconds),
|
||||
and only kills a tool that overruns by 30 s, with a warning that the eye
|
||||
cameras may need a reboot. So an interrupted run can take a while to return.
|
||||
|
||||
**Verified on synthetic data** (unit tests): a 0.3% brightness pulse in a
|
||||
third of the patches, with noise, drift, blinks and eye movement, is
|
||||
recovered within 1.5 BPM at 58, 72 and 115 BPM; noise and blinks alone are
|
||||
not reported as a pulse. **Not yet verified:** whether a real wearer's eye
|
||||
images contain a usable pulse, and how accurate it is. That needs someone
|
||||
wearing the headset and a reference, as below.
|
||||
|
||||
### Comparing with an Apple Watch
|
||||
|
||||
1. On the watch, start a workout (for example **Other**) so it measures heart
|
||||
rate every few seconds rather than occasionally.
|
||||
2. Put the Frame on, sit still and look ahead. Run:
|
||||
|
||||
```sh
|
||||
python3 scripts/tracking-on-frame.py pulse --seconds 120 --show \
|
||||
--log /home/steamos/pulse.csv
|
||||
```
|
||||
|
||||
The per-second estimates print at the end. Compare them with what the
|
||||
watch showed.
|
||||
3. End the workout. On the iPhone, open Health → your picture → **Export All
|
||||
Health Data**, and AirDrop `export.zip` to the Mac.
|
||||
4. On the Mac:
|
||||
|
||||
```sh
|
||||
scp frame:pulse.csv . && ssh frame rm pulse.csv
|
||||
python3 scripts/heart-check.py compare pulse.csv ~/Downloads/export.zip
|
||||
```
|
||||
|
||||
This first version analyses after the capture ends, because the method must
|
||||
prove itself before a live panel is worth building. The Apple Watch is a
|
||||
reference, not ground truth: in workouts it is typically within a few BPM of
|
||||
a chest strap when you are still.
|
||||
|
||||
## SlimeVR: feasibility only
|
||||
|
||||
SlimeVR is an independent application stack. Neither of our features installs,
|
||||
launches or depends on it. Users who want it can follow
|
||||
[SlimeVR's setup documentation](https://docs.slimevr.dev/server/index.html).
|
||||
The consented upstream releases tested were
|
||||
[server v21.1.0](https://github.com/SlimeVR/SlimeVR-Server/releases/tag/v21.1.0)
|
||||
and [driver v6.0.0](https://github.com/SlimeVR/SlimeVR-OpenVR-Driver/releases/tag/v6.0.0),
|
||||
under SlimeVR's MIT/Apache-2.0 licensing.
|
||||
|
||||
**Verified layout, read-only:** the Frame's registered runtime is `/opt/steamvr`;
|
||||
its native driver is `drivers/cv/bin/linuxarm64/driver_cv.so`, with a
|
||||
`drivers/cv/driver.vrdrivermanifest`. Frame controller manifests/resources are
|
||||
under `drivers/frame_controller/`. Configuration is under
|
||||
`~/.config/openvr/config/`, not the Steam client's config directory. The
|
||||
SlimeVR release also uses `slimevr/bin/linuxarm64/driver_slimevr.so` plus its
|
||||
manifest. Nothing in those installed SteamVR directories was changed.
|
||||
|
||||
**Inferred:** the matching ABI/layout and standalone factory success make
|
||||
SteamVR integration plausible. They do not prove successful driver `Init`,
|
||||
server/driver IPC, tracking, or calibration. That needs a separate integration
|
||||
check with hardware and an agreed SteamVR restart. No Java executable was on
|
||||
PATH for this check, so an isolated JRE was used. SlimeVR's server opens LAN
|
||||
listeners; our temporary server was stopped and the temporary downloads,
|
||||
configuration and logs were removed. It is not left installed or running.
|
||||
|
||||
## Tests and remaining checks
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
`tests/test_tracking.py` covers HRS packet parsing, contact/staleness, OSC
|
||||
padding/types and a real loopback socket, quaternion signs, opt-in networking,
|
||||
private/exclusive logging and a fake BlueZ object tree. The fake checks service
|
||||
ownership, notification routing, delayed GATT discovery and connection cleanup.
|
||||
It does not pretend to be a physical strap or a real OpenXR runtime.
|
||||
|
||||
Before calling hardware support complete, attach a strap and check BPM against
|
||||
its own display/reference, loss of contact, disconnect/reconnect, Stop, OSC and
|
||||
CSV together. Check avatar eyes while looking up/down/left/right in a supported
|
||||
VRChat session. No third-party tracking app is needed for either test.
|
||||
|
||||
Independent review attempt: `devin -p --model swe-2-max` with the frozen diff,
|
||||
contribution standards and read-only instructions returned no output for ten
|
||||
minutes. It was terminated with exit 143. No completed review or actual model
|
||||
identity was returned; hardware checks and independent review remain follow-up
|
||||
work before making the draft ready.
|
||||
@@ -0,0 +1,135 @@
|
||||
/* Frame Control's OpenXR gaze source. No values on stdout/stderr or disk.
|
||||
* The Python bridge supplies a private pipe with --fd; standalone probes only
|
||||
* report counters. Uses a headless session, never submits frames or takes focus. */
|
||||
#define XR_USE_TIMESPEC
|
||||
#include <time.h>
|
||||
#include <openxr/openxr.h>
|
||||
#include <openxr/openxr_platform.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static volatile sig_atomic_t stopped;
|
||||
static void stop(int sig) { (void)sig; stopped = 1; }
|
||||
#define CHECK(call) do { result = (call); if (XR_FAILED(result)) { \
|
||||
fprintf(stderr, "%s failed (%d)\n", #call, result); goto cleanup; } } while (0)
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int seconds = 10, fd = -1, running = 0, rc = 1;
|
||||
unsigned active = 0, valid = 0, samples = 0;
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (!strcmp(argv[i], "--seconds") && i+1 < argc) seconds = atoi(argv[++i]);
|
||||
else if (!strcmp(argv[i], "--fd") && i+1 < argc) fd = atoi(argv[++i]);
|
||||
else { fprintf(stderr, "usage: gaze [--seconds 1..86400] [--fd private-pipe]\n"); return 2; }
|
||||
}
|
||||
if (seconds < 1 || seconds > 86400 || (fd != -1 && fd < 3)) return 2;
|
||||
FILE *out = fd == -1 ? NULL : fdopen(fd, "w");
|
||||
if (fd != -1 && !out) return 2;
|
||||
signal(SIGINT, stop); signal(SIGTERM, stop); signal(SIGHUP, stop); signal(SIGPIPE, SIG_IGN);
|
||||
XrResult result;
|
||||
XrInstance instance = XR_NULL_HANDLE;
|
||||
XrSession session = XR_NULL_HANDLE;
|
||||
XrActionSet set = XR_NULL_HANDLE;
|
||||
XrSpace gaze = XR_NULL_HANDLE, view = XR_NULL_HANDLE;
|
||||
const char *extensions[] = {"XR_EXT_eye_gaze_interaction", "XR_MND_headless", "XR_KHR_convert_timespec_time"};
|
||||
XrInstanceCreateInfo create = {.type = XR_TYPE_INSTANCE_CREATE_INFO};
|
||||
strcpy(create.applicationInfo.applicationName, "Frame Control gaze");
|
||||
create.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0);
|
||||
create.enabledExtensionCount = 3; create.enabledExtensionNames = extensions;
|
||||
CHECK(xrCreateInstance(&create, &instance));
|
||||
XrSystemGetInfo get = {.type = XR_TYPE_SYSTEM_GET_INFO, .formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY};
|
||||
XrSystemId system;
|
||||
CHECK(xrGetSystem(instance, &get, &system));
|
||||
XrSystemEyeGazeInteractionPropertiesEXT eye = {.type = XR_TYPE_SYSTEM_EYE_GAZE_INTERACTION_PROPERTIES_EXT};
|
||||
XrSystemProperties props = {.type = XR_TYPE_SYSTEM_PROPERTIES, .next = &eye};
|
||||
CHECK(xrGetSystemProperties(instance, system, &props));
|
||||
printf("supportsEyeGazeInteraction=%u\n", eye.supportsEyeGazeInteraction);
|
||||
if (!eye.supportsEyeGazeInteraction) goto cleanup;
|
||||
XrSessionCreateInfo sc = {.type = XR_TYPE_SESSION_CREATE_INFO, .systemId = system};
|
||||
CHECK(xrCreateSession(instance, &sc, &session));
|
||||
XrActionSetCreateInfo asc = {.type = XR_TYPE_ACTION_SET_CREATE_INFO};
|
||||
strcpy(asc.actionSetName, "gaze"); strcpy(asc.localizedActionSetName, "Gaze");
|
||||
CHECK(xrCreateActionSet(instance, &asc, &set));
|
||||
XrActionCreateInfo ac = {.type = XR_TYPE_ACTION_CREATE_INFO, .actionType = XR_ACTION_TYPE_POSE_INPUT};
|
||||
strcpy(ac.actionName, "gaze_pose"); strcpy(ac.localizedActionName, "Gaze pose");
|
||||
XrAction action;
|
||||
CHECK(xrCreateAction(set, &ac, &action));
|
||||
XrPath profile, input;
|
||||
CHECK(xrStringToPath(instance, "/interaction_profiles/ext/eye_gaze_interaction", &profile));
|
||||
CHECK(xrStringToPath(instance, "/user/eyes_ext/input/gaze_ext/pose", &input));
|
||||
XrActionSuggestedBinding binding = {action, input};
|
||||
XrInteractionProfileSuggestedBinding suggested = {.type = XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING,
|
||||
.interactionProfile = profile, .countSuggestedBindings = 1, .suggestedBindings = &binding};
|
||||
CHECK(xrSuggestInteractionProfileBindings(instance, &suggested));
|
||||
XrSessionActionSetsAttachInfo attach = {.type = XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO, .countActionSets = 1, .actionSets = &set};
|
||||
CHECK(xrAttachSessionActionSets(session, &attach));
|
||||
XrActionSpaceCreateInfo space = {.type = XR_TYPE_ACTION_SPACE_CREATE_INFO, .action = action, .poseInActionSpace.orientation.w = 1};
|
||||
CHECK(xrCreateActionSpace(session, &space, &gaze));
|
||||
XrReferenceSpaceCreateInfo ref = {.type = XR_TYPE_REFERENCE_SPACE_CREATE_INFO, .referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW,
|
||||
.poseInReferenceSpace.orientation.w = 1};
|
||||
CHECK(xrCreateReferenceSpace(session, &ref, &view));
|
||||
PFN_xrConvertTimespecTimeToTimeKHR convert;
|
||||
CHECK(xrGetInstanceProcAddr(instance, "xrConvertTimespecTimeToTimeKHR", (PFN_xrVoidFunction *)&convert));
|
||||
struct timespec start, now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &start);
|
||||
while (!stopped) {
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
if (now.tv_sec - start.tv_sec >= seconds) break;
|
||||
XrEventDataBuffer event = {.type = XR_TYPE_EVENT_DATA_BUFFER};
|
||||
while ((result = xrPollEvent(instance, &event)) == XR_SUCCESS) {
|
||||
if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
|
||||
XrSessionState state = ((XrEventDataSessionStateChanged *)&event)->state;
|
||||
printf("sessionState=%d\n", state); fflush(stdout);
|
||||
if (state == XR_SESSION_STATE_READY && !running) {
|
||||
XrSessionBeginInfo begin = {.type = XR_TYPE_SESSION_BEGIN_INFO, .primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO};
|
||||
CHECK(xrBeginSession(session, &begin)); running = 1;
|
||||
} else if (state == XR_SESSION_STATE_STOPPING) {
|
||||
CHECK(xrEndSession(session)); running = 0; stopped = 1;
|
||||
} else if (state == XR_SESSION_STATE_EXITING || state == XR_SESSION_STATE_LOSS_PENDING) stopped = 1;
|
||||
} else if (event.type == XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING) stopped = 1;
|
||||
event.type = XR_TYPE_EVENT_DATA_BUFFER;
|
||||
}
|
||||
if (XR_FAILED(result)) goto cleanup;
|
||||
if (running && !stopped) {
|
||||
XrActiveActionSet activeSet = {set, XR_NULL_PATH};
|
||||
XrActionsSyncInfo sync = {.type = XR_TYPE_ACTIONS_SYNC_INFO, .countActiveActionSets = 1, .activeActionSets = &activeSet};
|
||||
CHECK(xrSyncActions(session, &sync));
|
||||
if (result != XR_SUCCESS) {
|
||||
struct timespec delay = {.tv_nsec = 33333333};
|
||||
nanosleep(&delay, NULL);
|
||||
continue; /* No stale gaze when the runtime denies focus. */
|
||||
}
|
||||
XrActionStateGetInfo ag = {.type = XR_TYPE_ACTION_STATE_GET_INFO, .action = action};
|
||||
XrActionStatePose pose = {.type = XR_TYPE_ACTION_STATE_POSE};
|
||||
CHECK(xrGetActionStatePose(session, &ag, &pose));
|
||||
samples++;
|
||||
if (pose.isActive) {
|
||||
active++;
|
||||
XrTime time; CHECK(convert(instance, &now, &time));
|
||||
XrSpaceLocation location = {.type = XR_TYPE_SPACE_LOCATION};
|
||||
CHECK(xrLocateSpace(gaze, view, time, &location));
|
||||
XrSpaceLocationFlags needed = XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_TRACKED_BIT;
|
||||
if ((location.locationFlags & needed) == needed) {
|
||||
valid++;
|
||||
if (out) {
|
||||
XrQuaternionf q = location.pose.orientation;
|
||||
if (fprintf(out, "%g %g %g %g\n", q.x, q.y, q.z, q.w) < 0 || fflush(out)) goto cleanup;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
struct timespec delay = {.tv_nsec = 33333333}; nanosleep(&delay, NULL);
|
||||
}
|
||||
printf("samples=%u active=%u valid=%u\n", samples, active, valid);
|
||||
rc = valid ? 0 : 3; /* Distinguish a working session from observed gaze. */
|
||||
cleanup:
|
||||
if (view) xrDestroySpace(view);
|
||||
if (gaze) xrDestroySpace(gaze);
|
||||
if (session) xrDestroySession(session);
|
||||
if (set) xrDestroyActionSet(set);
|
||||
if (instance) xrDestroyInstance(instance);
|
||||
if (out) fclose(out);
|
||||
return rc;
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Experimental pulse estimate from the Frame's IR eye-tracking cameras.
|
||||
|
||||
Skin brightens and darkens very slightly with each heartbeat as blood volume
|
||||
changes (photoplethysmography). The eye cameras film the skin around each eye
|
||||
under steady IR light at about 90 frames per second, so that rhythm may be
|
||||
visible in the average brightness of small patches of skin.
|
||||
|
||||
Capture uses SteamVR's own `eyetracking --calib` mode, which writes PNG pairs
|
||||
to /tmp. Each image is reduced to a grid of patch averages the moment it is
|
||||
complete, then deleted; the capture directory is removed on exit. No image
|
||||
leaves the Frame or outlives the run. This is an experiment, not a medical
|
||||
measurement.
|
||||
"""
|
||||
from array import array
|
||||
import bisect
|
||||
import cmath
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
ET_DIR = Path("/opt/steamvr/tools/eyetracking")
|
||||
ET_BIN = ET_DIR / "bin/linuxarm64/eyetracking"
|
||||
ET_WEIGHTS = ET_DIR / "resources/et_dsp_20250610_03136.weights"
|
||||
GRID = 16 # 16 × 16 patches of 25 × 25 pixels on the 400 × 400 image
|
||||
RATE = 15.0 # analysis sample rate, Hz; the band of interest ends at 3 Hz
|
||||
LOW, HIGH = 42.0, 180.0 # BPM search band
|
||||
# The Frame's proximity sensor (vcnl4000) reads about 3 with nobody wearing it.
|
||||
# A worn reading has not been measured yet, so the cut-off is provisional.
|
||||
IIO = Path("/sys/bus/iio/devices")
|
||||
WORN_MIN = 20.0
|
||||
WINDOW = 15.0 # seconds per windowed estimate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- capture ---
|
||||
|
||||
def grid_means(pixels, width, height, stride, channels, grid=GRID):
|
||||
"""Average of channel 0 in each of grid × grid equal patches."""
|
||||
bw, bh = width // grid, height // grid
|
||||
sums = [0] * (grid * grid)
|
||||
for y in range(bh * grid):
|
||||
start = y * stride
|
||||
row = pixels[start:start + width * channels:channels]
|
||||
base = (y // bh) * grid
|
||||
for bx in range(grid):
|
||||
sums[base + bx] += sum(row[bx * bw:(bx + 1) * bw])
|
||||
area = bw * bh
|
||||
return [s / area for s in sums]
|
||||
|
||||
|
||||
def load_grid(path):
|
||||
import gi
|
||||
gi.require_version("GdkPixbuf", "2.0")
|
||||
from gi.repository import GdkPixbuf
|
||||
image = GdkPixbuf.Pixbuf.new_from_file(str(path))
|
||||
return grid_means(image.read_pixel_bytes().get_data(), image.get_width(), image.get_height(),
|
||||
image.get_rowstride(), image.get_n_channels())
|
||||
|
||||
|
||||
def reduce_file(loader, path):
|
||||
"""Reduce one image to its patch grid and delete it, whatever happens."""
|
||||
try:
|
||||
return loader(path)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
class Capture:
|
||||
"""Run SteamVR's eye-camera capture and reduce frames as they arrive."""
|
||||
NAME = re.compile(r"^(left|right)_(\d+)\.png$")
|
||||
# Only SteamVR's own capture directories are read and removed.
|
||||
PREFIX = "/tmp/etcalib_"
|
||||
# Printed by the capture tool; its output is only flushed when it exits.
|
||||
WRITING = re.compile(r"Writing capture to: (\S+)")
|
||||
# About five seconds of frames (~65 MB in RAM-backed /tmp). If reduction
|
||||
# falls further behind than this, the capture stops rather than letting
|
||||
# eye images pile up.
|
||||
MAX_BACKLOG = 900
|
||||
|
||||
def __init__(self, seconds, runner=subprocess.Popen, loader=load_grid, workers=3):
|
||||
self.seconds, self.runner, self.loader, self.workers = seconds, runner, loader, workers
|
||||
self.grids = {"left": {}, "right": {}}
|
||||
self.directory = None
|
||||
self.pool = None
|
||||
self.submitted = set()
|
||||
self.futures = {}
|
||||
self.new = set() # every capture directory that appeared during our run
|
||||
self.before = None # capture directories that existed before the run
|
||||
self.log = None
|
||||
|
||||
def candidates(self):
|
||||
parent, stem = os.path.split(self.PREFIX)
|
||||
return {Path(entry.path) for entry in os.scandir(parent)
|
||||
if entry.name.startswith(stem) and entry.is_dir(follow_symlinks=False)}
|
||||
|
||||
def run(self):
|
||||
# The capture tool's output goes to a private temporary file, read for
|
||||
# failure messages. It is block-buffered, so the capture directory is
|
||||
# found by watching for a new one rather than waiting for its name.
|
||||
import tempfile
|
||||
self.before = before = self.candidates()
|
||||
process = None
|
||||
with tempfile.TemporaryFile("w+") as log:
|
||||
self.log = log
|
||||
try:
|
||||
if self.workers:
|
||||
# SteamVR pins its eye tracker to cores 0-1; decoding runs beside it.
|
||||
import concurrent.futures
|
||||
import multiprocessing
|
||||
self.pool = concurrent.futures.ProcessPoolExecutor(
|
||||
self.workers, mp_context=multiprocessing.get_context("fork"))
|
||||
process = self.runner([str(ET_BIN), "-b", "CDSP", "-w", str(ET_WEIGHTS), "--calib", str(self.seconds)],
|
||||
cwd=str(ET_BIN.parent), stdout=log, stderr=subprocess.STDOUT)
|
||||
deadline = time.monotonic() + self.seconds + 30
|
||||
while True:
|
||||
# Checked on every poll: a second capture directory means
|
||||
# we can't tell which images are ours, so stop.
|
||||
self.new |= self.candidates() - before
|
||||
if len(self.new) > 1:
|
||||
raise RuntimeError("another eye-camera capture is running")
|
||||
if not self.directory and self.new:
|
||||
self.directory = next(iter(self.new))
|
||||
finished = process.poll() is not None
|
||||
self.reduce(final=finished)
|
||||
if finished:
|
||||
break
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError("eye-camera capture did not finish")
|
||||
time.sleep(0.02)
|
||||
log.seek(0)
|
||||
text = log.read()
|
||||
if process.returncode or not self.directory:
|
||||
reason = "cameras unavailable" if "Failed to" in text else f"exit {process.returncode}"
|
||||
raise RuntimeError(f"eye-camera capture failed ({reason})")
|
||||
named = self.written(log)
|
||||
if named and named != self.directory:
|
||||
raise RuntimeError("the capture wrote somewhere else; not using those images")
|
||||
return self.frames()
|
||||
finally:
|
||||
# Each step runs even if an earlier one failed: eye images must
|
||||
# be removed whatever else went wrong.
|
||||
# Ctrl-C and SIGTERM (raised as KeyboardInterrupt) are held
|
||||
# until the images are gone, then re-raised.
|
||||
interrupted, failed = None, None
|
||||
for step in (lambda: self.finish(process),
|
||||
lambda: self.pool and self.pool.shutdown(wait=True, cancel_futures=True),
|
||||
self.adopt_reported):
|
||||
try:
|
||||
step()
|
||||
except BaseException as error:
|
||||
if isinstance(error, Exception):
|
||||
failed = failed or error
|
||||
else:
|
||||
interrupted = interrupted or error
|
||||
self.log = None
|
||||
self.remove()
|
||||
if interrupted:
|
||||
raise interrupted
|
||||
if failed:
|
||||
raise RuntimeError(f"the eye-camera capture did not stop cleanly: {failed}")
|
||||
|
||||
def finish(self, process):
|
||||
"""Let the capture tool end by itself, deleting its images meanwhile.
|
||||
|
||||
Never signal it: stopping the tool early leaves the headset's eye
|
||||
camera stuck streaming until the DSP service restarts (verified on the
|
||||
Frame with SIGTERM). Its run is bounded by `seconds`, so waiting is
|
||||
short. Only if it overruns by a wide margin is it killed."""
|
||||
if not process:
|
||||
return
|
||||
interrupted = None
|
||||
give_up = time.monotonic() + self.seconds + 30
|
||||
while True:
|
||||
try:
|
||||
if process.poll() is not None:
|
||||
break
|
||||
self.discard()
|
||||
if time.monotonic() > give_up:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise RuntimeError("the eye-camera capture had to be killed; "
|
||||
"the eye cameras may need a reboot to stream again")
|
||||
time.sleep(0.05)
|
||||
except KeyboardInterrupt as error: # keep cleaning up until it ends
|
||||
interrupted = interrupted or error
|
||||
if interrupted:
|
||||
raise interrupted
|
||||
|
||||
def discard(self):
|
||||
"""Delete the eye images written so far, without reading them."""
|
||||
if self.before is not None:
|
||||
self.new |= self.candidates() - self.before
|
||||
for directory in self.new | ({self.directory} if self.directory else set()):
|
||||
if str(directory).startswith(self.PREFIX) and directory.is_dir() and not directory.is_symlink():
|
||||
for entry in os.scandir(directory):
|
||||
if self.NAME.match(entry.name):
|
||||
try:
|
||||
os.unlink(entry.path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def adopt_reported(self):
|
||||
"""The directory the tool reported is ours by its own account."""
|
||||
named = self.written(self.log)
|
||||
if named and str(named).startswith(self.PREFIX):
|
||||
self.new.add(named)
|
||||
|
||||
def written(self, log):
|
||||
"""The directory the capture tool reported, once its output is flushed."""
|
||||
log.seek(0)
|
||||
match = self.WRITING.search(log.read())
|
||||
return Path(match.group(1)) if match else None
|
||||
|
||||
def reduce(self, final=False):
|
||||
"""Reduce and delete every complete image; an image is complete once a
|
||||
later one of the same eye exists, or the capture has ended."""
|
||||
if not self.directory or not self.directory.is_dir():
|
||||
return
|
||||
pending = {"left": [], "right": []}
|
||||
for entry in os.scandir(self.directory):
|
||||
match = self.NAME.match(entry.name)
|
||||
if match:
|
||||
pending[match.group(1)].append((int(match.group(2)), entry.path))
|
||||
if sum(len(files) for files in pending.values()) > self.MAX_BACKLOG:
|
||||
raise RuntimeError("eye-image processing fell behind; capture abandoned")
|
||||
for eye, files in pending.items():
|
||||
files.sort()
|
||||
ready = files if final else files[:-1]
|
||||
for index, path in ready:
|
||||
if path in self.submitted:
|
||||
continue
|
||||
self.submitted.add(path)
|
||||
if self.pool:
|
||||
self.futures[(eye, index)] = self.pool.submit(reduce_file, self.loader, path)
|
||||
else:
|
||||
self.grids[eye][index] = array("f", reduce_file(self.loader, path))
|
||||
for key, future in list(self.futures.items()):
|
||||
if final or future.done():
|
||||
self.grids[key[0]][key[1]] = array("f", future.result())
|
||||
del self.futures[key]
|
||||
|
||||
def frames(self):
|
||||
"""[(monotonic seconds, eye, grid)] joined with the capture metadata."""
|
||||
meta = json.loads((self.directory / "meta.json").read_text())
|
||||
frames = []
|
||||
for pair in meta["frames"]:
|
||||
for eye in ("left", "right"):
|
||||
info = pair.get(eye) or {}
|
||||
match = self.NAME.match(Path(info.get("fname", "")).name)
|
||||
grid = self.grids[eye].get(int(match.group(2))) if match else None
|
||||
if info.get("valid") and grid is not None:
|
||||
frames.append((float(info["tsMono"]), eye, grid))
|
||||
return frames
|
||||
|
||||
def remove(self):
|
||||
"""Remove every capture directory that appeared during the run. Eye
|
||||
images must not outlive it, so a failure to delete is an error."""
|
||||
left = []
|
||||
if self.before is not None:
|
||||
try:
|
||||
self.new |= self.candidates() - self.before # even if interrupted before the first poll
|
||||
except OSError:
|
||||
pass
|
||||
for directory in self.new | ({self.directory} if self.directory else set()):
|
||||
if str(directory).startswith(self.PREFIX) and directory.is_dir() and not directory.is_symlink():
|
||||
try:
|
||||
shutil.rmtree(directory)
|
||||
except OSError:
|
||||
left.append(str(directory))
|
||||
if left:
|
||||
raise RuntimeError("could not delete eye images in " + ", ".join(sorted(left)))
|
||||
|
||||
|
||||
# --------------------------------------------------------------- analysis ---
|
||||
|
||||
def fft(values):
|
||||
"""In-place iterative radix-2 FFT of a list whose length is a power of 2."""
|
||||
n = len(values)
|
||||
a = list(values)
|
||||
j = 0
|
||||
for i in range(1, n):
|
||||
bit = n >> 1
|
||||
while j & bit:
|
||||
j ^= bit
|
||||
bit >>= 1
|
||||
j |= bit
|
||||
if i < j:
|
||||
a[i], a[j] = a[j], a[i]
|
||||
size = 2
|
||||
while size <= n:
|
||||
step = cmath.exp(-2j * math.pi / size)
|
||||
half = size // 2
|
||||
for start in range(0, n, size):
|
||||
w = 1
|
||||
for k in range(start, start + half):
|
||||
t = w * a[k + half]
|
||||
a[k + half] = a[k] - t
|
||||
a[k] += t
|
||||
w *= step
|
||||
size *= 2
|
||||
return a
|
||||
|
||||
|
||||
def resample(times, values, rate=RATE):
|
||||
"""Average samples into 1/rate bins from the first sample; empty bins are
|
||||
filled from the previous bin."""
|
||||
if not times:
|
||||
return []
|
||||
start = times[0]
|
||||
count = int((times[-1] - start) * rate) + 1
|
||||
sums, counts = [0.0] * count, [0] * count
|
||||
for t, v in zip(times, values):
|
||||
i = min(int((t - start) * rate), count - 1)
|
||||
sums[i] += v
|
||||
counts[i] += 1
|
||||
out, last = [], None
|
||||
for s, c in zip(sums, counts):
|
||||
last = s / c if c else last
|
||||
out.append(last)
|
||||
first = next(v for v in out if v is not None)
|
||||
return [first if v is None else v for v in out]
|
||||
|
||||
|
||||
def clean(signal, rate=RATE):
|
||||
"""Relative change with slow drift removed; None if the patch is mostly
|
||||
blinks or eye movement. Spikes (blinks, saccades) become gaps at the
|
||||
local level rather than clipped steps, which would add false rhythm."""
|
||||
mean = sum(signal) / len(signal)
|
||||
x = [v / mean - 1 for v in signal]
|
||||
half = int(rate) # 2-second centred moving median removes drift and blinks
|
||||
trend = []
|
||||
for i in range(len(x)):
|
||||
chunk = sorted(x[max(0, i - half):i + half + 1])
|
||||
trend.append(chunk[len(chunk) // 2])
|
||||
residual = [v - m for v, m in zip(x, trend)]
|
||||
mad = sorted(abs(v) for v in residual)[len(residual) // 2] or 1e-9
|
||||
limit = 4 * 1.4826 * mad
|
||||
spikes = sum(1 for v in residual if abs(v) > limit)
|
||||
if spikes > 0.05 * len(residual):
|
||||
return None
|
||||
return [v if abs(v) <= limit else 0.0 for v in residual]
|
||||
|
||||
|
||||
def spectrum(signal, rate=RATE):
|
||||
"""[(bpm, power)] within the search band, Hann-windowed and zero-padded."""
|
||||
n = len(signal)
|
||||
size = 1
|
||||
while size < max(n * 4, 256):
|
||||
size *= 2
|
||||
window = [0.5 - 0.5 * math.cos(2 * math.pi * i / (n - 1)) for i in range(n)] if n > 1 else [1.0]
|
||||
padded = [s * w for s, w in zip(signal, window)] + [0.0] * (size - n)
|
||||
result = fft(padded)
|
||||
out = []
|
||||
for k in range(size // 2):
|
||||
bpm = k * rate / size * 60
|
||||
if LOW <= bpm <= HIGH:
|
||||
out.append((bpm, abs(result[k]) ** 2))
|
||||
return out
|
||||
|
||||
|
||||
def peak(spec):
|
||||
"""Peak BPM with parabolic interpolation between spectral bins."""
|
||||
i = max(range(len(spec)), key=lambda k: spec[k][1])
|
||||
if 0 < i < len(spec) - 1:
|
||||
a, b, c = spec[i - 1][1], spec[i][1], spec[i + 1][1]
|
||||
denominator = a - 2 * b + c
|
||||
offset = 0.5 * (a - c) / denominator if denominator else 0.0
|
||||
return spec[i][0] + offset * (spec[1][0] - spec[0][0])
|
||||
return spec[i][0]
|
||||
|
||||
|
||||
def snr(spec, bpm, width=4.0):
|
||||
"""Power near the pulse and its first harmonic against the rest of the band."""
|
||||
near = sum(p for f, p in spec if abs(f - bpm) <= width or abs(f - 2 * bpm) <= width)
|
||||
rest = sum(p for f, p in spec) - near
|
||||
if near <= 0:
|
||||
return 0.0
|
||||
return near / rest if rest > 0 else float("inf")
|
||||
|
||||
|
||||
def normalised(spec):
|
||||
total = sum(p for _, p in spec) or 1.0
|
||||
return [p / total for _, p in spec]
|
||||
|
||||
|
||||
def usable(values):
|
||||
"""Patches that are neither dark nor saturated for the whole recording."""
|
||||
mean = sum(values) / len(values)
|
||||
return 8 <= mean <= 245
|
||||
|
||||
|
||||
def estimate(times, patches, rate=RATE, share=0.2, window=WINDOW):
|
||||
"""Estimate pulse from patch brightness traces.
|
||||
|
||||
times: monotonic seconds per frame. patches: {name: [brightness per frame]}.
|
||||
Selects the share of usable patches with the clearest periodic signal,
|
||||
combines their spectra and reports the overall and windowed estimates.
|
||||
"""
|
||||
cleaned = {}
|
||||
for name, values in patches.items():
|
||||
if usable(values):
|
||||
signal = clean(resample(times, values, rate), rate)
|
||||
if signal is not None and any(signal): # flat patches carry no rhythm
|
||||
cleaned[name] = signal
|
||||
if not cleaned or len(next(iter(cleaned.values()))) < rate * 8:
|
||||
raise ValueError("need at least 8 seconds of usable eye-camera frames")
|
||||
quality = []
|
||||
for name, signal in cleaned.items():
|
||||
spec = spectrum(signal, rate)
|
||||
own = peak(spec)
|
||||
quality.append((snr(spec, own), name, own, spec))
|
||||
quality.sort(reverse=True)
|
||||
chosen = quality[:max(4, int(len(quality) * share))]
|
||||
combined = [sum(values) for values in zip(*(normalised(spec) for _, _, _, spec in chosen))]
|
||||
bins = [f for f, _ in chosen[0][3]]
|
||||
bpm = peak(list(zip(bins, combined)))
|
||||
top = chosen[:8]
|
||||
agree = sum(1 for _, _, own, _ in top if abs(own - bpm) <= 5) / len(top)
|
||||
series = []
|
||||
samples = int(window * rate)
|
||||
length = len(next(iter(cleaned.values())))
|
||||
for end in range(samples, length + 1, int(rate)):
|
||||
specs = [spectrum(cleaned[name][end - samples:end], rate) for _, name, _, _ in chosen]
|
||||
total = [sum(values) for values in zip(*(normalised(s) for s in specs))]
|
||||
window_bins = [f for f, _ in specs[0]]
|
||||
series.append((times[0] + end / rate, peak(list(zip(window_bins, total)))))
|
||||
return {
|
||||
"bpm": bpm,
|
||||
"agreement": agree,
|
||||
"patches": len(chosen),
|
||||
"usable": len(cleaned),
|
||||
"snr": snr(list(zip(bins, combined)), bpm),
|
||||
"series": series,
|
||||
}
|
||||
|
||||
|
||||
def analyse(frames):
|
||||
"""Estimate from Capture.frames(); both eyes' patches are analysed together
|
||||
on a shared time base, each sample taken from that eye's nearest frame."""
|
||||
times = sorted({t for t, _, _ in frames})
|
||||
patches = {}
|
||||
for eye in ("left", "right"):
|
||||
eye_frames = sorted((t, grid) for t, e, grid in frames if e == eye)
|
||||
if not eye_frames:
|
||||
continue
|
||||
eye_times = [t for t, _ in eye_frames]
|
||||
nearest = []
|
||||
for t in times:
|
||||
i = bisect.bisect_left(eye_times, t)
|
||||
if i == len(eye_times) or (i > 0 and t - eye_times[i - 1] <= eye_times[i] - t):
|
||||
i -= 1
|
||||
nearest.append(i)
|
||||
for k in range(len(eye_frames[0][1])):
|
||||
patches[f"{eye}{k}"] = [eye_frames[i][1][k] for i in nearest]
|
||||
return estimate(times, patches)
|
||||
|
||||
|
||||
def proximity(root=None):
|
||||
"""The headset's proximity reading, or None if it can't be read."""
|
||||
try:
|
||||
for device in sorted(Path(root or IIO).glob("iio:device*")):
|
||||
if (device / "name").read_text().strip() == "vcnl4000":
|
||||
return float((device / "in_proximity_raw").read_text())
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def worn(reading):
|
||||
"""False only when the sensor says the headset is not on a face."""
|
||||
return reading is None or reading >= WORN_MIN
|
||||
|
||||
|
||||
def reliable(result):
|
||||
"""Whether the estimate is clear enough to show as a reading. Thresholds
|
||||
are provisional until checked against a reference on a real wearer."""
|
||||
return result["agreement"] >= 0.75 and result["snr"] >= 0.5
|
||||
@@ -0,0 +1,438 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frame-local tracking tools. No network destination or data log by default."""
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
class TrackingError(RuntimeError):
|
||||
"""A safe, actionable status message containing no sensor data."""
|
||||
|
||||
|
||||
HRS = "0000180d-0000-1000-8000-00805f9b34fb"
|
||||
MEASUREMENT = "00002a37-0000-1000-8000-00805f9b34fb"
|
||||
DEVICE = "org.bluez.Device1"
|
||||
SERVICE = "org.bluez.GattService1"
|
||||
CHARACTERISTIC = "org.bluez.GattCharacteristic1"
|
||||
|
||||
|
||||
def heart_rate(data):
|
||||
"""Validate the Bluetooth HRS measurement, returning BPM/contact only.
|
||||
|
||||
Energy and RR intervals are checked for length but never retained.
|
||||
None means contact is supported and the strap reports no skin contact.
|
||||
"""
|
||||
data = bytes(data)
|
||||
if len(data) < 2 or data[0] & 0xe0:
|
||||
raise ValueError("invalid HRS measurement")
|
||||
flags = data[0]
|
||||
size = 2 if flags & 1 else 1
|
||||
end = 1 + size + (2 if flags & 8 else 0)
|
||||
if len(data) < end:
|
||||
raise ValueError("truncated HRS measurement")
|
||||
extra = len(data) - end
|
||||
if (flags & 16 and (extra < 2 or extra % 2)) or (not flags & 16 and extra):
|
||||
raise ValueError("invalid HRS optional fields")
|
||||
if flags & 4 and not flags & 2:
|
||||
return None
|
||||
bpm = int.from_bytes(data[1:1 + size], "little")
|
||||
return bpm if bpm else None
|
||||
|
||||
|
||||
def gaze_angles(quaternion):
|
||||
"""OpenXR head-relative -Z forward → VRChat degrees, down/right positive."""
|
||||
if len(quaternion) != 4 or not all(math.isfinite(v) for v in quaternion):
|
||||
raise ValueError("invalid gaze orientation")
|
||||
norm = math.sqrt(sum(v * v for v in quaternion))
|
||||
if not 0.9 < norm < 1.1:
|
||||
raise ValueError("invalid gaze orientation")
|
||||
x, y, z, w = (v / norm for v in quaternion)
|
||||
# Rotate OpenXR's forward vector (0, 0, -1) into VIEW space.
|
||||
dx, dy, dz = -2 * (x*z + w*y), 2 * (w*x - y*z), 2 * (x*x + y*y) - 1
|
||||
return math.degrees(math.atan2(-dy, math.hypot(dx, dz))), math.degrees(math.atan2(dx, -dz))
|
||||
|
||||
|
||||
def osc_message(address, values):
|
||||
if not address.startswith("/") or any(c.isspace() or c in '\0#*,?[]{}' for c in address):
|
||||
raise ValueError("OSC address must be a literal path")
|
||||
def string(value):
|
||||
encoded = value.encode("utf-8") + b"\0"
|
||||
return encoded + b"\0" * (-len(encoded) % 4)
|
||||
tags, payload = ",", b""
|
||||
for value in values:
|
||||
if type(value) is int:
|
||||
tags += "i"
|
||||
payload += struct.pack(">i", value)
|
||||
else:
|
||||
if not math.isfinite(value):
|
||||
raise ValueError("OSC value must be finite")
|
||||
tags += "f"
|
||||
payload += struct.pack(">f", value)
|
||||
return string(address) + string(tags) + payload
|
||||
|
||||
|
||||
class Osc:
|
||||
def __init__(self, endpoint=None):
|
||||
self.sock = None
|
||||
self.target = None
|
||||
if endpoint:
|
||||
import ipaddress
|
||||
address = ipaddress.ip_address(endpoint[0])
|
||||
port = int(endpoint[1])
|
||||
if address.is_unspecified or address.is_multicast or not 1 <= port <= 65535:
|
||||
raise ValueError("OSC needs a unicast IP address and port 1..65535")
|
||||
self.sock = socket.socket(socket.AF_INET6 if address.version == 6 else socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.target = (str(address), port)
|
||||
|
||||
def send(self, address, values):
|
||||
if self.sock:
|
||||
self.sock.sendto(osc_message(address, values), self.target)
|
||||
|
||||
def close(self):
|
||||
if self.sock:
|
||||
self.sock.close()
|
||||
|
||||
|
||||
class HeartSession:
|
||||
def __init__(self, osc, address, log=None, clock=time.monotonic):
|
||||
self.osc, self.address, self.clock = osc, address, clock
|
||||
self.bpm, self.updated = None, None
|
||||
self.log = None
|
||||
if log:
|
||||
# Exclusive creation refuses existing files and symlinks; mode is
|
||||
# private even with a permissive process umask.
|
||||
fd = os.open(log, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
self.log = os.fdopen(fd, "w")
|
||||
self.log.write("unix_seconds,bpm\n")
|
||||
|
||||
def notification(self, data):
|
||||
self.bpm = heart_rate(data)
|
||||
self.updated = self.clock()
|
||||
if self.bpm is not None:
|
||||
self.osc.send(self.address, [self.bpm])
|
||||
if self.log:
|
||||
self.log.write(f"{time.time():.3f},{self.bpm}\n")
|
||||
self.log.flush()
|
||||
|
||||
def current(self):
|
||||
if self.updated is None or self.clock() - self.updated > 5:
|
||||
return None
|
||||
return self.bpm
|
||||
|
||||
def close(self):
|
||||
if self.log:
|
||||
self.log.close()
|
||||
|
||||
|
||||
class BluezHeart:
|
||||
"""One explicitly selected, already discovered strap; no ambient scan."""
|
||||
def __init__(self, bus, interface, address, on_value):
|
||||
self.bus, self.interface, self.on_value = bus, interface, on_value
|
||||
self.device = self.characteristic = None
|
||||
self.connected_here = False
|
||||
self.notifying = False
|
||||
self.match = None
|
||||
objects = self.objects()
|
||||
matches = [path for path, interfaces in objects.items()
|
||||
if str(interfaces.get(DEVICE, {}).get("Address", "")).upper() == address.upper()]
|
||||
if len(matches) != 1:
|
||||
raise TrackingError("Strap not found uniquely in BlueZ; pair/discover it in SteamOS Bluetooth settings first")
|
||||
self.device = matches[0]
|
||||
self.match = bus.add_signal_receiver(self.changed, signal_name="PropertiesChanged",
|
||||
dbus_interface="org.freedesktop.DBus.Properties",
|
||||
bus_name="org.bluez", path_keyword="path")
|
||||
try:
|
||||
if not objects[self.device][DEVICE].get("Connected"):
|
||||
self.call(self.device, DEVICE).Connect(timeout=20)
|
||||
self.connected_here = True
|
||||
except Exception:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def objects(self):
|
||||
return self.call("/", "org.freedesktop.DBus.ObjectManager").GetManagedObjects()
|
||||
|
||||
def call(self, path, kind):
|
||||
return self.interface(self.bus.get_object("org.bluez", path), kind)
|
||||
|
||||
def subscribe(self):
|
||||
objects = self.objects()
|
||||
if not objects.get(self.device, {}).get(DEVICE, {}).get("ServicesResolved"):
|
||||
return False
|
||||
services = {p for p, obj in objects.items() if str(obj.get(SERVICE, {}).get("UUID", "")).lower() == HRS
|
||||
and obj[SERVICE].get("Device") == self.device}
|
||||
for path, obj in objects.items():
|
||||
props = obj.get(CHARACTERISTIC, {})
|
||||
if props.get("Service") in services and str(props.get("UUID", "")).lower() == MEASUREMENT:
|
||||
if "notify" not in props.get("Flags", []):
|
||||
raise TrackingError("Heart-rate characteristic does not support notifications")
|
||||
self.characteristic = path
|
||||
self.call(path, CHARACTERISTIC).StartNotify()
|
||||
self.notifying = True
|
||||
return True
|
||||
raise TrackingError("Selected device has no standard Heart Rate Service measurement")
|
||||
|
||||
def changed(self, kind, changes, invalidated, path=None):
|
||||
if kind == CHARACTERISTIC and path == self.characteristic and "Value" in changes:
|
||||
self.on_value(changes["Value"])
|
||||
elif kind == DEVICE and path == self.device and "Connected" in changes and not changes["Connected"]:
|
||||
self.on_value(None)
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
if self.notifying:
|
||||
self.call(self.characteristic, CHARACTERISTIC).StopNotify()
|
||||
finally:
|
||||
if self.match:
|
||||
self.match.remove()
|
||||
if self.connected_here:
|
||||
self.call(self.device, DEVICE).Disconnect()
|
||||
|
||||
|
||||
def run_gaze(args, osc):
|
||||
binary = Path(__file__).with_name("gaze")
|
||||
command = [str(binary), "--seconds", str(args.seconds)]
|
||||
if not args.osc:
|
||||
return subprocess.call(command)
|
||||
read_fd, write_fd = os.pipe()
|
||||
process = None
|
||||
try:
|
||||
process = subprocess.Popen(command + ["--fd", str(write_fd)], pass_fds=(write_fd,))
|
||||
os.close(write_fd)
|
||||
write_fd = None
|
||||
with os.fdopen(read_fd) as source:
|
||||
read_fd = None
|
||||
for line in source:
|
||||
try:
|
||||
angles = gaze_angles([float(v) for v in line.split()])
|
||||
except ValueError:
|
||||
continue
|
||||
osc.send("/tracking/eye/CenterPitchYaw", angles)
|
||||
return process.wait()
|
||||
finally:
|
||||
if read_fd is not None:
|
||||
os.close(read_fd)
|
||||
if write_fd is not None:
|
||||
os.close(write_fd)
|
||||
if process and process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
class HeartPanel:
|
||||
"""Our GTK panel, using the Frame's existing GTK4/GI platform libraries."""
|
||||
def __init__(self):
|
||||
os.environ["GDK_BACKEND"] = "x11"
|
||||
import gi
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("GdkX11", "4.0")
|
||||
from gi.repository import Gtk, Gdk, GdkX11, GLib
|
||||
Gtk.init()
|
||||
self.running = True
|
||||
self.window = Gtk.Window(title="Frame Control · Heart rate")
|
||||
self.window.set_default_size(480, 320)
|
||||
self.window.connect("close-request", self.stop)
|
||||
Gtk.Settings.get_default().set_property("gtk-application-prefer-dark-theme", True)
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
|
||||
box.set_valign(Gtk.Align.CENTER)
|
||||
box.set_halign(Gtk.Align.CENTER)
|
||||
box.set_size_request(440, -1)
|
||||
for side in ("top", "bottom", "start", "end"):
|
||||
getattr(box, "set_margin_" + side)(24)
|
||||
self.window.set_child(box)
|
||||
title = Gtk.Label(label="Heart rate")
|
||||
title.add_css_class("title-2")
|
||||
box.append(title)
|
||||
self.reading = Gtk.Label(label="—")
|
||||
self.reading.add_css_class("reading")
|
||||
box.append(self.reading)
|
||||
self.status = Gtk.Label(label="Waiting for strap")
|
||||
box.append(self.status)
|
||||
button = Gtk.Button(label="Stop")
|
||||
button.connect("clicked", self.stop)
|
||||
box.append(button)
|
||||
css = Gtk.CssProvider()
|
||||
css.load_from_data(b".reading { font-size: 144px; font-weight: 700; }")
|
||||
Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
||||
self.window.present()
|
||||
context = GLib.MainContext.default()
|
||||
while context.pending():
|
||||
context.iteration(False)
|
||||
try:
|
||||
xid = GdkX11.X11Surface.get_xid(self.window.get_surface())
|
||||
subprocess.run(["xprop", "-id", str(xid), "-f", "STEAM_GAME", "32c", "-set", "STEAM_GAME", "2000000027"],
|
||||
check=True, stdout=subprocess.DEVNULL)
|
||||
except Exception:
|
||||
self.window.destroy()
|
||||
raise
|
||||
|
||||
def stop(self, *args):
|
||||
self.running = False
|
||||
return True
|
||||
|
||||
def update(self, bpm):
|
||||
self.reading.set_label(str(bpm) if bpm is not None else "—")
|
||||
self.status.set_label("beats per minute" if bpm is not None else "Waiting for strap")
|
||||
|
||||
def close(self):
|
||||
self.window.destroy()
|
||||
|
||||
|
||||
def run_heart(args, osc):
|
||||
import dbus
|
||||
from dbus.mainloop.glib import DBusGMainLoop
|
||||
from gi.repository import GLib
|
||||
DBusGMainLoop(set_as_default=True)
|
||||
session = HeartSession(osc, args.address, args.log)
|
||||
reader, root = None, None
|
||||
failure = []
|
||||
def value(data):
|
||||
if data is None:
|
||||
session.bpm = None
|
||||
failure.append("Strap disconnected; reconnect and start again")
|
||||
return
|
||||
try:
|
||||
session.notification(data)
|
||||
except ValueError:
|
||||
session.bpm = None
|
||||
except OSError:
|
||||
failure.append("OSC or session log write failed")
|
||||
try:
|
||||
reader = BluezHeart(dbus.SystemBus(), dbus.Interface, args.device, value)
|
||||
context = GLib.MainContext.default()
|
||||
deadline = time.monotonic() + 20
|
||||
while not reader.subscribe():
|
||||
if time.monotonic() > deadline:
|
||||
raise TrackingError("Timed out waiting for the strap's GATT services")
|
||||
while context.pending():
|
||||
context.iteration(False)
|
||||
time.sleep(0.1)
|
||||
end = time.monotonic() + args.seconds
|
||||
print("Heart-rate notifications started; readings stay local unless OSC or a log was selected.")
|
||||
if args.panel:
|
||||
root = HeartPanel()
|
||||
running = lambda: root.running if root else True
|
||||
while running() and time.monotonic() < end and not failure:
|
||||
while context.pending():
|
||||
context.iteration(False)
|
||||
if root:
|
||||
root.update(session.current())
|
||||
time.sleep(0.05)
|
||||
if failure:
|
||||
raise TrackingError(failure[0])
|
||||
return 0
|
||||
finally:
|
||||
if root:
|
||||
root.close()
|
||||
try:
|
||||
if reader:
|
||||
reader.close()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def run_pulse(args, osc):
|
||||
"""Experimental: estimate pulse from the IR eye cameras (see pulse.py)."""
|
||||
import pulse
|
||||
if not pulse.ET_BIN.exists():
|
||||
raise TrackingError("SteamVR's eye-tracking tool is not installed on this Frame")
|
||||
print(f"Capturing {args.seconds} s from the eye cameras. Wear the headset and keep still.", flush=True)
|
||||
readings = [pulse.proximity()]
|
||||
try:
|
||||
frames = pulse.Capture(args.seconds).run()
|
||||
readings.append(pulse.proximity())
|
||||
except RuntimeError as error: # capture status only; no image data
|
||||
raise TrackingError(str(error))
|
||||
print(f"Captured {len(frames)} eye frames; images already deleted. Analysing...", flush=True)
|
||||
try:
|
||||
result = pulse.analyse(frames)
|
||||
except ValueError as error:
|
||||
raise TrackingError(str(error))
|
||||
on_face = all(pulse.worn(reading) for reading in readings)
|
||||
if not on_face:
|
||||
# Unworn, the cameras still show a steady periodic artifact that looks
|
||||
# like a pulse (verified on the Frame), so it is never called clear.
|
||||
print("The proximity sensor says the headset is not being worn; no pulse can be read.")
|
||||
clear = on_face and pulse.reliable(result)
|
||||
offset = time.time() - time.monotonic() # the capture's timestamps are CLOCK_MONOTONIC
|
||||
if args.log:
|
||||
fd = os.open(args.log, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, "w") as log:
|
||||
log.write("unix_seconds,bpm\n")
|
||||
for when, bpm in result["series"]:
|
||||
log.write(f"{when + offset:.3f},{bpm:.1f}\n")
|
||||
if clear:
|
||||
osc.send(args.address, [round(result["bpm"])])
|
||||
if args.show:
|
||||
print(f"Estimate: {result['bpm']:.1f} BPM ({'clear' if clear else 'NOT clear'}; "
|
||||
f"patch agreement {result['agreement']:.0%}, signal/noise {result['snr']:.2f}, "
|
||||
f"{result['patches']} of {result['usable']} usable patches)")
|
||||
print("Per-second estimates (15 s windows): "
|
||||
+ " ".join(str(round(bpm)) for _, bpm in result["series"]))
|
||||
else:
|
||||
print("A clear pulse was found." if clear else "No clear pulse was found.")
|
||||
return 0 if clear else 3
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
gaze = commands.add_parser("gaze", help="headless OpenXR; prints counters only without --osc")
|
||||
heart = commands.add_parser("heart", help="standard BLE HRS from an explicitly selected strap")
|
||||
pulse = commands.add_parser("pulse", help="experimental pulse estimate from the IR eye cameras")
|
||||
for command in (gaze, heart, pulse):
|
||||
command.add_argument("--osc", nargs=2, metavar=("IP", "PORT"), help="explicit UDP destination; no default")
|
||||
for command in (gaze, heart):
|
||||
command.add_argument("--seconds", type=int, default=10, help="bounded run, 1..86400 seconds (default: 10)")
|
||||
pulse.add_argument("--seconds", type=int, default=60, help="capture length, 20..300 seconds (default: 60)")
|
||||
heart.add_argument("--device", required=True, help="strap Bluetooth address already discovered by BlueZ")
|
||||
heart.add_argument("--panel", action="store_true", help="show our panel on gamescope DISPLAY=:0")
|
||||
for command in (heart, pulse):
|
||||
command.add_argument("--address", default="/avatar/parameters/HeartRate", help="integer BPM OSC parameter")
|
||||
command.add_argument("--log", help="new private CSV file; disabled by default")
|
||||
pulse.add_argument("--show", action="store_true", help="print the estimate and per-second series")
|
||||
args = parser.parse_args()
|
||||
if not 1 <= args.seconds <= 86400:
|
||||
parser.error("--seconds must be 1..86400")
|
||||
if args.command == "pulse" and not 20 <= args.seconds <= 300:
|
||||
parser.error("pulse --seconds must be 20..300")
|
||||
def interrupted(signum, frame):
|
||||
raise KeyboardInterrupt
|
||||
signal.signal(signal.SIGTERM, interrupted)
|
||||
if hasattr(signal, "SIGHUP"):
|
||||
signal.signal(signal.SIGHUP, interrupted)
|
||||
osc = None
|
||||
try:
|
||||
if args.command in ("heart", "pulse"):
|
||||
osc_message(args.address, [0])
|
||||
if getattr(args, "panel", False):
|
||||
os.environ["DISPLAY"] = ":0"
|
||||
osc = Osc(args.osc)
|
||||
run = {"gaze": run_gaze, "heart": run_heart, "pulse": run_pulse}[args.command]
|
||||
return run(args, osc)
|
||||
except TrackingError as error:
|
||||
print(str(error))
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
except Exception as error:
|
||||
# Never dump notifications, gaze, BLE addresses or exception payloads.
|
||||
print(f"Tracking stopped ({type(error).__name__}). Check the device, runtime and selected output.")
|
||||
return 1
|
||||
finally:
|
||||
if osc:
|
||||
osc.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,4 @@
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
build/
|
||||
DerivedData/
|
||||
@@ -0,0 +1,539 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */; };
|
||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */; };
|
||||
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4E6C28315CA8729FCAAEA /* WebShell.swift */; };
|
||||
41A697B9924018DA48F24A1F /* Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237D9AF04EEA257AB382F60E /* Keys.swift */; };
|
||||
4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */; };
|
||||
476D8858DC2C9E6616B084BC /* PortForwarder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */; };
|
||||
765661DBC0E6798A27CC60DB /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B24E1BCD4F69A24C7DEF02F /* RootView.swift */; };
|
||||
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */; };
|
||||
84423CB45629465420180A64 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */; };
|
||||
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; };
|
||||
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; };
|
||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; };
|
||||
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */; };
|
||||
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A11F3431826A26B247C0695 /* AppModel.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
E1823E86AC0698172B566DB0 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 72E728699F904E68DEC369D3 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 1015B8BE90EB02C2062752A1;
|
||||
remoteInfo = FrameControl;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; };
|
||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; };
|
||||
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
|
||||
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameFinder.swift; sourceTree = "<group>"; };
|
||||
6B5B6718C5EA77FA67F6B14C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FrameControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8A11F3431826A26B247C0695 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||
8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlApp.swift; sourceTree = "<group>"; };
|
||||
9B24E1BCD4F69A24C7DEF02F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||
A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortForwarder.swift; sourceTree = "<group>"; };
|
||||
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallLink.swift; sourceTree = "<group>"; };
|
||||
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShell.swift; sourceTree = "<group>"; };
|
||||
DB544223FC60A59CC3E8EF5F /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
|
||||
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadsetServer.swift; sourceTree = "<group>"; };
|
||||
F3E2F5607DD877272483D64E /* FrameControl.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = FrameControl.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
35C098707058D19A2E23092E /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
1518C8775325C731AD7E2421 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B4F84A5777E9EFEAEB54DB91 /* FrameControl */,
|
||||
75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */,
|
||||
59B34B34E2BE8BCD237BCF26 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5064A5B6FE5B17B18E5FA4B8 /* SSH */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */,
|
||||
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */,
|
||||
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */,
|
||||
237D9AF04EEA257AB382F60E /* Keys.swift */,
|
||||
A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */,
|
||||
);
|
||||
path = SSH;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
59B34B34E2BE8BCD237BCF26 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F3E2F5607DD877272483D64E /* FrameControl.app */,
|
||||
6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
68AF00C8593B71502E1FB72B /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8A11F3431826A26B247C0695 /* AppModel.swift */,
|
||||
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */,
|
||||
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
|
||||
);
|
||||
path = FrameControlTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A939D1267299AE8A48092557 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9B24E1BCD4F69A24C7DEF02F /* RootView.swift */,
|
||||
DB544223FC60A59CC3E8EF5F /* SetupView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4F84A5777E9EFEAEB54DB91 /* FrameControl */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */,
|
||||
6B5B6718C5EA77FA67F6B14C /* Info.plist */,
|
||||
68AF00C8593B71502E1FB72B /* App */,
|
||||
5064A5B6FE5B17B18E5FA4B8 /* SSH */,
|
||||
A939D1267299AE8A48092557 /* Views */,
|
||||
CC25EAB6C385A68D63F7DDF7 /* Web */,
|
||||
);
|
||||
path = FrameControl;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CC25EAB6C385A68D63F7DDF7 /* Web */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */,
|
||||
);
|
||||
path = Web;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
1015B8BE90EB02C2062752A1 /* FrameControl */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = F08406CA3118DCFFD91EEA4D /* Build configuration list for PBXNativeTarget "FrameControl" */;
|
||||
buildPhases = (
|
||||
81ACE79C878CE95DC2C74A8B /* Pack the Frame bundle */,
|
||||
D452F3AE39D323226E2E4D1D /* Sources */,
|
||||
B6E396EEA6D8BB0EE84E01A4 /* Resources */,
|
||||
35C098707058D19A2E23092E /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = FrameControl;
|
||||
packageProductDependencies = (
|
||||
6BA549B6CC0A0CB847126456 /* Citadel */,
|
||||
);
|
||||
productName = FrameControl;
|
||||
productReference = F3E2F5607DD877272483D64E /* FrameControl.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
88565F33E966FD0BAC7AC9C8 /* FrameControlTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 3EE44365AF181B5C85B38B07 /* Build configuration list for PBXNativeTarget "FrameControlTests" */;
|
||||
buildPhases = (
|
||||
F220B2041FE675A075E860BB /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
B88C5AA6F25947DCEE51C178 /* PBXTargetDependency */,
|
||||
);
|
||||
name = FrameControlTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = FrameControlTests;
|
||||
productReference = 6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
72E728699F904E68DEC369D3 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1430;
|
||||
TargetAttributes = {
|
||||
};
|
||||
};
|
||||
buildConfigurationList = D6217CB1638429ED91524BB3 /* Build configuration list for PBXProject "FrameControl" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
);
|
||||
mainGroup = 1518C8775325C731AD7E2421;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
AD49230A09C7F457BC247E4D /* XCRemoteSwiftPackageReference "Citadel" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 59B34B34E2BE8BCD237BCF26 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
1015B8BE90EB02C2062752A1 /* FrameControl */,
|
||||
88565F33E966FD0BAC7AC9C8 /* FrameControlTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
B6E396EEA6D8BB0EE84E01A4 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
84423CB45629465420180A64 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
81ACE79C878CE95DC2C74A8B /* Pack the Frame bundle */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Pack the Frame bundle";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "mkdir -p \"${DERIVED_FILE_DIR}\"\npython3 \"${SRCROOT}/scripts/make_frame_bundle.py\" \"${DERIVED_FILE_DIR}/frame-bundle.tar.gz\" > \"${DERIVED_FILE_DIR}/frame-bundle.version\"\nmkdir -p \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\ncp \"${DERIVED_FILE_DIR}/frame-bundle.tar.gz\" \"${DERIVED_FILE_DIR}/frame-bundle.version\" \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
D452F3AE39D323226E2E4D1D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
|
||||
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
|
||||
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */,
|
||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
|
||||
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */,
|
||||
4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */,
|
||||
41A697B9924018DA48F24A1F /* Keys.swift in Sources */,
|
||||
476D8858DC2C9E6616B084BC /* PortForwarder.swift in Sources */,
|
||||
765661DBC0E6798A27CC60DB /* RootView.swift in Sources */,
|
||||
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */,
|
||||
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F220B2041FE675A075E860BB /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
B88C5AA6F25947DCEE51C178 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 1015B8BE90EB02C2062752A1 /* FrameControl */;
|
||||
targetProxy = E1823E86AC0698172B566DB0 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
0B43879551190738EFF21848 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = FrameControl/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.saphid.framecontrol;
|
||||
PRODUCT_NAME = "Frame Control";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
3228B6B229BF6430C8338B55 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
54BEF779B5906F671E4134CE /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"DEBUG=1",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
57A1F4BD520A2EDA424181E8 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.saphid.FrameControlTests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Frame Control.app/Frame Control";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
6E69BB8A560DC32B8D0E10A6 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.saphid.FrameControlTests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Frame Control.app/Frame Control";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
C7FCE7EB18B4AEF8EFEC8FDE /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = FrameControl/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.saphid.framecontrol;
|
||||
PRODUCT_NAME = "Frame Control";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
3EE44365AF181B5C85B38B07 /* Build configuration list for PBXNativeTarget "FrameControlTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
6E69BB8A560DC32B8D0E10A6 /* Debug */,
|
||||
57A1F4BD520A2EDA424181E8 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
D6217CB1638429ED91524BB3 /* Build configuration list for PBXProject "FrameControl" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
54BEF779B5906F671E4134CE /* Debug */,
|
||||
3228B6B229BF6430C8338B55 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
F08406CA3118DCFFD91EEA4D /* Build configuration list for PBXNativeTarget "FrameControl" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
C7FCE7EB18B4AEF8EFEC8FDE /* Debug */,
|
||||
0B43879551190738EFF21848 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
AD49230A09C7F457BC247E4D /* XCRemoteSwiftPackageReference "Citadel" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/orlandos-nl/Citadel.git";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 0.12.1;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
6BA549B6CC0A0CB847126456 /* Citadel */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = AD49230A09C7F457BC247E4D /* XCRemoteSwiftPackageReference "Citadel" */;
|
||||
productName = Citadel;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 72E728699F904E68DEC369D3 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"originHash" : "06e1233a9a9b220c5f5b14eefc3220aa9e394ac504fece9df28b2a550b7d6017",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "bigint",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/attaswift/BigInt.git",
|
||||
"state" : {
|
||||
"revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe",
|
||||
"version" : "5.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "citadel",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/orlandos-nl/Citadel.git",
|
||||
"state" : {
|
||||
"revision" : "ae8562f895de06ccb86fdb1cbb65fd99c8976e12",
|
||||
"version" : "0.12.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-asn1.git",
|
||||
"state" : {
|
||||
"revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2",
|
||||
"version" : "1.7.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-atomics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34",
|
||||
"version" : "1.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "98ef3c98609a1e31b7e157b5b619579001a789d6",
|
||||
"version" : "1.7.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-crypto",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-crypto.git",
|
||||
"state" : {
|
||||
"revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
|
||||
"version" : "3.15.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "9c6fb14227f55d8f711ce3847dc2f419fb0ecacb",
|
||||
"version" : "1.15.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio.git",
|
||||
"state" : {
|
||||
"revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d",
|
||||
"version" : "2.103.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-ssh",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Wellz26/swift-nio-ssh.git",
|
||||
"state" : {
|
||||
"revision" : "d88989f3d3bb1dfb2a38ce4af598afbf7fc3095c",
|
||||
"version" : "0.3.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-system",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system.git",
|
||||
"state" : {
|
||||
"revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750",
|
||||
"version" : "1.8.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
|
||||
BuildableName = "FrameControl.app"
|
||||
BlueprintName = "FrameControl"
|
||||
ReferencedContainer = "container:FrameControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "NO"
|
||||
buildForProfiling = "NO"
|
||||
buildForArchiving = "NO"
|
||||
buildForAnalyzing = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "88565F33E966FD0BAC7AC9C8"
|
||||
BuildableName = "FrameControlTests.xctest"
|
||||
BlueprintName = "FrameControlTests"
|
||||
ReferencedContainer = "container:FrameControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
|
||||
BuildableName = "FrameControl.app"
|
||||
BlueprintName = "FrameControl"
|
||||
ReferencedContainer = "container:FrameControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "88565F33E966FD0BAC7AC9C8"
|
||||
BuildableName = "FrameControlTests.xctest"
|
||||
BlueprintName = "FrameControlTests"
|
||||
ReferencedContainer = "container:FrameControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
|
||||
BuildableName = "FrameControl.app"
|
||||
BlueprintName = "FrameControl"
|
||||
ReferencedContainer = "container:FrameControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1015B8BE90EB02C2062752A1"
|
||||
BuildableName = "FrameControl.app"
|
||||
BlueprintName = "FrameControl"
|
||||
ReferencedContainer = "container:FrameControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,291 @@
|
||||
import Citadel
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// The app's one piece of state: which headset, and how far along connecting to it is.
|
||||
@MainActor
|
||||
final class AppModel: ObservableObject {
|
||||
enum Phase: Equatable {
|
||||
case setup
|
||||
case connecting(String)
|
||||
case ready(URL)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@Published private(set) var phase: Phase
|
||||
@Published private(set) var settings: FrameSettings?
|
||||
/// Install links that arrived before the page was ready for them.
|
||||
@Published var pendingInstallLinks: [InstallLink] = []
|
||||
|
||||
private var link: FrameLink?
|
||||
private var server: HeadsetServer?
|
||||
private var forwarder: PortForwarder?
|
||||
private var attempt = 0
|
||||
|
||||
private static let settingsKey = "frame.settings"
|
||||
private static let hostKeyKey = "frame.hostKey"
|
||||
|
||||
init() {
|
||||
let saved = UserDefaults.standard.data(forKey: Self.settingsKey).flatMap { try? JSONDecoder().decode(FrameSettings.self, from: $0) }
|
||||
settings = saved
|
||||
phase = saved == nil ? .setup : .connecting("Connecting")
|
||||
}
|
||||
|
||||
var deviceName: String { UIDevice.current.userInterfaceIdiom == .pad ? "iPad" : "iPhone" }
|
||||
private var hostKey: String? { UserDefaults.standard.string(forKey: Self.hostKeyKey) }
|
||||
|
||||
// MARK: pairing
|
||||
|
||||
/// First time: log in with the Developer Mode password, add this phone's key to
|
||||
/// ~/.ssh/authorized_keys, record the Frame's host key, then connect with the key.
|
||||
func pair(host: String, user: String, password: String) async {
|
||||
guard let target = Self.parse(host: host, user: user) else {
|
||||
fail("Enter the headset's address and user name.", retry: false)
|
||||
return
|
||||
}
|
||||
invalidate()
|
||||
let mine = attempt
|
||||
await teardown()
|
||||
guard mine == attempt else { return }
|
||||
phase = .connecting("Signing in to \(target.host)")
|
||||
let pin = PinnedHostKey(expected: nil)
|
||||
do {
|
||||
let link = try await FrameLink.connect(target, auth: .passwordBased(username: target.user, password: password), hostKey: pin)
|
||||
defer { Task { await link.close() } }
|
||||
guard mine == attempt else { return }
|
||||
phase = .connecting("Adding this \(deviceName)'s key")
|
||||
let line = authorizedKeysLine
|
||||
// A file whose last line has no newline would otherwise swallow the key.
|
||||
let file = "~/.ssh/authorized_keys"
|
||||
try await link.check("umask 077; mkdir -p ~/.ssh && touch \(file) && "
|
||||
+ "{ grep -qxF \(shellQuote(line)) \(file) || { "
|
||||
+ "[ -s \(file) ] && [ -n \"$(tail -c 1 \(file))\" ] && printf '\\n' >> \(file); "
|
||||
+ "printf '%s\\n' \(shellQuote(line)) >> \(file); }; }",
|
||||
"Couldn't add the key on the Frame")
|
||||
guard mine == attempt else { return } // cancelled meanwhile: save nothing
|
||||
guard let seen = pin.seen else { throw FrameFailure("The Frame didn't show a host key") }
|
||||
UserDefaults.standard.set(seen, forKey: Self.hostKeyKey)
|
||||
UserDefaults.standard.set(try JSONEncoder().encode(target), forKey: Self.settingsKey)
|
||||
settings = target
|
||||
} catch {
|
||||
guard mine == attempt else { return }
|
||||
let failure = error as? FrameFailure
|
||||
fail(failure?.message ?? FrameLink.describe(error, host: target.host), retry: false,
|
||||
needsPairing: failure?.needsPairing ?? false)
|
||||
return
|
||||
}
|
||||
await connect()
|
||||
}
|
||||
|
||||
/// For someone who added this phone's key to the Frame themselves: no password.
|
||||
/// The Frame's host key is recorded on this first connection.
|
||||
func useKey(host: String, user: String) async {
|
||||
guard let target = Self.parse(host: host, user: user) else {
|
||||
fail("Enter the headset's address and user name.", retry: false)
|
||||
return
|
||||
}
|
||||
UserDefaults.standard.removeObject(forKey: Self.hostKeyKey)
|
||||
UserDefaults.standard.set(try? JSONEncoder().encode(target), forKey: Self.settingsKey)
|
||||
settings = target
|
||||
await connect()
|
||||
}
|
||||
|
||||
/// "host", "host:port" or "[v6]:port", plus a user name.
|
||||
nonisolated static func parse(host: String, user: String) -> FrameSettings? {
|
||||
var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces))
|
||||
if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") {
|
||||
let rest = target.host[target.host.index(after: close)...]
|
||||
if rest.hasPrefix(":"), let port = Int(rest.dropFirst()) { target.port = port }
|
||||
target.host = String(target.host[target.host.index(after: target.host.startIndex)..<close])
|
||||
} else if target.host.filter({ $0 == ":" }).count == 1, let colon = target.host.lastIndex(of: ":"),
|
||||
let port = Int(target.host[target.host.index(after: colon)...]) {
|
||||
target.port = port
|
||||
target.host = String(target.host[..<colon])
|
||||
}
|
||||
guard !target.host.isEmpty, !target.user.isEmpty, (1...65535).contains(target.port) else { return nil }
|
||||
return target
|
||||
}
|
||||
|
||||
/// This phone's line for ~/.ssh/authorized_keys on the Frame.
|
||||
var authorizedKeysLine: String {
|
||||
DeviceKey.authorizedKeysLine(DeviceKey.loadOrCreate(), comment: "frame-control@\(deviceName)")
|
||||
}
|
||||
|
||||
/// Forget the headset: back to the pairing screen. The Frame keeps the key line;
|
||||
/// remove it from ~/.ssh/authorized_keys there to revoke this phone.
|
||||
func forget() async {
|
||||
invalidate()
|
||||
await teardown()
|
||||
UserDefaults.standard.removeObject(forKey: Self.settingsKey)
|
||||
UserDefaults.standard.removeObject(forKey: Self.hostKeyKey)
|
||||
settings = nil
|
||||
phase = .setup
|
||||
}
|
||||
|
||||
func showSetup() {
|
||||
invalidate()
|
||||
Task { await teardown() }
|
||||
phase = .setup
|
||||
}
|
||||
|
||||
/// Whether the failure screen is retrying on its own.
|
||||
@Published private(set) var retrying = false
|
||||
|
||||
// MARK: connecting
|
||||
|
||||
/// Every connection attempt has a number; anything that finishes after a newer
|
||||
/// attempt started (or the user went back to setup) closes what it made and stops.
|
||||
private func invalidate() {
|
||||
attempt += 1
|
||||
retrying = false
|
||||
}
|
||||
|
||||
/// quiet: a background retry, which leaves the failure screen up until it works.
|
||||
func connect(quiet: Bool = false) async {
|
||||
guard let settings else {
|
||||
phase = .setup
|
||||
return
|
||||
}
|
||||
invalidate()
|
||||
let mine = attempt
|
||||
await teardown()
|
||||
func current() -> Bool { mine == attempt }
|
||||
func step(_ s: String) { if current() && !quiet { phase = .connecting(s) } }
|
||||
step("Connecting to \(settings.host)")
|
||||
var link: FrameLink?
|
||||
var forwarder: PortForwarder?
|
||||
do {
|
||||
let bundle = try HeadsetServer.Bundle.fromApp()
|
||||
let auth = SSHAuthenticationMethod.ed25519(username: settings.user, privateKey: DeviceKey.loadOrCreate())
|
||||
let pin = PinnedHostKey(expected: hostKey)
|
||||
let l = try await FrameLink.connect(settings, auth: auth, hostKey: pin)
|
||||
link = l
|
||||
guard current() else { throw CancellationError() }
|
||||
if hostKey == nil, let seen = pin.seen { UserDefaults.standard.set(seen, forKey: Self.hostKeyKey) }
|
||||
let dir = try await HeadsetServer.deploy(bundle, over: l) { s in Task { @MainActor in step(s) } }
|
||||
guard current() else { throw CancellationError() }
|
||||
step("Starting Frame Control on the headset")
|
||||
let key = Self.randomKey()
|
||||
let server = try await HeadsetServer.start(in: dir, over: l, key: key, device: deviceName)
|
||||
guard current() else { throw CancellationError() }
|
||||
let f = try await PortForwarder.start(over: l, to: server.port)
|
||||
forwarder = f
|
||||
guard current() else { throw CancellationError() }
|
||||
if let tail = server.exited { // stopped while the tunnel was opening
|
||||
throw FrameFailure("Frame Control on the headset stopped. \(tail.suffix(200))")
|
||||
}
|
||||
// Only now does this attempt's connection become the app's.
|
||||
self.link = l
|
||||
self.server = server
|
||||
self.forwarder = f
|
||||
readySince = Date()
|
||||
var page = "http://127.0.0.1:\(f.localPort)/?key=\(key)"
|
||||
#if DEBUG
|
||||
// Test hooks for the Simulator: open on a given tab, and leave the URL where
|
||||
// a test can drive the same tunnel (`simctl get_app_container … data`).
|
||||
if let tab = ProcessInfo.processInfo.environment["FRAME_TEST_PAGE"] { page += "#\(tab)" }
|
||||
if let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
|
||||
try? page.write(to: dir.appendingPathComponent("frame-test-url.txt"), atomically: true, encoding: .utf8)
|
||||
}
|
||||
#endif
|
||||
phase = .ready(URL(string: page)!)
|
||||
// Runs at once if it stopped in the moment since the check above.
|
||||
server.whenExited { [weak self] tail in
|
||||
Task { @MainActor in self?.lost(mine, "Frame Control on the headset stopped. \(tail.suffix(200))") }
|
||||
}
|
||||
watchHealth(mine)
|
||||
} catch {
|
||||
forwarder?.stop()
|
||||
if let link { await link.close() } // ends its server too
|
||||
guard current(), !(error is CancellationError) else { return }
|
||||
let failure = error as? FrameFailure
|
||||
fail(failure?.message ?? FrameLink.describe(error, host: settings.host), retry: !(failure?.needsPairing ?? false),
|
||||
needsPairing: failure?.needsPairing ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the last failure needs the user to pair again rather than wait.
|
||||
@Published private(set) var needsPairing = false
|
||||
|
||||
private func fail(_ message: String, retry: Bool, needsPairing: Bool = false) {
|
||||
self.needsPairing = needsPairing
|
||||
phase = .failed(message)
|
||||
retrying = retry && settings != nil
|
||||
guard retrying else { return }
|
||||
// Keep trying quietly while the app is open: the Frame may just be asleep.
|
||||
// A new task each time, so retrying for hours doesn't nest awaits.
|
||||
let mine = attempt
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 10_000_000_000)
|
||||
guard let self, mine == self.attempt, case .failed = self.phase,
|
||||
UIApplication.shared.applicationState == .active else { return }
|
||||
await self.connect(quiet: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// While connected, check every 20 s that the SSH session still answers: a
|
||||
/// network change can leave it looking open while nothing gets through.
|
||||
private func watchHealth(_ mine: Int) {
|
||||
Task { [weak self] in
|
||||
while true {
|
||||
try? await Task.sleep(nanoseconds: 20_000_000_000)
|
||||
guard let self, mine == self.attempt, case .ready = self.phase else { return }
|
||||
if UIApplication.shared.applicationState != .active { continue }
|
||||
if await !(self.link?.answers() ?? false) {
|
||||
guard mine == self.attempt else { return }
|
||||
await self.connect(quiet: true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the app comes back to the foreground: iOS may have dropped the
|
||||
/// connection, or left it looking open, while it was in the background.
|
||||
func resume() {
|
||||
switch phase {
|
||||
case .ready:
|
||||
let mine = attempt
|
||||
Task {
|
||||
let ok = await link?.answers() ?? false
|
||||
if (!ok || server?.exited != nil), mine == attempt { await connect() }
|
||||
}
|
||||
case .failed:
|
||||
// A changed identity or a refused login needs the user, not another try.
|
||||
if settings != nil, !needsPairing { Task { await connect() } }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private var readySince = Date.distantPast
|
||||
|
||||
private func lost(_ which: Int, _ why: String) {
|
||||
guard which == attempt, case .ready = phase else { return }
|
||||
// Restart it once; if it dies again straight away, say so instead of looping.
|
||||
if Date().timeIntervalSince(readySince) < 20 {
|
||||
invalidate()
|
||||
Task { await teardown() }
|
||||
fail(why, retry: false)
|
||||
} else {
|
||||
Task { await connect() }
|
||||
}
|
||||
}
|
||||
|
||||
private func teardown() async {
|
||||
forwarder?.stop()
|
||||
forwarder = nil
|
||||
server = nil
|
||||
if let link {
|
||||
self.link = nil
|
||||
await link.close() // ends the server too: its stdin closes
|
||||
}
|
||||
}
|
||||
|
||||
private static func randomKey() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 24)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
return bytes.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct FrameControlApp: App {
|
||||
@StateObject private var model = AppModel()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView(model: model)
|
||||
.task {
|
||||
#if DEBUG
|
||||
// Simulator testing without the pairing screen: print this device's key,
|
||||
// and connect to FRAME_TEST_HOST with it (`simctl launch` passes
|
||||
// SIMCTL_CHILD_FRAME_TEST_HOST through as FRAME_TEST_HOST).
|
||||
print("FRAME_CONTROL_KEY: \(model.authorizedKeysLine)")
|
||||
// FRAME_TEST_LANDSCAPE=1 turns the app on its side, to check the safe areas there.
|
||||
if ProcessInfo.processInfo.environment["FRAME_TEST_LANDSCAPE"] != nil,
|
||||
let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscapeRight))
|
||||
}
|
||||
// FRAME_TEST_PAIR="host|user|password" runs the real password pairing.
|
||||
if model.settings == nil, let pair = ProcessInfo.processInfo.environment["FRAME_TEST_PAIR"] {
|
||||
let f = pair.components(separatedBy: "|")
|
||||
if f.count == 3 { await model.pair(host: f[0], user: f[1], password: f[2]); return }
|
||||
}
|
||||
if model.settings == nil, let host = ProcessInfo.processInfo.environment["FRAME_TEST_HOST"] {
|
||||
await model.useKey(host: host, user: "steamos")
|
||||
return
|
||||
}
|
||||
#endif
|
||||
if model.settings != nil { await model.connect() }
|
||||
}
|
||||
.onOpenURL { url in
|
||||
// frame-control://install?… from a website (docs/web-install.md).
|
||||
guard let link = InstallLink(url.absoluteString), model.pendingInstallLinks.count < 5 else { return }
|
||||
model.pendingInstallLinks.append(link)
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active { model.resume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
/// frame-control://install?manifest=URL or ?url=URL (docs/web-install.md), the same
|
||||
/// first filter as app/install-link.js. The server on the Frame applies the full
|
||||
/// rules (HTTPS, no private addresses, redirects) before fetching anything.
|
||||
struct InstallLink: Equatable {
|
||||
enum Kind: String { case manifest, url }
|
||||
let kind: Kind
|
||||
let target: String
|
||||
|
||||
static let scheme = "frame-control"
|
||||
private static let maxLink = 4096
|
||||
private static let maxURL = 2048
|
||||
|
||||
init?(_ raw: String) {
|
||||
guard raw.count <= Self.maxLink, raw.lowercased().hasPrefix("\(Self.scheme):"),
|
||||
let link = URLComponents(string: raw), link.scheme?.lowercased() == Self.scheme,
|
||||
link.host?.lowercased() == "install", ["", "/"].contains(link.path) else { return nil }
|
||||
let items = link.queryItems ?? []
|
||||
guard items.count == 1, let item = items.first, let kind = Kind(rawValue: item.name),
|
||||
let target = item.value, !target.isEmpty, target.count <= Self.maxURL,
|
||||
let url = URLComponents(string: target), ["https", "http"].contains(url.scheme?.lowercased() ?? ""),
|
||||
url.host?.isEmpty == false, url.user == nil, url.password == nil else { return nil }
|
||||
self.kind = kind
|
||||
self.target = target
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0xFF", "green" : "0x9F", "red" : "0x1A" } }, "idiom" : "universal" } ],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images" : [ { "filename" : "icon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 190 KiB |
@@ -0,0 +1 @@
|
||||
{ "images" : [ { "filename" : "icon.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } }
|
||||
|
After Width: | Height: | Size: 190 KiB |
@@ -0,0 +1 @@
|
||||
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Frame Control</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.saphid.framecontrol.install</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>frame-control</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>ssh</string>
|
||||
<string>sftp</string>
|
||||
<string>steamlink</string>
|
||||
<string>rdp</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_steamos-devkit._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Frame Control finds your Steam Frame on your network and connects to it.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Frame Control saves headset captures and screenshots to your photo library when you ask it to.</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string></string>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Dark</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// Finds the Frame on the local network so nobody has to type its address.
|
||||
/// A Frame in Developer Mode advertises Valve's devkit service over Bonjour
|
||||
/// (`_steamos-devkit._tcp`); failing that, `fallback` (the saved address, or
|
||||
/// frame.local) is checked by opening its SSH port. Both repeat until stopped.
|
||||
@MainActor
|
||||
final class FrameFinder: ObservableObject {
|
||||
struct Found: Equatable {
|
||||
let host: String // what to connect to
|
||||
let name: String // what to call it
|
||||
}
|
||||
|
||||
@Published private(set) var found: Found?
|
||||
/// When the search began, to tell "still looking" from "can't find it".
|
||||
@Published private(set) var since = Date()
|
||||
|
||||
private var browser: NWBrowser?
|
||||
private var probeTask: Task<Void, Never>?
|
||||
private var fallback = "frame.local"
|
||||
|
||||
func start(fallback: String?) {
|
||||
stop()
|
||||
self.fallback = (fallback?.isEmpty == false ? fallback : nil) ?? "frame.local"
|
||||
found = nil
|
||||
since = Date()
|
||||
browse()
|
||||
probeTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
let host = self.fallback
|
||||
if self.found == nil, await Self.sshAnswers(host: host) {
|
||||
self.found = Found(host: host, name: host)
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
probeTask?.cancel()
|
||||
probeTask = nil
|
||||
}
|
||||
|
||||
private func browse() {
|
||||
let browser = NWBrowser(for: .bonjour(type: "_steamos-devkit._tcp", domain: nil), using: .tcp)
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
for result in results {
|
||||
guard case let .service(name, _, _, _) = result.endpoint else { continue }
|
||||
Self.resolve(result.endpoint) { host in
|
||||
Task { @MainActor in
|
||||
guard let self, let host else { return }
|
||||
// A found device is used over the fallback probe.
|
||||
self.found = Found(host: host, name: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
browser.start(queue: .main)
|
||||
self.browser = browser
|
||||
}
|
||||
|
||||
/// The device's IP address: connect to the service and read where it went.
|
||||
nonisolated private static func resolve(_ endpoint: NWEndpoint, done: @escaping @Sendable (String?) -> Void) {
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let once = Once()
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
var host: String?
|
||||
if case let .hostPort(h, _)? = connection.currentPath?.remoteEndpoint {
|
||||
host = "\(h)".components(separatedBy: "%").first // drop an IPv6 interface suffix
|
||||
}
|
||||
connection.cancel()
|
||||
if once.claim() { done(host) }
|
||||
case .failed, .cancelled:
|
||||
if once.claim() { done(nil) }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: .global())
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 5) {
|
||||
connection.cancel()
|
||||
if once.claim() { done(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether something answers on the SSH port of "host" or "host:port" within a few seconds.
|
||||
nonisolated static func sshAnswers(host address: String) async -> Bool {
|
||||
var host = address, port: UInt16 = 22
|
||||
if let target = AppModel.parse(host: address, user: "steamos") {
|
||||
host = target.host
|
||||
port = UInt16(target.port)
|
||||
}
|
||||
return await sshAnswers(host: host, port: port)
|
||||
}
|
||||
|
||||
nonisolated static func sshAnswers(host: String, port: UInt16) async -> Bool {
|
||||
await withCheckedContinuation { (c: CheckedContinuation<Bool, Never>) in
|
||||
let connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port) ?? 22, using: .tcp)
|
||||
let once = Once()
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
connection.cancel()
|
||||
if once.claim() { c.resume(returning: true) }
|
||||
case .failed, .waiting:
|
||||
connection.cancel()
|
||||
if once.claim() { c.resume(returning: false) }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: .global())
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 3) {
|
||||
connection.cancel()
|
||||
if once.claim() { c.resume(returning: false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import Citadel
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOSSH
|
||||
|
||||
/// Where the Frame is and who to log in as.
|
||||
struct FrameSettings: Codable, Equatable {
|
||||
var host: String
|
||||
var port: Int = 22
|
||||
var user: String = "steamos"
|
||||
}
|
||||
|
||||
struct FrameFailure: LocalizedError {
|
||||
let message: String
|
||||
/// Retrying can't help: the Frame's identity changed, or it refused this phone's login.
|
||||
var needsPairing = false
|
||||
init(_ message: String, needsPairing: Bool = false) {
|
||||
self.message = message
|
||||
self.needsPairing = needsPairing
|
||||
}
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
/// Trust on first use: pairing records the Frame's host key; later connections
|
||||
/// accept that key and nothing else, as ssh's known_hosts does.
|
||||
final class PinnedHostKey: NIOSSHClientServerAuthenticationDelegate, @unchecked Sendable {
|
||||
struct Changed: Error {}
|
||||
let expected: String?
|
||||
private let lock = NSLock()
|
||||
private var _seen: String?
|
||||
var seen: String? { lock.withLock { _seen } }
|
||||
|
||||
init(expected: String?) { self.expected = expected }
|
||||
|
||||
func validateHostKey(hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise<Void>) {
|
||||
let key = String(openSSHPublicKey: hostKey)
|
||||
lock.withLock { _seen = key }
|
||||
if expected == nil || expected == key {
|
||||
validationCompletePromise.succeed(())
|
||||
} else {
|
||||
validationCompletePromise.fail(Changed())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One SSH connection to the Frame, and the few things the app does over it.
|
||||
final class FrameLink: @unchecked Sendable {
|
||||
let client: SSHClient
|
||||
|
||||
private init(client: SSHClient) { self.client = client }
|
||||
|
||||
static func connect(_ settings: FrameSettings, auth: SSHAuthenticationMethod, hostKey: PinnedHostKey) async throws -> FrameLink {
|
||||
do {
|
||||
let client = try await SSHClient.connect(
|
||||
host: settings.host, port: settings.port, authenticationMethod: auth,
|
||||
hostKeyValidator: .custom(hostKey), reconnect: .never, connectTimeout: .seconds(8))
|
||||
return FrameLink(client: client)
|
||||
} catch {
|
||||
let text = String(describing: error)
|
||||
throw FrameFailure(describe(error, host: settings.host),
|
||||
needsPairing: error is PinnedHostKey.Changed || text.contains("allAuthenticationOptionsFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The plain-language reason a connection failed, like the desktop server's messages.
|
||||
static func describe(_ error: Error, host: String) -> String {
|
||||
if error is PinnedHostKey.Changed {
|
||||
return "The Frame's SSH identity changed (after a reinstall, or a different device at \(host)). Pair again."
|
||||
}
|
||||
let text = String(describing: error)
|
||||
if text.contains("allAuthenticationOptionsFailed") || text.contains("authentication") {
|
||||
return "The Frame didn't accept the login. Pair again, and check the Developer Mode password."
|
||||
}
|
||||
if ["timeout", "Timeout", "timed out", "Host is down", "No route to host", "Network is unreachable",
|
||||
"errno: 64", "errno: 65", "errno: 51", "errno: 60"].contains(where: text.contains) {
|
||||
return "The Frame isn't answering at \(host). It may be asleep, switched off, or on another network."
|
||||
}
|
||||
if text.contains("refused") || text.contains("ECONNREFUSED") {
|
||||
return "The Frame refused the connection at \(host). Check Developer Mode is still on."
|
||||
}
|
||||
if text.contains("NXDOMAIN") || text.contains("resolve") || text.contains("unknownHost") || text.contains("NoAddress") {
|
||||
return "Can't find \(host) on the network. Check the address, and that the Frame is on the same network."
|
||||
}
|
||||
return "Couldn't connect to \(host): \(text)"
|
||||
}
|
||||
|
||||
var isConnected: Bool { client.isConnected }
|
||||
|
||||
/// Whether the Frame answers a trivial command within a few seconds. The probe
|
||||
/// runs unstructured: a dead link can keep it waiting well past the deadline,
|
||||
/// and the answer mustn't wait for it.
|
||||
func answers(within seconds: Double = 6) async -> Bool {
|
||||
guard client.isConnected else { return false }
|
||||
let once = Once()
|
||||
return await withCheckedContinuation { (c: CheckedContinuation<Bool, Never>) in
|
||||
Task { let ok = (try? await self.run("true").status) == 0; if once.claim() { c.resume(returning: ok) } }
|
||||
Task { try? await Task.sleep(nanoseconds: UInt64(seconds * 1e9)); if once.claim() { c.resume(returning: false) } }
|
||||
}
|
||||
}
|
||||
|
||||
func close() async {
|
||||
try? await client.close()
|
||||
}
|
||||
|
||||
/// Runs a shell command; returns its combined output and exit status.
|
||||
func run(_ command: String) async throws -> (output: String, status: Int) {
|
||||
// stderr joins stdout (Citadel treats any stderr as a failure), and the
|
||||
// status comes back as the last line so a non-zero exit isn't an exception.
|
||||
let buffer = try await client.executeCommand("{ \(command)\n} 2>&1; echo \"@@rc=$?\"")
|
||||
var text = String(buffer: buffer)
|
||||
var status = 0
|
||||
if let range = text.range(of: "@@rc=", options: .backwards) {
|
||||
status = Int(text[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines)) ?? -1
|
||||
text = String(text[..<range.lowerBound])
|
||||
}
|
||||
return (text.trimmingCharacters(in: .whitespacesAndNewlines), status)
|
||||
}
|
||||
|
||||
/// Runs a command that must succeed; its output, or a FrameFailure with it.
|
||||
@discardableResult
|
||||
func check(_ command: String, _ what: String) async throws -> String {
|
||||
let r = try await run(command)
|
||||
guard r.status == 0 else { throw FrameFailure("\(what): \(r.output.isEmpty ? "exit \(r.status)" : r.output)") }
|
||||
return r.output
|
||||
}
|
||||
|
||||
/// Writes data to a path relative to the home directory.
|
||||
func upload(_ data: Data, to path: String) async throws {
|
||||
let sftp = try await client.openSFTP()
|
||||
do {
|
||||
try await sftp.withFile(filePath: path, flags: [.write, .create, .truncate]) { file in
|
||||
try await file.write(ByteBuffer(bytes: data))
|
||||
}
|
||||
try? await sftp.close()
|
||||
} catch {
|
||||
try? await sftp.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the first caller only.
|
||||
final class Once: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var done = false
|
||||
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
|
||||
}
|
||||
|
||||
func shellQuote(_ s: String) -> String {
|
||||
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import Citadel
|
||||
import Foundation
|
||||
import NIOCore
|
||||
|
||||
/// Frame Control's server, running on the Frame itself. The app copies the bundle
|
||||
/// (ios/scripts/make_frame_bundle.py) to ~/.cache/frame-control/<version> once per
|
||||
/// version, then starts ui/server.py there over SSH. It listens only on the Frame's
|
||||
/// 127.0.0.1, and it exits when this SSH session ends (--exit-on-eof).
|
||||
final class HeadsetServer: @unchecked Sendable {
|
||||
let port: Int
|
||||
private let lock = NSLock()
|
||||
private var _exited: String?
|
||||
private var onExit: (@Sendable (String) -> Void)?
|
||||
/// Set once the server stops, with its last output.
|
||||
var exited: String? { lock.withLock { _exited } }
|
||||
|
||||
private init(port: Int) { self.port = port }
|
||||
|
||||
/// Calls back once when the server stops, at once if it already has.
|
||||
func whenExited(_ callback: @escaping @Sendable (String) -> Void) {
|
||||
let already: String? = lock.withLock {
|
||||
if _exited == nil { onExit = callback }
|
||||
return _exited
|
||||
}
|
||||
if let already { callback(already) }
|
||||
}
|
||||
|
||||
fileprivate func markExited(_ tail: String) {
|
||||
let callback: (@Sendable (String) -> Void)? = lock.withLock {
|
||||
guard _exited == nil else { return nil }
|
||||
_exited = tail
|
||||
defer { onExit = nil }
|
||||
return onExit
|
||||
}
|
||||
callback?(tail)
|
||||
}
|
||||
|
||||
static let cacheDir = ".cache/frame-control"
|
||||
|
||||
struct Bundle {
|
||||
let data: Data
|
||||
let version: String
|
||||
|
||||
static func fromApp() throws -> Bundle {
|
||||
guard let url = Foundation.Bundle.main.url(forResource: "frame-bundle", withExtension: "tar.gz"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let vurl = Foundation.Bundle.main.url(forResource: "frame-bundle", withExtension: "version"),
|
||||
let version = try? String(contentsOf: vurl, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
version.range(of: "^[0-9a-f]{16}$", options: .regularExpression) != nil else {
|
||||
throw FrameFailure("This build of the app is missing its Frame bundle")
|
||||
}
|
||||
return Bundle(data: data, version: version)
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies the bundle over unless this version is already there; removes older versions.
|
||||
static func deploy(_ bundle: Bundle, over link: FrameLink, progress: @escaping @Sendable (String) -> Void) async throws -> String {
|
||||
let dir = "\(cacheDir)/\(bundle.version)"
|
||||
let py = try await link.run("command -v python3 >/dev/null && python3 -c 'import sys; print(sys.version_info >= (3, 8))'")
|
||||
guard py.status == 0, py.output.hasSuffix("True") else {
|
||||
throw FrameFailure("The Frame has no Python 3.8 or later, which Frame Control needs there.")
|
||||
}
|
||||
if try await link.run("test -f \(dir)/ui/server.py").status != 0 {
|
||||
progress("Copying Frame Control to the headset")
|
||||
try await link.check("mkdir -p \(cacheDir)", "Couldn't make \(cacheDir)")
|
||||
let archive = "\(dir).tar.gz"
|
||||
try await link.upload(bundle.data, to: archive)
|
||||
progress("Unpacking")
|
||||
try await link.check("rm -rf \(dir).tmp && mkdir \(dir).tmp && tar xzf \(archive) -C \(dir).tmp && rm -f \(archive) "
|
||||
+ "&& rm -rf \(dir) && mv \(dir).tmp \(dir)", "Couldn't unpack Frame Control on the headset")
|
||||
}
|
||||
// Another phone or iPad may be running a different version right now: a version
|
||||
// goes only when no server runs from it and it hasn't been used for two weeks
|
||||
// (this one is marked as used). Servers run by absolute path, so pgrep sees it.
|
||||
_ = try? await link.run("touch \(dir) && cd \(cacheDir) && for d in */; do d=${d%/}; "
|
||||
+ "[ \"$d\" = \(bundle.version) ] && continue; "
|
||||
+ "[ -n \"$(find \"$d\" -maxdepth 0 -mtime +14)\" ] || continue; "
|
||||
+ "pgrep -f \"$PWD/$d/\" >/dev/null && continue; rm -rf -- \"$d\"; done")
|
||||
return dir
|
||||
}
|
||||
|
||||
/// Starts the server in dir and waits for it to say which port it took.
|
||||
static func start(in dir: String, over link: FrameLink, key: String, device: String) async throws -> HeadsetServer {
|
||||
let command = "cd \(dir) && FRAME_LOCAL=1 FRAME_UI_KEY=\(key) FRAME_DEVICE=\(shellQuote(device)) "
|
||||
+ "exec python3 -I -u -B \"$PWD/ui/server.py\" --port 0 --exit-on-eof 2>&1"
|
||||
let stream = try await link.client.executeCommandStream(command)
|
||||
let box = PortWaiter()
|
||||
let reader = Task { () -> Void in
|
||||
var text = ""
|
||||
do {
|
||||
for try await chunk in stream {
|
||||
switch chunk {
|
||||
case .stdout(let b), .stderr(let b): text += String(buffer: b)
|
||||
}
|
||||
if text.count > 20_000 { text = String(text.suffix(10_000)) }
|
||||
if let port = Self.port(in: text) { box.found(port) }
|
||||
}
|
||||
} catch {
|
||||
text += "\n\(error)"
|
||||
}
|
||||
box.ended(text)
|
||||
}
|
||||
let server: HeadsetServer
|
||||
do {
|
||||
server = HeadsetServer(port: try await box.wait(seconds: 30))
|
||||
} catch {
|
||||
reader.cancel()
|
||||
throw error
|
||||
}
|
||||
box.whenEnded { [weak server] tail in server?.markExited(tail) }
|
||||
return server
|
||||
}
|
||||
|
||||
/// The port from the server's first line. Output arrives in chunks, so the digits
|
||||
/// only count once something follows them (the line goes on after the port).
|
||||
static func port(in text: String) -> Int? {
|
||||
guard let r = text.range(of: #"Frame Control on http://127\.0\.0\.1:[0-9]+\s"#, options: .regularExpression),
|
||||
let port = Int(text[r].dropLast().split(separator: ":").last ?? ""), (1...65535).contains(port) else { return nil }
|
||||
return port
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands the port from the output reader to start(), or the output if the server died first.
|
||||
private final class PortWaiter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuation: CheckedContinuation<Int, Error>?
|
||||
private var result: Result<Int, Error>?
|
||||
private var endedTail: String?
|
||||
private var onEnd: (@Sendable (String) -> Void)?
|
||||
|
||||
/// Calls back when the output ends, at once if it already has.
|
||||
func whenEnded(_ callback: @escaping @Sendable (String) -> Void) {
|
||||
let already: String? = lock.withLock {
|
||||
if endedTail == nil { onEnd = callback }
|
||||
return endedTail
|
||||
}
|
||||
if let already { callback(already) }
|
||||
}
|
||||
|
||||
func found(_ port: Int) { finish(.success(port)) }
|
||||
|
||||
func ended(_ text: String) {
|
||||
let tail = String(text.suffix(600)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let callback: (@Sendable (String) -> Void)? = lock.withLock {
|
||||
endedTail = tail
|
||||
defer { onEnd = nil }
|
||||
return onEnd
|
||||
}
|
||||
finish(.failure(FrameFailure("Frame Control's server on the headset stopped: \(tail.isEmpty ? "no output" : tail)")))
|
||||
callback?(tail)
|
||||
}
|
||||
|
||||
private func finish(_ r: Result<Int, Error>) {
|
||||
let c: CheckedContinuation<Int, Error>? = lock.withLock {
|
||||
guard result == nil else { return nil }
|
||||
result = r
|
||||
defer { continuation = nil }
|
||||
return continuation
|
||||
}
|
||||
c?.resume(with: r)
|
||||
}
|
||||
|
||||
func wait(seconds: Double) async throws -> Int {
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(seconds * 1e9))
|
||||
self?.finish(.failure(FrameFailure("Frame Control's server on the headset didn't start within \(Int(seconds)) s")))
|
||||
}
|
||||
return try await withCheckedThrowingContinuation { c in
|
||||
let done: Result<Int, Error>? = lock.withLock {
|
||||
if let result { return result }
|
||||
continuation = c
|
||||
return nil
|
||||
}
|
||||
if let done { c.resume(with: done) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import NIOSSH
|
||||
import Security
|
||||
|
||||
/// Small wrapper over the Keychain for this app's secrets.
|
||||
enum Keychain {
|
||||
private static let service = "com.saphid.framecontrol"
|
||||
|
||||
private static func query(_ account: String) -> [String: Any] {
|
||||
[kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account]
|
||||
}
|
||||
|
||||
static func data(_ account: String) -> Data? {
|
||||
var q = query(account)
|
||||
q[kSecReturnData as String] = true
|
||||
q[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var out: AnyObject?
|
||||
return SecItemCopyMatching(q as CFDictionary, &out) == errSecSuccess ? out as? Data : nil
|
||||
}
|
||||
|
||||
static func set(_ data: Data, _ account: String) {
|
||||
SecItemDelete(query(account) as CFDictionary)
|
||||
var q = query(account)
|
||||
q[kSecValueData as String] = data
|
||||
// Only on this device and not in backups: the key is this phone's identity.
|
||||
q[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
SecItemAdd(q as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func delete(_ account: String) {
|
||||
SecItemDelete(query(account) as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
/// This phone's SSH key: ed25519, made once, kept in the Keychain.
|
||||
enum DeviceKey {
|
||||
private static let account = "ssh-ed25519"
|
||||
|
||||
static func loadOrCreate() -> Curve25519.Signing.PrivateKey {
|
||||
if let raw = Keychain.data(account), let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: raw) {
|
||||
return key
|
||||
}
|
||||
let key = Curve25519.Signing.PrivateKey()
|
||||
Keychain.set(key.rawRepresentation, account)
|
||||
return key
|
||||
}
|
||||
|
||||
/// The line for ~/.ssh/authorized_keys, e.g. "ssh-ed25519 AAAA… frame-control@iPhone".
|
||||
static func authorizedKeysLine(_ key: Curve25519.Signing.PrivateKey, comment: String) -> String {
|
||||
String(openSSHPublicKey: NIOSSHPrivateKey(ed25519Key: key).publicKey) + " " + comment
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import Citadel
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
import NIOSSH
|
||||
|
||||
/// Listens on this phone's 127.0.0.1 and carries each connection to a port on the
|
||||
/// Frame's 127.0.0.1 through the SSH session (ssh -L). The web view loads the
|
||||
/// server from here; every API request still needs the session's key.
|
||||
final class PortForwarder: @unchecked Sendable {
|
||||
private let channel: Channel
|
||||
let localPort: Int
|
||||
|
||||
private init(channel: Channel, localPort: Int) {
|
||||
self.channel = channel
|
||||
self.localPort = localPort
|
||||
}
|
||||
|
||||
static func start(over link: FrameLink, to remotePort: Int) async throws -> PortForwarder {
|
||||
let client = link.client
|
||||
// The listener shares the SSH connection's event loop, so the glue between
|
||||
// each pair of channels never crosses threads.
|
||||
let bootstrap = ServerBootstrap(group: client.eventLoop)
|
||||
.serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
|
||||
.childChannelOption(ChannelOptions.allowRemoteHalfClosure, value: true)
|
||||
// Nothing is read from the web view until the SSH side is ready for it.
|
||||
.childChannelOption(ChannelOptions.autoRead, value: false)
|
||||
.childChannelInitializer { inbound in
|
||||
inbound.eventLoop.makeFutureWithTask {
|
||||
let (local, remote) = GlueHandler.matchedPair()
|
||||
try await inbound.pipeline.addHandler(local).get()
|
||||
let origin = try inbound.remoteAddress ?? SocketAddress(ipAddress: "127.0.0.1", port: 0)
|
||||
_ = try await client.createDirectTCPIPChannel(
|
||||
using: SSHChannelType.DirectTCPIP(targetHost: "127.0.0.1", targetPort: remotePort, originatorAddress: origin)
|
||||
) { channel in channel.pipeline.addHandler(remote) }
|
||||
try await inbound.setOption(ChannelOptions.autoRead, value: true).get()
|
||||
}
|
||||
}
|
||||
let channel = try await bootstrap.bind(host: "127.0.0.1", port: 0).get()
|
||||
guard let port = channel.localAddress?.port else { throw FrameFailure("Couldn't open a local port") }
|
||||
return PortForwarder(channel: channel, localPort: port)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
channel.close(promise: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins two channels: what one reads, the other writes, with backpressure and
|
||||
/// half-close passed across (the pattern from SwiftNIO's examples).
|
||||
final class GlueHandler: ChannelDuplexHandler, @unchecked Sendable {
|
||||
typealias InboundIn = NIOAny
|
||||
typealias OutboundIn = NIOAny
|
||||
typealias OutboundOut = NIOAny
|
||||
|
||||
private var partner: GlueHandler?
|
||||
private var context: ChannelHandlerContext?
|
||||
private var pendingRead = false
|
||||
|
||||
static func matchedPair() -> (GlueHandler, GlueHandler) {
|
||||
let a = GlueHandler(), b = GlueHandler()
|
||||
a.partner = b
|
||||
b.partner = a
|
||||
return (a, b)
|
||||
}
|
||||
|
||||
private func partnerWrite(_ data: NIOAny) { context?.write(data, promise: nil) }
|
||||
private func partnerFlush() { context?.flush() }
|
||||
private func partnerWriteEOF() { context?.close(mode: .output, promise: nil) }
|
||||
private func partnerClose() { context?.close(promise: nil) }
|
||||
private var partnerWritable: Bool { context?.channel.isWritable ?? false }
|
||||
|
||||
private func partnerBecameWritable() {
|
||||
if pendingRead {
|
||||
pendingRead = false
|
||||
context?.read()
|
||||
}
|
||||
}
|
||||
|
||||
func handlerAdded(context: ChannelHandlerContext) { self.context = context }
|
||||
|
||||
func handlerRemoved(context: ChannelHandlerContext) {
|
||||
self.context = nil
|
||||
partner = nil
|
||||
}
|
||||
|
||||
func channelRead(context: ChannelHandlerContext, data: NIOAny) { partner?.partnerWrite(data) }
|
||||
func channelReadComplete(context: ChannelHandlerContext) { partner?.partnerFlush() }
|
||||
func channelInactive(context: ChannelHandlerContext) { partner?.partnerClose() }
|
||||
|
||||
func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) {
|
||||
if let e = event as? ChannelEvent, case .inputClosed = e {
|
||||
partner?.partnerWriteEOF()
|
||||
}
|
||||
context.fireUserInboundEventTriggered(event)
|
||||
}
|
||||
|
||||
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
||||
partner?.partnerClose()
|
||||
}
|
||||
|
||||
func channelWritabilityChanged(context: ChannelHandlerContext) {
|
||||
if context.channel.isWritable { partner?.partnerBecameWritable() }
|
||||
}
|
||||
|
||||
func read(context: ChannelHandlerContext) {
|
||||
if let partner, partner.partnerWritable {
|
||||
context.read()
|
||||
} else {
|
||||
pendingRead = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.frameBackground.ignoresSafeArea()
|
||||
switch model.phase {
|
||||
case .setup:
|
||||
SetupView(model: model)
|
||||
case .connecting(let step):
|
||||
ConnectingView(step: step, host: model.settings?.host) { model.showSetup() }
|
||||
case .failed(let message) where model.retrying && !model.needsPairing:
|
||||
WaitingView(host: model.settings.map { $0.port == 22 ? $0.host : "\($0.host):\($0.port)" } ?? "", detail: message, deviceName: model.deviceName,
|
||||
reachable: { Task { await model.connect(quiet: true) } }, change: { model.showSetup() })
|
||||
case .failed(let message):
|
||||
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
|
||||
retry: { Task { await model.connect() } }, change: { model.showSetup() })
|
||||
case .ready(let url):
|
||||
WebShell(url: url, model: model).ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.tint(.frameBlue)
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
static let frameBackground = Color(red: 0.055, green: 0.078, blue: 0.106)
|
||||
static let framePanel = Color(red: 0.118, green: 0.137, blue: 0.161)
|
||||
static let frameBlue = Color(red: 0.102, green: 0.624, blue: 1.0)
|
||||
static let frameMuted = Color(red: 0.561, green: 0.596, blue: 0.627)
|
||||
}
|
||||
|
||||
struct ConnectingView: View {
|
||||
let step: String
|
||||
let host: String?
|
||||
let cancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
ProgressView().controlSize(.large)
|
||||
Text(step).font(.headline).multilineTextAlignment(.center)
|
||||
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
|
||||
Button("Change headset", action: cancel).padding(.top, 8)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
|
||||
struct FailedView: View {
|
||||
let message: String
|
||||
let canRetry: Bool
|
||||
let retrying: Bool
|
||||
let needsPairing: Bool
|
||||
let retry: () -> Void
|
||||
let change: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
|
||||
.font(.system(size: 44)).foregroundStyle(.orange)
|
||||
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
|
||||
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
|
||||
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
|
||||
if needsPairing {
|
||||
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
} else if canRetry {
|
||||
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
}
|
||||
if !needsPairing { Button(canRetry ? "Change headset" : "Back", action: change) }
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: 480)
|
||||
}
|
||||
}
|
||||
|
||||
/// A paired Frame that isn't answering is almost always asleep: say how to wake
|
||||
/// it, and connect the moment it does (its SSH port is checked every 3 s).
|
||||
struct WaitingView: View {
|
||||
let host: String
|
||||
let detail: String
|
||||
let deviceName: String
|
||||
let reachable: () -> Void
|
||||
let change: () -> Void
|
||||
@State private var pulse = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
.opacity(pulse ? 1 : 0.55)
|
||||
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
|
||||
Text("Waiting for your Frame").font(.title3.bold())
|
||||
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
|
||||
.multilineTextAlignment(.center)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
|
||||
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
|
||||
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
|
||||
Button("Connect to a different Frame", action: change).font(.footnote)
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 480)
|
||||
.onAppear { pulse = true }
|
||||
.task(id: host) {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
if !host.isEmpty, await FrameFinder.sshAnswers(host: host) {
|
||||
reachable()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// First run: two steps. Wake the Frame (the app finds it by itself), then type the
|
||||
/// Developer Mode password once. Everything else waits under "Other ways to connect".
|
||||
struct SetupView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@StateObject private var finder = FrameFinder()
|
||||
@State private var password = ""
|
||||
@State private var manualHost = ""
|
||||
@State private var user = "steamos"
|
||||
@State private var showOther = false
|
||||
@State private var showHelp = false
|
||||
@FocusState private var passwordFocused: Bool
|
||||
|
||||
/// Where Connect goes: what the finder saw, else what was typed, else frame.local.
|
||||
private var host: String {
|
||||
let typed = manualHost.trimmingCharacters(in: .whitespaces)
|
||||
return finder.found?.host ?? (typed.isEmpty ? "frame.local" : typed)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 22) {
|
||||
header
|
||||
StepCard(number: 1, title: "Wake your Frame", done: finder.found != nil) { wakeStep }
|
||||
StepCard(number: 2, title: "Enter its Developer Mode password", done: false) { passwordStep }
|
||||
otherWays
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: 560)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.background(Color.frameBackground)
|
||||
.onAppear {
|
||||
manualHost = model.settings?.host ?? ""
|
||||
user = model.settings?.user ?? "steamos"
|
||||
var fallback = model.settings?.host
|
||||
#if DEBUG
|
||||
fallback = ProcessInfo.processInfo.environment["FRAME_TEST_FALLBACK"] ?? fallback // Simulator test hook
|
||||
#endif
|
||||
finder.start(fallback: fallback)
|
||||
}
|
||||
.onDisappear { finder.stop() }
|
||||
// After a few seconds of not finding it, say exactly what to check.
|
||||
.task(id: finder.since) {
|
||||
try? await Task.sleep(nanoseconds: 8_000_000_000)
|
||||
showHelp = true
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Image("AppIconImage").resizable().frame(width: 56, height: 56).clipShape(RoundedRectangle(cornerRadius: 13))
|
||||
Text("Connect to your Steam Frame").font(.title2.bold())
|
||||
Text("One time only. After this, the app connects by itself whenever your Frame is awake.")
|
||||
.foregroundStyle(Color.frameMuted)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: step 1
|
||||
|
||||
@ViewBuilder private var wakeStep: some View {
|
||||
if let found = finder.found {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Found your Frame").fontWeight(.semibold)
|
||||
Text(found.name == found.host ? found.host : "\(found.name) · \(found.host)")
|
||||
.font(.footnote).foregroundStyle(Color.frameMuted)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Looking for it on this network…").foregroundStyle(Color.frameMuted)
|
||||
}
|
||||
Text("Put the headset on, or press its power button, so it's awake.")
|
||||
if showHelp {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Still can't see it? Check:").font(.subheadline.weight(.semibold))
|
||||
Tip(icon: "wifi", text: "The Frame and this \(model.deviceName) are on the same Wi-Fi.")
|
||||
Tip(icon: "hammer", text: "Developer Mode is on: on the Frame, Steam Settings → System → Enable Developer Mode.")
|
||||
Tip(icon: "network", text: "Local Network is allowed for Frame Control: \(model.deviceName) Settings → Apps → Frame Control.")
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: step 2
|
||||
|
||||
@ViewBuilder private var passwordStep: some View {
|
||||
SecureField("Developer Mode password", text: $password)
|
||||
.textContentType(.password)
|
||||
.submitLabel(.go)
|
||||
.focused($passwordFocused)
|
||||
.onSubmit(connect)
|
||||
.padding(12)
|
||||
.background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 10))
|
||||
Text("Haven't set one? On the Frame: Steam Settings → Developer → Set User Password. It's only used now, to let this \(model.deviceName) in; it isn't saved.")
|
||||
.font(.footnote).foregroundStyle(Color.frameMuted)
|
||||
Button(action: connect) {
|
||||
Text(finder.found == nil ? "Connect to \(host)" : "Connect")
|
||||
.fontWeight(.semibold).frame(maxWidth: .infinity).padding(.vertical, 4)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(password.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: everything else, out of the way
|
||||
|
||||
private var otherWays: some View {
|
||||
DisclosureGroup(isExpanded: $showOther) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Address").font(.footnote).foregroundStyle(Color.frameMuted)
|
||||
TextField("frame.local, an IP, or a Tailscale name", text: $manualHost)
|
||||
.keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.onSubmit { finder.start(fallback: manualHost) }
|
||||
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
|
||||
TextField("User", text: $user)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
|
||||
Text("Typing an address here uses it instead of searching.").font(.caption).foregroundStyle(Color.frameMuted)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Already reach the Frame over SSH? Add this \(model.deviceName)'s key to ~/.ssh/authorized_keys there, then connect without a password.")
|
||||
.font(.footnote).foregroundStyle(Color.frameMuted)
|
||||
HStack {
|
||||
Button("Copy key") { UIPasteboard.general.string = model.authorizedKeysLine }
|
||||
Spacer()
|
||||
Button("Connect with the key") {
|
||||
let (h, u) = (host, user)
|
||||
Task { await model.useKey(host: h, user: u) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if let saved = model.settings {
|
||||
Button("Forget \(saved.host)", role: .destructive) { Task { await model.forget() } }
|
||||
}
|
||||
}
|
||||
.padding(.top, 10)
|
||||
} label: {
|
||||
Text("Other ways to connect").foregroundStyle(Color.frameMuted)
|
||||
}
|
||||
.onChange(of: manualHost) { _, value in
|
||||
// A typed address replaces the search.
|
||||
if !value.trimmingCharacters(in: .whitespaces).isEmpty, finder.found?.host != value { finder.start(fallback: value) }
|
||||
}
|
||||
}
|
||||
|
||||
private func connect() {
|
||||
guard !password.isEmpty else { passwordFocused = true; return }
|
||||
let (h, u, p) = (host, user, password)
|
||||
password = ""
|
||||
finder.stop()
|
||||
Task { await model.pair(host: h, user: u, password: p) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A numbered step with a tick once it's done.
|
||||
struct StepCard<Content: View>: View {
|
||||
let number: Int
|
||||
let title: String
|
||||
let done: Bool
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle().fill(done ? Color.green : Color.frameBlue).frame(width: 26, height: 26)
|
||||
if done { Image(systemName: "checkmark").font(.caption.bold()) } else { Text("\(number)").font(.subheadline.bold()) }
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
Text(title).font(.headline)
|
||||
}
|
||||
content
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
struct Tip: View {
|
||||
let icon: String
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
Label { Text(text).font(.subheadline).fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: icon).foregroundStyle(Color.frameBlue) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
/// The Frame Control page, served by the server on the headset, in a web view.
|
||||
/// window.frameApp (the same bridge the desktop app's preload.js provides) lets
|
||||
/// the page use the phone: clipboard, saving images, other apps, install links.
|
||||
struct WebShell: UIViewRepresentable {
|
||||
let url: URL
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(model: model) }
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
let content = WKUserContentController()
|
||||
content.addUserScript(WKUserScript(source: Self.bridge, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||
content.addScriptMessageHandler(context.coordinator, contentWorld: .page, name: "frameApp")
|
||||
config.userContentController = content
|
||||
config.allowsInlineMediaPlayback = true
|
||||
let web = WKWebView(frame: .zero, configuration: config)
|
||||
web.navigationDelegate = context.coordinator
|
||||
web.uiDelegate = context.coordinator
|
||||
web.isOpaque = false
|
||||
web.backgroundColor = UIColor(red: 0.055, green: 0.078, blue: 0.106, alpha: 1)
|
||||
web.scrollView.backgroundColor = web.backgroundColor
|
||||
web.scrollView.contentInsetAdjustmentBehavior = .never // the page pads for the safe area itself
|
||||
web.allowsBackForwardNavigationGestures = false
|
||||
#if DEBUG
|
||||
web.isInspectable = true
|
||||
#endif
|
||||
context.coordinator.web = web
|
||||
web.load(URLRequest(url: url))
|
||||
return web
|
||||
}
|
||||
|
||||
func updateUIView(_ web: WKWebView, context: Context) {
|
||||
if context.coordinator.loaded != url {
|
||||
context.coordinator.loaded = url
|
||||
web.load(URLRequest(url: url))
|
||||
}
|
||||
context.coordinator.deliverInstallLinks()
|
||||
}
|
||||
|
||||
static let bridge = """
|
||||
(() => {
|
||||
const call = (name, arg) => window.webkit.messageHandlers.frameApp.postMessage({ name, arg: arg ?? null });
|
||||
let installCb = null;
|
||||
window.frameApp = {
|
||||
platform: "ios",
|
||||
readClipboard: () => call("readClipboard"),
|
||||
setUpConnection: () => call("setUpConnection"),
|
||||
open: (what) => call("open", what),
|
||||
saveImages: (images) => call("saveImages", images),
|
||||
onInstallLink: (cb) => { installCb = cb; return call("installLinkReady"); },
|
||||
};
|
||||
window.__frameInstallLink = (req) => { if (installCb) installCb(req); };
|
||||
})();
|
||||
"""
|
||||
|
||||
final class Coordinator: NSObject, WKScriptMessageHandlerWithReply, WKNavigationDelegate, WKUIDelegate {
|
||||
let model: AppModel
|
||||
weak var web: WKWebView?
|
||||
var loaded: URL?
|
||||
private var installReady = false
|
||||
|
||||
init(model: AppModel) { self.model = model }
|
||||
|
||||
// MARK: bridge
|
||||
|
||||
@MainActor
|
||||
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage,
|
||||
replyHandler: @escaping (Any?, String?) -> Void) {
|
||||
guard let body = message.body as? [String: Any], let name = body["name"] as? String else {
|
||||
return replyHandler(nil, "bad message")
|
||||
}
|
||||
let arg = body["arg"]
|
||||
switch name {
|
||||
case "readClipboard":
|
||||
replyHandler(UIPasteboard.general.string ?? "", nil)
|
||||
case "setUpConnection":
|
||||
model.showSetup()
|
||||
replyHandler(nil, nil)
|
||||
case "open":
|
||||
let result = open(arg as? String ?? "")
|
||||
replyHandler(result.message.map { ["message": $0] }, result.error)
|
||||
case "saveImages":
|
||||
let images = (arg as? [[String: Any]] ?? []).compactMap { item -> UIImage? in
|
||||
guard let b64 = item["data"] as? String, let data = Data(base64Encoded: b64) else { return nil }
|
||||
return UIImage(data: data)
|
||||
}
|
||||
guard !images.isEmpty else { return replyHandler(nil, "No images to save") }
|
||||
share(images)
|
||||
replyHandler(["message": "Choose Save Image to keep \(images.count == 1 ? "it" : "them") in Photos"], nil)
|
||||
case "installLinkReady":
|
||||
installReady = true
|
||||
deliverInstallLinks()
|
||||
replyHandler(nil, nil)
|
||||
default:
|
||||
replyHandler(nil, "unknown request \(name)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func deliverInstallLinks() {
|
||||
guard installReady, let web, !model.pendingInstallLinks.isEmpty else { return }
|
||||
let links = model.pendingInstallLinks
|
||||
model.pendingInstallLinks = []
|
||||
for link in links {
|
||||
let req = ["kind": link.kind.rawValue, "target": link.target]
|
||||
guard let json = try? JSONSerialization.data(withJSONObject: req), let text = String(data: json, encoding: .utf8) else { continue }
|
||||
web.evaluateJavaScript("window.__frameInstallLink(\(text))")
|
||||
}
|
||||
}
|
||||
|
||||
/// SSH, SFTP, Steam Link and remote desktop open in the apps that handle them.
|
||||
@MainActor
|
||||
private func open(_ what: String) -> (message: String?, error: String?) {
|
||||
guard let s = model.settings else { return (nil, "Not paired with a Frame") }
|
||||
let host = s.host.contains(":") ? "[\(s.host)]" : s.host
|
||||
let target: (url: String, app: String, store: String)
|
||||
switch what {
|
||||
case "terminal": target = ("ssh://\(s.user)@\(host):\(s.port)", "an SSH app such as Blink Shell or Termius", "https://apps.apple.com/search?term=ssh")
|
||||
case "sftp": target = ("sftp://\(s.user)@\(host):\(s.port)", "an SFTP app such as Termius or Secure ShellFish", "https://apps.apple.com/search?term=sftp")
|
||||
case "steamlink": target = ("steamlink://", "Steam Link", "https://apps.apple.com/app/steam-link/id1246969117")
|
||||
case "rdp": target = ("rdp://full%20address=s:\(s.host):3389", "Windows App (Microsoft Remote Desktop)", "https://apps.apple.com/app/windows-app/id714464092")
|
||||
default: return (nil, "Can't open \(what) on this \(model.deviceName)")
|
||||
}
|
||||
guard let url = URL(string: target.url) else { return (nil, "Bad address") }
|
||||
if UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url)
|
||||
return ("Opening \(target.app)", nil)
|
||||
}
|
||||
if let store = URL(string: target.store) { UIApplication.shared.open(store) }
|
||||
return (nil, "Install \(target.app) to open this; opening the App Store")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func share(_ images: [UIImage]) {
|
||||
guard let web, let root = web.window?.rootViewController else { return }
|
||||
let sheet = UIActivityViewController(activityItems: images, applicationActivities: nil)
|
||||
sheet.popoverPresentationController?.sourceView = web
|
||||
sheet.popoverPresentationController?.sourceRect = CGRect(x: web.bounds.midX, y: web.bounds.midY, width: 1, height: 1)
|
||||
(root.presentedViewController ?? root).present(sheet, animated: true)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Simulator test hook: FRAME_TEST_JS runs in the page once it has loaded.
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
guard let js = ProcessInfo.processInfo.environment["FRAME_TEST_JS"] else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { webView.evaluateJavaScript(js) }
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: navigation: the app's page stays here; other sites open in Safari
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard let url = action.request.url else { return decisionHandler(.cancel) }
|
||||
if url.host == "127.0.0.1" || url.scheme == "about" || url.scheme == "blob" || url.scheme == "data" {
|
||||
return decisionHandler(.allow)
|
||||
}
|
||||
UIApplication.shared.open(url)
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
||||
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
||||
if let url = action.request.url { UIApplication.shared.open(url) } // target="_blank" links
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: alert() and confirm(), which the page uses before removing things
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String,
|
||||
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
|
||||
present(message, actions: [UIAlertAction(title: "OK", style: .default) { _ in completionHandler() }], fallback: completionHandler)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
|
||||
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
||||
present(message, actions: [
|
||||
UIAlertAction(title: "Cancel", style: .cancel) { _ in completionHandler(false) },
|
||||
UIAlertAction(title: "OK", style: .default) { _ in completionHandler(true) },
|
||||
], fallback: { completionHandler(false) })
|
||||
}
|
||||
|
||||
private func present(_ message: String, actions: [UIAlertAction], fallback: @escaping () -> Void) {
|
||||
guard let root = web?.window?.rootViewController else { return fallback() }
|
||||
let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
|
||||
actions.forEach(alert.addAction)
|
||||
(root.presentedViewController ?? root).present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import CryptoKit
|
||||
import XCTest
|
||||
@testable import Frame_Control
|
||||
|
||||
final class InstallLinkTests: XCTestCase {
|
||||
func testAcceptsManifestAndURLLinks() {
|
||||
XCTAssertEqual(InstallLink("frame-control://install?manifest=https://example.com/app.json"),
|
||||
InstallLink("frame-control://install/?manifest=https://example.com/app.json"))
|
||||
XCTAssertEqual(InstallLink("frame-control://install?url=https://example.com/a.apk")?.kind, .url)
|
||||
XCTAssertEqual(InstallLink("FRAME-CONTROL://install?manifest=https://example.com/m.json")?.target, "https://example.com/m.json")
|
||||
}
|
||||
|
||||
func testRejectsAnythingElse() {
|
||||
for raw in ["frame-control://other?url=https://example.com/a.apk",
|
||||
"frame-control://install?url=ftp://example.com/a.apk",
|
||||
"frame-control://install?url=https://user:pw@example.com/a.apk",
|
||||
"frame-control://install?url=https://example.com/a&manifest=https://example.com/b",
|
||||
"frame-control://install?url=https://a.example/x&url=https://b.example/y",
|
||||
"frame-control://install?url=",
|
||||
"frame-control://install/deeper?url=https://example.com/a.apk",
|
||||
"https://example.com/?url=https://example.com/a.apk",
|
||||
"frame-control://install?url=https://example.com/" + String(repeating: "a", count: 2100)] {
|
||||
XCTAssertNil(InstallLink(raw), raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class HeadsetServerTests: XCTestCase {
|
||||
func testReadsThePortTheServerPrints() {
|
||||
XCTAssertEqual(HeadsetServer.port(in: "Frame Control on http://127.0.0.1:41234 (alias: frame; Ctrl-C to stop)\n"), 41234)
|
||||
XCTAssertNil(HeadsetServer.port(in: "Traceback (most recent call last):"))
|
||||
XCTAssertNil(HeadsetServer.port(in: "Frame Control on http://127.0.0.1:4")) // more digits may follow
|
||||
XCTAssertNil(HeadsetServer.port(in: "Frame Control on http://127.0.0.1:99999 "))
|
||||
}
|
||||
|
||||
func testBundleIsInTheApp() throws {
|
||||
let bundle = try HeadsetServer.Bundle.fromApp()
|
||||
XCTAssertGreaterThan(bundle.data.count, 100_000)
|
||||
XCTAssertEqual(bundle.version.count, 16)
|
||||
}
|
||||
}
|
||||
|
||||
final class KeyTests: XCTestCase {
|
||||
func testAuthorizedKeysLine() {
|
||||
let line = DeviceKey.authorizedKeysLine(Curve25519.Signing.PrivateKey(), comment: "frame-control@iPhone")
|
||||
let parts = line.split(separator: " ")
|
||||
XCTAssertEqual(parts.count, 3)
|
||||
XCTAssertEqual(parts[0], "ssh-ed25519")
|
||||
XCTAssertEqual(Data(base64Encoded: String(parts[1]))?.count, 51) // string "ssh-ed25519" + 32-byte key
|
||||
XCTAssertEqual(parts[2], "frame-control@iPhone")
|
||||
}
|
||||
|
||||
func testShellQuote() {
|
||||
XCTAssertEqual(shellQuote("it's"), "'it'\\''s'")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
name: FrameControl
|
||||
options:
|
||||
bundleIdPrefix: com.saphid
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
createIntermediateGroups: true
|
||||
packages:
|
||||
Citadel:
|
||||
url: https://github.com/orlandos-nl/Citadel.git
|
||||
exactVersion: 0.12.1
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.0"
|
||||
MARKETING_VERSION: "0.1.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
targets:
|
||||
FrameControl:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: FrameControl
|
||||
dependencies:
|
||||
- package: Citadel
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.saphid.framecontrol
|
||||
PRODUCT_NAME: Frame Control
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_FILE: FrameControl/Info.plist
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
||||
info:
|
||||
path: FrameControl/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: Frame Control
|
||||
UILaunchScreen:
|
||||
UIColorName: ""
|
||||
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
|
||||
UISupportedInterfaceOrientations~ipad: [UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
|
||||
UIUserInterfaceStyle: Dark
|
||||
NSLocalNetworkUsageDescription: Frame Control finds your Steam Frame on your network and connects to it.
|
||||
NSBonjourServices: [_steamos-devkit._tcp]
|
||||
NSPhotoLibraryAddUsageDescription: Frame Control saves headset captures and screenshots to your photo library when you ask it to.
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsLocalNetworking: true
|
||||
LSApplicationQueriesSchemes: [ssh, sftp, steamlink, rdp]
|
||||
CFBundleURLTypes:
|
||||
- CFBundleURLName: com.saphid.framecontrol.install
|
||||
CFBundleURLSchemes: [frame-control]
|
||||
preBuildScripts:
|
||||
- name: Pack the Frame bundle
|
||||
# The server, headset helpers and catalogue, as the app copies them to the Frame.
|
||||
script: |
|
||||
mkdir -p "${DERIVED_FILE_DIR}"
|
||||
python3 "${SRCROOT}/scripts/make_frame_bundle.py" "${DERIVED_FILE_DIR}/frame-bundle.tar.gz" > "${DERIVED_FILE_DIR}/frame-bundle.version"
|
||||
mkdir -p "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"
|
||||
cp "${DERIVED_FILE_DIR}/frame-bundle.tar.gz" "${DERIVED_FILE_DIR}/frame-bundle.version" "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/"
|
||||
basedOnDependencyAnalysis: false
|
||||
FrameControlTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: FrameControlTests
|
||||
dependencies:
|
||||
- target: FrameControl
|
||||
settings:
|
||||
base:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Frame Control.app/Frame Control"
|
||||
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||
schemes:
|
||||
FrameControl:
|
||||
build:
|
||||
targets:
|
||||
FrameControl: all
|
||||
FrameControlTests: [test]
|
||||
test:
|
||||
targets: [FrameControlTests]
|
||||
@@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1a9fff"/>
|
||||
<stop offset="1" stop-color="#6f42c1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="visor" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#dfe8f5"/>
|
||||
</linearGradient>
|
||||
<clipPath id="tile"><rect x="100" y="100" width="824" height="824" rx="185"/></clipPath>
|
||||
<mask id="nose">
|
||||
<rect width="1024" height="1024" fill="#fff"/>
|
||||
<ellipse cx="512" cy="690" rx="78" ry="96" fill="#000"/>
|
||||
</mask>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#0b1020" flood-opacity=".35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="1024" height="1024" fill="url(#bg)"/>
|
||||
<g transform="translate(512 512) scale(1.2427) translate(-512 -512)">
|
||||
<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"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pack what Frame Control's server needs to run on the Frame itself (the files the
|
||||
desktop app ships, plus ui/local-bin) into one reproducible .tar.gz.
|
||||
|
||||
The iPhone app copies it to ~/.cache/frame-control/<version> on the Frame and
|
||||
starts ui/server.py there. <version> is the SHA-256 of the archive, so a new
|
||||
build replaces an old one and an unchanged one isn't copied again.
|
||||
|
||||
Usage: make_frame_bundle.py OUT.tar.gz (prints the version)
|
||||
"""
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
|
||||
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js"]
|
||||
|
||||
|
||||
def files():
|
||||
found = set()
|
||||
for pattern in PATTERNS:
|
||||
for p in ROOT.glob(pattern):
|
||||
if p.is_file() and "__pycache__" not in p.parts:
|
||||
found.add(p)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def build():
|
||||
raw = io.BytesIO()
|
||||
with tarfile.open(fileobj=raw, mode="w", format=tarfile.PAX_FORMAT) as tar:
|
||||
for p in files():
|
||||
info = tarfile.TarInfo(str(p.relative_to(ROOT)))
|
||||
data = p.read_bytes()
|
||||
info.size, info.mtime, info.uid, info.gid, info.uname, info.gname = len(data), 0, 0, 0, "", ""
|
||||
info.mode = 0o755 if p.stat().st_mode & 0o111 else 0o644
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
out = io.BytesIO()
|
||||
with gzip.GzipFile(fileobj=out, mode="wb", mtime=0) as gz:
|
||||
gz.write(raw.getvalue())
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
sys.exit(__doc__)
|
||||
data = build()
|
||||
Path(sys.argv[1]).write_bytes(data)
|
||||
print(hashlib.sha256(data).hexdigest()[:16])
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Linux host with Docker: run the end-to-end tests against the fake Frame.
|
||||
# Builds the fake Frame and host images (tests/fakeframe), starts them with
|
||||
# docker compose, runs tests/e2e in the host container, prints the results
|
||||
# and takes everything down again. Exits with the tests' status (2 if the
|
||||
# harness itself didn't come up). See docs/testing.md.
|
||||
#
|
||||
# Usage: scripts/e2e.sh [TEST...] e.g. scripts/e2e.sh test_titles test_faults.Faults.test_disk_full
|
||||
# Env: FAKEFRAME_BASE base image (default: archlinux:base, or Valve's Holo Core aarch64 on arm64)
|
||||
# FAKEFRAME_KEEP=1 leave the containers running afterwards
|
||||
set -uo pipefail
|
||||
|
||||
root=${0:A:h:h}
|
||||
cd "$root" || exit 2
|
||||
case $(uname -m) in
|
||||
x86_64|amd64) base=archlinux:base ;;
|
||||
aarch64|arm64) base=registry.gitlab.steamos.cloud/holo/holo-core-aarch64-preview/base-devel:latest ;;
|
||||
*) print -u2 "No Arch Linux base image known for $(uname -m); set FAKEFRAME_BASE"; exit 2 ;;
|
||||
esac
|
||||
base=${FAKEFRAME_BASE:-$base}
|
||||
compose=(docker compose -p fakeframe-e2e -f tests/fakeframe/compose.yaml)
|
||||
started=$SECONDS
|
||||
|
||||
print "==> Building fakeframe-frame (from $base) and fakeframe-host"
|
||||
# Quiet when it works; if a build fails, build again with the full log so CI shows why.
|
||||
build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } }
|
||||
build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe
|
||||
build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe
|
||||
|
||||
logs() {
|
||||
print "\n==> Fake Frame logs"
|
||||
$compose logs --no-color --tail 80 fakeframe
|
||||
$compose exec -T fakeframe sh -c 'for f in /var/log/fakeframe/*.log; do echo "--- $f"; tail -n 40 "$f"; done' 2>/dev/null
|
||||
print "\n==> ui/server.py log"
|
||||
$compose exec -T host sh -c 'tail -n 80 /tmp/fakeframe-e2e-server.log' 2>/dev/null
|
||||
}
|
||||
|
||||
finish() {
|
||||
if [[ ${FAKEFRAME_KEEP:-0} == 1 ]]; then
|
||||
print "==> Left running: ${(j: :)compose} exec host bash"
|
||||
else
|
||||
$compose down -v --remove-orphans >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
trap finish EXIT
|
||||
|
||||
$compose down -v --remove-orphans >/dev/null 2>&1
|
||||
print "==> Starting the fake Frame and the host"
|
||||
if ! $compose up -d --wait; then
|
||||
logs
|
||||
exit 2
|
||||
fi
|
||||
print "==> Up after $(( SECONDS - started )) s; running tests/e2e"
|
||||
|
||||
if (( $# )); then
|
||||
args=(-v "$@")
|
||||
else
|
||||
args=(discover -v -s .)
|
||||
fi
|
||||
tests_started=$SECONDS
|
||||
$compose exec -T -w /repo/tests/e2e host python3 -m unittest "${args[@]}"
|
||||
rc=$?
|
||||
(( rc == 0 )) || logs
|
||||
print "\n==> tests/e2e: $([[ $rc == 0 ]] && echo passed || echo "FAILED (exit $rc)") in $(( SECONDS - tests_started )) s" \
|
||||
"($(( SECONDS - started )) s with builds)"
|
||||
exit $rc
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac or Linux: the headset smoke test. Installs, launches and removes tiny
|
||||
# test titles on the Frame (the `frame` alias) and records the results with
|
||||
# its BUILD_ID under tests/smoke/results/. See docs/testing.md.
|
||||
#
|
||||
# Usage: scripts/frame-smoke.sh [--pair] (--pair needs you in the headset to approve)
|
||||
set -euo pipefail
|
||||
exec python3 "${0:A:h:h}/tests/smoke/frame_smoke.py" "$@"
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check Frame Control's heart-rate readings against a reference, on your computer.
|
||||
|
||||
python3 scripts/heart-check.py listen --port 9000 --out ours.csv
|
||||
python3 scripts/heart-check.py compare ours.csv reference.csv
|
||||
python3 scripts/heart-check.py compare ours.csv ~/Downloads/export.zip
|
||||
|
||||
`listen` receives our integer-BPM OSC messages (send them here with
|
||||
`tracking-on-frame.py heart --osc <this computer's IP> 9000`) and shows each
|
||||
reading live, so you can watch it next to a reference such as your Apple
|
||||
Watch. `--out` also records `unix_seconds,bpm` to a new private file.
|
||||
|
||||
`compare` lines up two recordings by time and reports how far apart they are.
|
||||
The reference can be:
|
||||
- a CSV whose first column is a time (unix seconds or ISO 8601) and whose
|
||||
second is BPM, such as our own log, `listen --out`, or the test strap's
|
||||
output (a third `flags` column marks skin-contact loss as "no reading");
|
||||
- an Apple Health export (`export.zip` or `export.xml`, from Health → your
|
||||
profile → Export All Health Data). Only heart-rate records within the
|
||||
recording's time range are read.
|
||||
|
||||
Everything stays on this computer. Nothing is uploaded.
|
||||
"""
|
||||
import argparse
|
||||
import bisect
|
||||
import csv
|
||||
from datetime import datetime
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from xml.etree import ElementTree
|
||||
|
||||
ADDRESS = "/avatar/parameters/HeartRate"
|
||||
|
||||
|
||||
def parse_osc(packet):
|
||||
"""Return (address, values) for a single OSC message with i/f arguments."""
|
||||
def string(offset):
|
||||
end = packet.index(b"\0", offset)
|
||||
return packet[offset:end].decode("utf-8"), (end + 4) & ~3
|
||||
address, offset = string(0)
|
||||
tags, offset = string(offset)
|
||||
if not tags.startswith(","):
|
||||
raise ValueError("not an OSC message")
|
||||
values = []
|
||||
for tag in tags[1:]:
|
||||
if tag not in "if" or offset + 4 > len(packet):
|
||||
raise ValueError("unsupported OSC argument")
|
||||
values.append(struct.unpack(">i" if tag == "i" else ">f", packet[offset:offset + 4])[0])
|
||||
offset += 4
|
||||
return address, values
|
||||
|
||||
|
||||
def listen(port, address, out, seconds, clock=time.time, stream=sys.stdout):
|
||||
log = None
|
||||
if out:
|
||||
log = os.fdopen(os.open(out, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w")
|
||||
log.write("unix_seconds,bpm\n")
|
||||
received = 0
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.bind(("0.0.0.0", port))
|
||||
sock.settimeout(0.5)
|
||||
print(f"Listening for {address} on UDP port {port}. Ctrl-C stops.", file=stream, flush=True)
|
||||
end = clock() + seconds if seconds else None
|
||||
try:
|
||||
while end is None or clock() < end:
|
||||
try:
|
||||
packet = sock.recv(1024)
|
||||
except socket.timeout:
|
||||
continue
|
||||
try:
|
||||
path, values = parse_osc(packet)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
continue
|
||||
if path != address or len(values) != 1:
|
||||
continue
|
||||
now = clock()
|
||||
bpm = int(values[0])
|
||||
received += 1
|
||||
print(f"{time.strftime('%H:%M:%S', time.localtime(now))} {bpm:3d} bpm", file=stream, flush=True)
|
||||
if log:
|
||||
log.write(f"{now:.3f},{bpm}\n")
|
||||
log.flush()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
if log:
|
||||
log.close()
|
||||
return received
|
||||
|
||||
|
||||
def parse_time(text):
|
||||
text = text.strip()
|
||||
if re.fullmatch(r"\d+(\.\d+)?", text):
|
||||
return float(text)
|
||||
# Apple Health uses "2026-09-29 09:12:03 +1000".
|
||||
text = re.sub(r" ([+-]\d{2}):?(\d{2})$", r"\1\2", text).replace("Z", "+0000")
|
||||
for pattern in ("%Y-%m-%d %H:%M:%S%z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z"):
|
||||
try:
|
||||
return datetime.strptime(text, pattern).timestamp()
|
||||
except ValueError:
|
||||
pass
|
||||
raise ValueError(f"unrecognised time {text!r}")
|
||||
|
||||
|
||||
def read_csv(path):
|
||||
"""[(time, bpm or None)], sorted. A header row is skipped."""
|
||||
samples = []
|
||||
with open(path, newline="") as source:
|
||||
for row in csv.reader(source):
|
||||
if len(row) < 2:
|
||||
continue
|
||||
try:
|
||||
when, bpm = parse_time(row[0]), int(float(row[1]))
|
||||
except (ValueError, OverflowError):
|
||||
continue # header or unparseable line
|
||||
try:
|
||||
flags = int(float(row[2])) if len(row) > 2 else None
|
||||
except (ValueError, OverflowError):
|
||||
flags = None # an unrecognised flag leaves the reading as it is
|
||||
if flags is not None and flags & 4 and not flags & 2:
|
||||
bpm = None # contact supported and not detected: no reading
|
||||
samples.append((when, bpm))
|
||||
return sorted(samples, key=lambda s: s[0])
|
||||
|
||||
|
||||
def read_health(path, start, end):
|
||||
"""Heart-rate records from an Apple Health export between start and end."""
|
||||
samples = []
|
||||
def scan(source):
|
||||
for _, element in ElementTree.iterparse(source):
|
||||
if element.tag == "Record" and element.get("type") == "HKQuantityTypeIdentifierHeartRate":
|
||||
try:
|
||||
when = parse_time(element.get("startDate") or "")
|
||||
value = round(float(element.get("value") or ""))
|
||||
except (ValueError, OverflowError):
|
||||
continue
|
||||
if start <= when <= end:
|
||||
samples.append((when, value))
|
||||
element.clear()
|
||||
if zipfile.is_zipfile(path):
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
names = [n for n in archive.namelist() if n.endswith("/export.xml") or n == "export.xml"]
|
||||
if not names:
|
||||
raise ValueError("no export.xml in that archive; use Health's Export All Health Data")
|
||||
with archive.open(names[0]) as source:
|
||||
scan(source)
|
||||
else:
|
||||
with open(path, "rb") as source:
|
||||
scan(source)
|
||||
return sorted(samples)
|
||||
|
||||
|
||||
def read_any(path, start=None, end=None):
|
||||
path = str(path)
|
||||
if path.endswith((".zip", ".xml")):
|
||||
return read_health(path, start, end)
|
||||
return read_csv(path)
|
||||
|
||||
|
||||
def value_at(samples, when, hold, times=None):
|
||||
"""Our reading at a moment: the latest sample no older than `hold` seconds.
|
||||
`times` is the samples' time column, if the caller has already built it."""
|
||||
times = times if times is not None else [s[0] for s in samples]
|
||||
i = bisect.bisect_right(times, when) - 1
|
||||
if i < 0 or when - times[i] > hold:
|
||||
return None
|
||||
return samples[i][1]
|
||||
|
||||
|
||||
def compare(ours, reference, max_lag=10.0, hold=5.0):
|
||||
"""Compare our readings with the reference at each reference moment.
|
||||
|
||||
`lag` is the delay added to reference times before looking up ours (our
|
||||
readings arrive after the sensor's). The best lag within ±max_lag is used.
|
||||
"""
|
||||
real = [(t, b) for t, b in reference if b is not None]
|
||||
if not real or not any(b is not None for _, b in ours):
|
||||
raise ValueError("both recordings need at least one reading")
|
||||
best = None
|
||||
ours_times = [t for t, _ in ours]
|
||||
steps = int(max_lag * 4)
|
||||
for step in range(-steps, steps + 1):
|
||||
lag = step / 4
|
||||
pairs = [(value_at(ours, t + lag, hold, ours_times), b) for t, b in real]
|
||||
pairs = [(o, r) for o, r in pairs if o is not None]
|
||||
if not pairs:
|
||||
continue
|
||||
error = sum(abs(o - r) for o, r in pairs) / len(pairs)
|
||||
key = (-len(pairs), error, abs(lag))
|
||||
if best is None or key < best[0]:
|
||||
best = (key, lag, pairs)
|
||||
if best is None:
|
||||
raise ValueError("the recordings do not overlap in time")
|
||||
_, lag, pairs = best
|
||||
differences = [o - r for o, r in pairs]
|
||||
# While the reference says "no reading" (lost skin contact), we must not
|
||||
# produce new readings. Count ours that arrive during such a stretch.
|
||||
gaps = [t for t, b in reference if b is None]
|
||||
reference_times = [t for t, _ in reference]
|
||||
shown_in_gaps = 0
|
||||
for t, bpm in ours:
|
||||
i = bisect.bisect_right(reference_times, t - lag) - 1
|
||||
if bpm is not None and i >= 0 and reference[i][1] is None:
|
||||
shown_in_gaps += 1
|
||||
return {
|
||||
"reference_readings": len(real),
|
||||
"matched": len(pairs),
|
||||
"lag_seconds": lag,
|
||||
"mean_abs_error": sum(abs(d) for d in differences) / len(differences),
|
||||
"bias": sum(differences) / len(differences),
|
||||
"max_abs_error": max(abs(d) for d in differences),
|
||||
"within_5": sum(1 for d in differences if abs(d) <= 5) / len(differences),
|
||||
"exact": sum(1 for d in differences if d == 0) / len(differences),
|
||||
"no_contact_moments": len(gaps),
|
||||
"shown_during_no_contact": shown_in_gaps,
|
||||
}
|
||||
|
||||
|
||||
def report(result, tolerance, stream=sys.stdout):
|
||||
print(f"Reference readings: {result['reference_readings']}, matched: {result['matched']}", file=stream)
|
||||
print(f"Best alignment: ours {result['lag_seconds']:+.2f} s after the reference", file=stream)
|
||||
print(f"Mean absolute difference: {result['mean_abs_error']:.2f} BPM "
|
||||
f"(bias {result['bias']:+.2f}, worst {result['max_abs_error']})", file=stream)
|
||||
print(f"Within ±5 BPM: {result['within_5']:.0%}; identical: {result['exact']:.0%}", file=stream)
|
||||
if result["no_contact_moments"]:
|
||||
print(f"No-contact moments: {result['no_contact_moments']}, where we showed a stale "
|
||||
f"reading: {result['shown_during_no_contact']}", file=stream)
|
||||
coverage = result["matched"] / result["reference_readings"]
|
||||
passed = (result["mean_abs_error"] <= tolerance and coverage >= 0.8
|
||||
and not result["shown_during_no_contact"])
|
||||
print(("PASS" if passed else "FAIL") + f" (mean difference ≤ {tolerance} BPM, ≥80% of reference "
|
||||
f"readings matched, nothing shown without contact)", file=stream)
|
||||
return passed
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
heard = commands.add_parser("listen", help="show and optionally record our OSC readings live")
|
||||
heard.add_argument("--port", type=int, default=9000)
|
||||
heard.add_argument("--address", default=ADDRESS)
|
||||
heard.add_argument("--out", help="new private CSV file")
|
||||
heard.add_argument("--seconds", type=float, default=0, help="stop after this long (default: until Ctrl-C)")
|
||||
check = commands.add_parser("compare", help="compare our recording with a reference")
|
||||
check.add_argument("ours", type=Path)
|
||||
check.add_argument("reference", type=Path)
|
||||
check.add_argument("--tolerance", type=float, default=5.0, help="allowed mean difference in BPM (default 5)")
|
||||
check.add_argument("--max-lag", type=float, default=10.0, help="largest time offset to search, seconds")
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "listen":
|
||||
count = listen(args.port, args.address, args.out, args.seconds)
|
||||
print(f"Received {count} readings.")
|
||||
return 0 if count else 3
|
||||
try:
|
||||
ours = read_csv(args.ours)
|
||||
if not ours:
|
||||
raise ValueError("our recording has no readings")
|
||||
reference = read_any(args.reference, ours[0][0] - 60, ours[-1][0] + 60)
|
||||
result = compare(ours, reference, args.max_lag)
|
||||
except (ValueError, OSError, csv.Error, ElementTree.ParseError, zipfile.BadZipFile) as error:
|
||||
print(f"Could not compare: {error}")
|
||||
return 1
|
||||
return 0 if report(result, args.tolerance) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
// A synthetic Bluetooth heart-rate strap for testing, run on the Mac.
|
||||
//
|
||||
// swiftc -O scripts/heart-test-strap.swift -o /tmp/heart-test-strap
|
||||
// /tmp/heart-test-strap [seconds] # default 60
|
||||
//
|
||||
// Advertises the standard Heart Rate Service (180d) as "FC Test Strap" and,
|
||||
// while a central is subscribed, sends one Heart Rate Measurement (2a37) per
|
||||
// second from a fixed, known sequence. Each sent value is printed to stdout as
|
||||
// `unix_seconds,bpm,flags` so scripts/heart-check.py can compare what the
|
||||
// Frame received with what was sent. Every value is synthetic; this is not a
|
||||
// sensor. macOS asks for Bluetooth permission the first time it runs.
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
let hrs = CBUUID(string: "180D")
|
||||
let measurement = CBUUID(string: "2A37")
|
||||
|
||||
/// The known sequence: an 8-bit ramp, 16-bit encodings, a skin-contact loss
|
||||
/// (which must show as no reading) and a recovery.
|
||||
func packet(_ second: Int) -> (bpm: Int, flags: UInt8) {
|
||||
switch second % 60 {
|
||||
case 0..<30: return (60 + second % 60 * 4, 0x06) // 60…176, contact detected
|
||||
case 30..<40: return (180 - (second % 60 - 30) * 3, 0x07) // 16-bit value
|
||||
case 40..<45: return (150, 0x04) // contact lost
|
||||
default: return (72 + (second % 60 - 45), 0x06)
|
||||
}
|
||||
}
|
||||
|
||||
final class Strap: NSObject, CBPeripheralManagerDelegate {
|
||||
let seconds: Int
|
||||
var manager: CBPeripheralManager!
|
||||
var characteristic: CBMutableCharacteristic!
|
||||
var subscribed = false
|
||||
var sent = 0
|
||||
|
||||
init(seconds: Int) {
|
||||
self.seconds = seconds
|
||||
super.init()
|
||||
manager = CBPeripheralManager(delegate: self, queue: nil)
|
||||
}
|
||||
|
||||
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
|
||||
guard peripheral.state == .poweredOn else {
|
||||
FileHandle.standardError.write("Bluetooth state \(peripheral.state.rawValue)\n".data(using: .utf8)!)
|
||||
if peripheral.state == .unauthorized || peripheral.state == .unsupported || peripheral.state == .poweredOff {
|
||||
FileHandle.standardError.write("Bluetooth unavailable (state \(peripheral.state.rawValue))\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
characteristic = CBMutableCharacteristic(type: measurement, properties: [.notify], value: nil, permissions: [])
|
||||
let service = CBMutableService(type: hrs, primary: true)
|
||||
service.characteristics = [characteristic]
|
||||
peripheral.add(service)
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
|
||||
if let error { fail("add service: \(error.localizedDescription)") }
|
||||
peripheral.startAdvertising([CBAdvertisementDataLocalNameKey: "FC Test Strap",
|
||||
CBAdvertisementDataServiceUUIDsKey: [hrs]])
|
||||
}
|
||||
|
||||
func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
|
||||
if let error { fail("advertise: \(error.localizedDescription)") }
|
||||
FileHandle.standardError.write("Advertising FC Test Strap\n".data(using: .utf8)!)
|
||||
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.tick() }
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
|
||||
subscribed = true
|
||||
FileHandle.standardError.write("Central subscribed\n".data(using: .utf8)!)
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
|
||||
subscribed = false
|
||||
FileHandle.standardError.write("Central unsubscribed\n".data(using: .utf8)!)
|
||||
}
|
||||
|
||||
func tick() {
|
||||
guard subscribed else { return }
|
||||
if sent >= seconds { exit(0) }
|
||||
let (bpm, flags) = packet(sent)
|
||||
var bytes: [UInt8] = [flags, UInt8(bpm & 0xff)]
|
||||
if flags & 1 != 0 { bytes.append(UInt8(bpm >> 8)) }
|
||||
if manager.updateValue(Data(bytes), for: characteristic, onSubscribedCentrals: nil) {
|
||||
print(String(format: "%.3f,%d,%d", Date().timeIntervalSince1970, bpm, flags))
|
||||
fflush(stdout)
|
||||
sent += 1
|
||||
}
|
||||
}
|
||||
|
||||
func fail(_ message: String) -> Never {
|
||||
FileHandle.standardError.write("\(message)\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
let seconds = CommandLine.arguments.count > 1 ? Int(CommandLine.arguments[1]) ?? 60 : 60
|
||||
let strap = Strap(seconds: seconds)
|
||||
RunLoop.main.run()
|
||||
@@ -59,9 +59,13 @@ colordepth=32
|
||||
quality=9
|
||||
viewonly=0
|
||||
showcursor=1
|
||||
scale=1
|
||||
viewmode=1
|
||||
window_maximize=1
|
||||
EOF
|
||||
echo \"wrote \$d/mac-screen-sharing.remmina\"
|
||||
"
|
||||
print "On the Mac: System Settings > General > Sharing > Screen Sharing (i) >"
|
||||
print " enable 'VNC viewers may control screen with password' and set one."
|
||||
print "Remmina may ask for your Mac account name + login password instead (Apple auth)."
|
||||
fi
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Hammerspoon: draw a ring around the Mac pointer so it shows in the VNC
|
||||
-- mirror on the Frame. macOS Screen Sharing leaves the pointer out of the
|
||||
-- framebuffer; a real on-screen window is captured like anything else.
|
||||
--
|
||||
-- Install: brew install --cask hammerspoon, then in ~/.hammerspoon/init.lua:
|
||||
-- dofile("/path/to/frame-control/scripts/mac-cursor-ring.lua")
|
||||
-- Toggle: ctrl+alt+cmd+M. Polls the pointer position, so no Accessibility
|
||||
-- permission is needed.
|
||||
|
||||
local SIZE, WIDTH = 34, 3
|
||||
local COLOR = { red = 1, green = 0.2, blue = 0.2, alpha = 0.9 }
|
||||
|
||||
local ring = hs.canvas.new({ x = 0, y = 0, w = SIZE, h = SIZE })
|
||||
ring:appendElements({
|
||||
type = "circle", action = "stroke",
|
||||
strokeColor = COLOR, strokeWidth = WIDTH,
|
||||
radius = (SIZE - WIDTH) / 2,
|
||||
})
|
||||
ring:level(hs.canvas.windowLevels.cursor)
|
||||
ring:behavior({ "canJoinAllSpaces", "stationary", "ignoresCycle" })
|
||||
|
||||
local last = {}
|
||||
local function follow()
|
||||
local p = hs.mouse.absolutePosition()
|
||||
if p.x ~= last.x or p.y ~= last.y then
|
||||
ring:topLeft({ x = p.x - SIZE / 2, y = p.y - SIZE / 2 })
|
||||
last = p
|
||||
end
|
||||
end
|
||||
|
||||
frameCursorRing = { canvas = ring, timer = hs.timer.new(1 / 60, follow) }
|
||||
|
||||
local function show() follow(); ring:show(); frameCursorRing.timer:start() end
|
||||
local function hide() frameCursorRing.timer:stop(); ring:hide() end
|
||||
|
||||
hs.hotkey.bind({ "ctrl", "alt", "cmd" }, "M", function()
|
||||
if ring:isShowing() then hide() else show() end
|
||||
end)
|
||||
|
||||
show()
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install or run our local-only tracking tools on the Frame.
|
||||
|
||||
python3 scripts/tracking-on-frame.py install
|
||||
python3 scripts/tracking-on-frame.py gaze --seconds 10
|
||||
python3 scripts/tracking-on-frame.py gaze --seconds 3600 --osc 127.0.0.1 9000
|
||||
python3 scripts/tracking-on-frame.py heart --device AA:BB:CC:DD:EE:FF --panel
|
||||
python3 scripts/tracking-on-frame.py pulse --seconds 60 --show # experimental
|
||||
|
||||
FRAME_ALIAS overrides the SSH alias (default: frame). Runs in the foreground;
|
||||
Ctrl-C stops the reader. No service, autostart, sudo or SteamVR settings changes.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
|
||||
REMOTE = '"$HOME/.local/share/frame-control/tracking"'
|
||||
INSTALL = '''set -eu
|
||||
base="$HOME/.local/share/frame-control/tracking"
|
||||
mkdir -p "$base"
|
||||
stage=$(mktemp -d "$base/.install.XXXXXX")
|
||||
trap 'rm -rf "$stage"' EXIT
|
||||
tar -xf - -C "$stage"
|
||||
cc -O2 -Wall -Wextra -Werror "$stage/gaze.c" \\
|
||||
-L/opt/steamvr/bin/linuxarm64 -Wl,-rpath,/opt/steamvr/bin/linuxarm64 \\
|
||||
-lopenxr_loader -o "$stage/gaze"
|
||||
chmod 700 "$stage/gaze" "$stage/tracking.py"
|
||||
chmod 600 "$stage/pulse.py"
|
||||
mv "$stage/gaze" "$stage/tracking.py" "$stage/pulse.py" "$base/"
|
||||
echo 'Installed Frame Control tracking tools (no service started).'
|
||||
'''
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in ("install", "gaze", "heart", "pulse"):
|
||||
print(__doc__)
|
||||
return 2
|
||||
host = os.environ.get("FRAME_ALIAS", "frame")
|
||||
if not host or host.startswith("-"):
|
||||
raise ValueError("invalid SSH alias")
|
||||
ssh = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", host]
|
||||
if sys.argv[1] == "install":
|
||||
import tempfile
|
||||
source = Path(__file__).resolve().parents[1] / "frame" / "tracking"
|
||||
with tempfile.TemporaryFile() as archive:
|
||||
with tarfile.open(fileobj=archive, mode="w") as tar:
|
||||
for name in ("gaze.c", "tracking.py", "pulse.py"):
|
||||
tar.add(source / name, arcname=name)
|
||||
archive.seek(0)
|
||||
return subprocess.call(ssh + ["bash -c " + shlex.quote(INSTALL)], stdin=archive)
|
||||
# Allocate a tty so SSH forwards Ctrl-C and hangup to the foreground process.
|
||||
command = 'exec python3 ' + REMOTE + '/tracking.py ' + shlex.join(sys.argv[1:])
|
||||
return subprocess.call(ssh[:-1] + ["-tt", host, command])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
@@ -0,0 +1,35 @@
|
||||
# Website
|
||||
|
||||
The Frame Control website, <https://frame-control.pages.dev>, on Cloudflare Pages.
|
||||
|
||||
- `public/`: static pages. `/` is the landing page, `/feedback/` the feedback form, `/privacy/` the privacy note.
|
||||
- `functions/api/feedback.js`: `POST /api/feedback`, which turns the form into a GitHub issue labelled `feedback`.
|
||||
- `lib/feedback.js`: validation and issue formatting, tested by `test/feedback.test.mjs`.
|
||||
- `public/js/site.js`: settings, including the Ko-fi page name for the donate buttons.
|
||||
|
||||
## Feedback → GitHub issues
|
||||
|
||||
The function needs a `GITHUB_TOKEN` secret: a fine-grained token with **Issues: read and write** on
|
||||
`saphid/frame-control` only. Issues are opened as the token's owner, so they pass the contributor gate
|
||||
(`.github/workflows/issue-gate.yml`) and stay open. Without the token the form answers 503 and offers a
|
||||
prefilled GitHub issue instead.
|
||||
|
||||
```sh
|
||||
cd site
|
||||
npx wrangler pages secret put GITHUB_TOKEN --project-name frame-control
|
||||
```
|
||||
|
||||
Spam protection: a hidden honeypot field, a 3-second minimum fill time, 5 submissions per hour per IP
|
||||
(a salted hash, kept in the `FEEDBACK_RL` KV namespace for about an hour), and 100 a day in total.
|
||||
User text has `@mentions` and `#123` references broken so nobody gets pinged.
|
||||
|
||||
## Run and deploy
|
||||
|
||||
```sh
|
||||
cd site
|
||||
node --test test/*.test.mjs
|
||||
npx wrangler pages dev --port 8788 # local; put GITHUB_TOKEN/GITHUB_REPO in .dev.vars to test issues
|
||||
npx wrangler pages deploy --branch main # production
|
||||
```
|
||||
|
||||
Point `GITHUB_REPO` in `.dev.vars` at a scratch repo when testing locally so test issues don't land on the real tracker.
|
||||
@@ -0,0 +1,86 @@
|
||||
// POST /api/feedback: turns the website's feedback form into a GitHub issue.
|
||||
//
|
||||
// Environment (Cloudflare Pages → Settings → Variables and Secrets):
|
||||
// GITHUB_TOKEN secret. Fine-grained token with Issues: read and write on GITHUB_REPO only.
|
||||
// GITHUB_REPO owner/name, e.g. saphid/frame-control (wrangler.toml sets it).
|
||||
// FEEDBACK_RL KV namespace binding for rate limits (optional; without it there is no limit).
|
||||
|
||||
import { buildIssue, hashIp, validate } from "../../lib/feedback.js";
|
||||
|
||||
const PER_IP_PER_HOUR = 5;
|
||||
const TOTAL_PER_DAY = 100;
|
||||
|
||||
const json = (status, data) =>
|
||||
new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
|
||||
});
|
||||
|
||||
async function overLimit(kv, key, limit, ttl) {
|
||||
const count = Number(await kv.get(key)) || 0;
|
||||
if (count >= limit) return true;
|
||||
await kv.put(key, String(count + 1), { expirationTtl: ttl });
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function onRequestPost({ request, env }) {
|
||||
if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
|
||||
return json(503, { error: "Feedback isn't connected to GitHub yet. Use the GitHub link instead." });
|
||||
}
|
||||
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin && new URL(origin).host !== new URL(request.url).host) {
|
||||
return json(403, { error: "Send feedback from the website's form." });
|
||||
}
|
||||
|
||||
let input;
|
||||
try {
|
||||
input = await request.json();
|
||||
} catch {
|
||||
return json(400, { error: "Send the form as JSON." });
|
||||
}
|
||||
|
||||
const checked = validate(input);
|
||||
// Bots get a success-shaped answer so they don't learn what tripped them.
|
||||
if (checked.spam) return json(200, { ok: true });
|
||||
if (checked.error) return json(400, { error: checked.error });
|
||||
|
||||
// Best effort: KV is eventually consistent, so bursts can slip past, and a
|
||||
// storage error lets the feedback through rather than losing it.
|
||||
if (env.FEEDBACK_RL) try {
|
||||
const ip = request.headers.get("cf-connecting-ip") || "unknown";
|
||||
const hour = Math.floor(Date.now() / 3600e3);
|
||||
const day = Math.floor(Date.now() / 86400e3);
|
||||
// Salted with the secret token, so the stored hashes can't be reversed by trying every IP.
|
||||
const who = await hashIp(ip, env.GITHUB_TOKEN);
|
||||
if (await overLimit(env.FEEDBACK_RL, `ip:${who}:${hour}`, PER_IP_PER_HOUR, 3900)) {
|
||||
return json(429, { error: "That's a lot of feedback in one hour. Try again later, or use GitHub." });
|
||||
}
|
||||
if (await overLimit(env.FEEDBACK_RL, `day:${day}`, TOTAL_PER_DAY, 90000)) {
|
||||
return json(429, { error: "The form has had a busy day. Try again tomorrow, or use GitHub." });
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`Rate limit check failed: ${err}`);
|
||||
}
|
||||
|
||||
const res = await fetch(`https://api.github.com/repos/${env.GITHUB_REPO}/issues`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${env.GITHUB_TOKEN}`,
|
||||
accept: "application/vnd.github+json",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
"user-agent": "frame-control-website",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(buildIssue(checked.value)),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.log(`GitHub answered ${res.status}: ${(await res.text()).slice(0, 500)}`);
|
||||
return json(502, { error: "GitHub didn't accept it just now. Try again, or use the GitHub link." });
|
||||
}
|
||||
const issue = await res.json();
|
||||
return json(201, { ok: true, number: issue.number, url: issue.html_url });
|
||||
}
|
||||
|
||||
export const onRequest = () => json(405, { error: "POST only." });
|
||||
@@ -0,0 +1,102 @@
|
||||
// Feedback form → GitHub issue. Pure functions, so tests can run them without
|
||||
// Cloudflare or GitHub (site/test/feedback.test.mjs).
|
||||
|
||||
export const KINDS = {
|
||||
bug: { label: "bug", title: "Bug report" },
|
||||
idea: { label: "enhancement", title: "Idea" },
|
||||
question: { label: "question", title: "Question" },
|
||||
other: { label: null, title: "Other feedback" },
|
||||
};
|
||||
|
||||
export const LIMITS = { title: [5, 120], message: [10, 5000], field: 120 };
|
||||
|
||||
// Anyone who fills the form in under this many milliseconds is a script.
|
||||
export const MIN_FILL_MS = 3000;
|
||||
|
||||
const GITHUB_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
|
||||
|
||||
const oneLine = (value, max) => String(value ?? "").replace(/\s+/g, " ").trim().slice(0, max);
|
||||
|
||||
// Mentions in someone else's text would ping strangers, and issue references
|
||||
// (#1, owner/repo#1, GH-1, github.com links) would add backlinks to other
|
||||
// people's issues, so break them all with a zero-width space. Escaping & first
|
||||
// stops @ and # from turning back into @ and # when GitHub renders,
|
||||
// escaping < keeps out raw HTML such as an unclosed <!-- comment, and doubling
|
||||
// backslashes stops GH\-1 or github\.com from being unescaped back into references.
|
||||
const ZWSP = "\u200b";
|
||||
export function defang(text) {
|
||||
return text
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/@(?=[A-Za-z0-9])/g, `@${ZWSP}`)
|
||||
.replace(/#(?=\d)/g, `#${ZWSP}`)
|
||||
.replace(/\b(GH)-(?=\d)/gi, `$1${ZWSP}-`)
|
||||
.replace(/\b(github)\.com/gi, `$1${ZWSP}.com`);
|
||||
}
|
||||
|
||||
// Returns { error } or { value } with every field trimmed and bounded.
|
||||
export function validate(input) {
|
||||
if (!input || typeof input !== "object") return { error: "Send the form as JSON." };
|
||||
if (oneLine(input.website, 200)) return { spam: true };
|
||||
// Measured in the browser with a monotonic clock, so clock skew doesn't matter.
|
||||
const elapsed = Number(input.elapsed);
|
||||
if (!Number.isFinite(elapsed)) return { spam: true };
|
||||
// The page waits this long before sending, so only scripts get here; say so anyway.
|
||||
if (elapsed < MIN_FILL_MS) return { error: "That was quick. Send it again in a moment." };
|
||||
|
||||
const kind = Object.hasOwn(KINDS, input.kind) ? input.kind : "other";
|
||||
const title = oneLine(input.title, LIMITS.title[1]);
|
||||
const message = String(input.message ?? "").replace(/\r\n?/g, "\n").trim();
|
||||
if (title.length < LIMITS.title[0]) return { error: "Give it a short title (at least 5 characters)." };
|
||||
if (message.length < LIMITS.message[0]) return { error: "Tell us a little more (at least 10 characters)." };
|
||||
if (message.length > LIMITS.message[1]) return { error: `Keep it under ${LIMITS.message[1]} characters.` };
|
||||
|
||||
const github = oneLine(input.github, 40).replace(/^@/, "");
|
||||
if (github && !GITHUB_LOGIN.test(github)) return { error: "That doesn't look like a GitHub username." };
|
||||
|
||||
return {
|
||||
value: {
|
||||
kind,
|
||||
title,
|
||||
message,
|
||||
github,
|
||||
version: oneLine(input.version, LIMITS.field),
|
||||
os: oneLine(input.os, LIMITS.field),
|
||||
steamos: oneLine(input.steamos, LIMITS.field),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildIssue(value) {
|
||||
const kind = KINDS[value.kind];
|
||||
const details = [
|
||||
["Frame Control version", value.version],
|
||||
["Computer", value.os],
|
||||
["SteamOS build", value.steamos],
|
||||
].filter(([, v]) => v);
|
||||
|
||||
// Our own lines go first, so nothing in the sender's text can hide them.
|
||||
const lines = [
|
||||
value.github
|
||||
? `> Sent from the website feedback form by @${value.github}.`
|
||||
: "> Sent from the website feedback form. The sender left no GitHub username, so they won't see replies here.",
|
||||
"",
|
||||
];
|
||||
if (details.length) {
|
||||
lines.push("| | |", "|---|---|", ...details.map(([k, v]) => `| ${k} | ${defang(v).replace(/\|/g, "\\|")} |`), "");
|
||||
}
|
||||
lines.push(defang(value.message));
|
||||
|
||||
return {
|
||||
title: `${kind.title}: ${value.title}`,
|
||||
body: lines.join("\n"),
|
||||
labels: ["feedback", ...(kind.label ? [kind.label] : [])],
|
||||
};
|
||||
}
|
||||
|
||||
export async function hashIp(ip, salt) {
|
||||
const bytes = new TextEncoder().encode(`${salt}:${ip}`);
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return [...new Uint8Array(digest)].slice(0, 12).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "type": "module", "private": true }
|
||||
@@ -0,0 +1,60 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Not found · Frame Control</title>
|
||||
<meta name="description" content="Page not found.">
|
||||
<meta name="theme-color" content="#0e141b">
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/css/site.css">
|
||||
<script src="/js/site.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="top">
|
||||
<div class="wrap">
|
||||
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
|
||||
<nav aria-label="Sections">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/#setup">Setup</a>
|
||||
<a href="/#download">Download</a>
|
||||
<a href="/#faq">FAQ</a>
|
||||
<a href="/feedback/">Feedback</a>
|
||||
</nav>
|
||||
<div class="end">
|
||||
<a class="btn small ghost" href="https://github.com/saphid/frame-control">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<a class="btn small coffee" data-kofi href="#" target="_blank" rel="noopener">Support</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
<div class="wrap" style="text-align:center;padding:80px 24px">
|
||||
<span class="kicker">404</span>
|
||||
<h1>That page isn't here</h1>
|
||||
<p style="color:var(--muted);margin-top:14px">It may have moved. Try the home page, or tell us what you were looking for.</p>
|
||||
<div class="cta"><a class="btn primary" href="/">Home</a><a class="btn ghost" href="/feedback/">Send feedback</a></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">
|
||||
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
|
||||
<div class="cols">
|
||||
<a href="https://github.com/saphid/frame-control">GitHub</a>
|
||||
<a href="https://github.com/saphid/frame-control/releases">Releases</a>
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/docs/frame-control.md">Docs</a>
|
||||
<a href="/feedback/">Feedback</a>
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">Contributing</a>
|
||||
<a href="/privacy/">Privacy</a>
|
||||
</div>
|
||||
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
X-Frame-Options: DENY
|
||||
Permissions-Policy: camera=(), microphone=(), geolocation=()
|
||||
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self'; connect-src 'self' https://api.github.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
|
||||
|
||||
/css/*
|
||||
Cache-Control: public, max-age=3600
|
||||
/js/*
|
||||
Cache-Control: public, max-age=3600
|
||||
/img/*
|
||||
Cache-Control: public, max-age=86400
|
||||
/media/*
|
||||
Cache-Control: public, max-age=604800
|
||||
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,201 @@
|
||||
/* Frame Control website. Colours follow the app (ui/index.html): Steam's navy and blue. */
|
||||
:root {
|
||||
--bg: #0e141b; --bg-2: #131c26; --panel: #17222e; --panel-2: #1d2a38; --line: rgba(143, 152, 160, .16);
|
||||
--text: #c7d0d8; --bright: #fff; --muted: #8f98a0; --dim: #5e6873;
|
||||
--blue: #1a9fff; --link: #66c0f4; --green: #75b022; --warn: #d9a23a; --bad: #e0573c;
|
||||
--action: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
|
||||
--action-hi: linear-gradient(to right, #5fcaff 5%, #2458d6 95%);
|
||||
--radius: 14px; --wrap: 1160px;
|
||||
--font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; scroll-padding-top: 80px; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.6 var(--font); -webkit-font-smoothing: antialiased; }
|
||||
img, video { max-width: 100%; height: auto; display: block; }
|
||||
a { color: var(--link); text-decoration: none; }
|
||||
a:hover { color: var(--bright); }
|
||||
::selection { background: var(--blue); color: #fff; }
|
||||
:focus-visible { outline: 2px solid var(--link); outline-offset: 3px; border-radius: 4px; }
|
||||
h1, h2, h3 { color: var(--bright); line-height: 1.15; margin: 0; letter-spacing: -.02em; }
|
||||
h1 { font-size: clamp(38px, 6vw, 64px); font-weight: 800; }
|
||||
h2 { font-size: clamp(28px, 3.6vw, 40px); font-weight: 750; }
|
||||
h3 { font-size: 18px; font-weight: 650; letter-spacing: -.01em; }
|
||||
p { margin: 0; }
|
||||
code { font: 14px ui-monospace, SFMono-Regular, Menlo, monospace; background: rgba(255,255,255,.06); padding: 2px 6px; border-radius: 5px; color: var(--bright); }
|
||||
.wrap { max-width: var(--wrap); margin: 0 auto; padding: 0 24px; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* ---- header ---- */
|
||||
.top { position: sticky; top: 0; z-index: 20; background: rgba(14, 20, 27, .78); backdrop-filter: saturate(160%) blur(14px);
|
||||
-webkit-backdrop-filter: saturate(160%) blur(14px); border-bottom: 1px solid var(--line); }
|
||||
.top .wrap { display: flex; align-items: center; gap: 28px; height: 64px; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; color: var(--bright); font-weight: 700; letter-spacing: 2.2px; font-size: 14px; text-transform: uppercase; }
|
||||
.brand { white-space: nowrap; }
|
||||
.brand img { width: 30px; height: 30px; }
|
||||
.top nav { display: flex; gap: 22px; margin-left: 8px; }
|
||||
.top nav a { color: var(--muted); font-size: 14.5px; font-weight: 500; }
|
||||
.top nav a:hover, .top nav a[aria-current] { color: var(--bright); }
|
||||
.top .end { margin-left: auto; display: flex; gap: 10px; align-items: center; }
|
||||
@media (max-width: 880px) { .top nav { display: none; } }
|
||||
@media (max-width: 480px) { .top .end .ghost { display: none; } }
|
||||
|
||||
/* ---- buttons ---- */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 9px; height: 46px; padding: 0 22px; border-radius: 10px;
|
||||
font: 600 15.5px var(--font); color: var(--bright); background: rgba(103, 112, 123, .22); border: 1px solid transparent;
|
||||
cursor: pointer; transition: background .15s, transform .15s, box-shadow .15s; white-space: nowrap; }
|
||||
.btn:hover { background: rgba(103, 112, 123, .4); color: var(--bright); }
|
||||
.btn.primary { background: var(--action); box-shadow: 0 8px 28px rgba(26, 159, 255, .28); }
|
||||
.btn.primary:hover { background: var(--action-hi); transform: translateY(-1px); }
|
||||
/* The background shorthand resets this; without it the gradient repeats under the transparent border. */
|
||||
.btn.primary, .btn.primary:hover { background-origin: border-box; }
|
||||
.btn.ghost { background: transparent; border-color: var(--line); }
|
||||
.btn.ghost:hover { border-color: rgba(143,152,160,.4); background: rgba(255,255,255,.03); }
|
||||
.btn.small { height: 36px; padding: 0 14px; font-size: 14px; border-radius: 8px; }
|
||||
.btn.coffee { background: #ff5e5b; box-shadow: 0 8px 24px rgba(255, 94, 91, .22); }
|
||||
.btn.coffee:hover { background: #ff7471; }
|
||||
.btn svg { width: 18px; height: 18px; flex: none; }
|
||||
.btn[disabled] { opacity: .6; cursor: progress; transform: none; }
|
||||
|
||||
/* ---- hero ---- */
|
||||
.hero { position: relative; padding: 88px 0 40px; overflow: hidden; text-align: center; }
|
||||
.hero::before { content: ""; position: absolute; inset: -30% -10% auto; height: 900px; pointer-events: none;
|
||||
background: radial-gradient(600px 380px at 30% 30%, rgba(26,159,255,.22), transparent 70%),
|
||||
radial-gradient(520px 360px at 72% 20%, rgba(111,66,193,.2), transparent 70%); }
|
||||
.hero > * { position: relative; }
|
||||
.eyebrow { display: inline-block; max-width: 100%; padding: 6px 14px; border-radius: 999px; font-size: 13.5px;
|
||||
color: var(--link); background: rgba(26,159,255,.1); border: 1px solid rgba(102,192,244,.22); margin-bottom: 26px; }
|
||||
.eyebrow b { color: var(--bright); font-weight: 600; }
|
||||
.eyebrow .plats { white-space: nowrap; }
|
||||
@media (max-width: 520px) { .eyebrow { border-radius: 16px; } .eyebrow .sep { display: none; } .eyebrow .plats { display: block; white-space: normal; } }
|
||||
.hero h1 { max-width: 880px; margin: 0 auto; }
|
||||
.hero h1 span { background: linear-gradient(90deg, #66c0f4, #1a9fff 45%, #8a6cff); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
||||
.lede { max-width: 680px; margin: 22px auto 0; font-size: 19px; color: var(--text); }
|
||||
.cta { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px; margin-top: 34px; }
|
||||
.fine { margin-top: 16px; font-size: 13.5px; color: var(--muted); }
|
||||
.fine a { color: var(--muted); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.shot { margin: 64px auto 0; max-width: 1080px; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line);
|
||||
box-shadow: 0 50px 120px rgba(0,0,0,.55), 0 0 0 1px rgba(255,255,255,.02), 0 0 120px rgba(26,159,255,.12); }
|
||||
.shot .bar { display: flex; gap: 7px; padding: 12px 14px; background: #10171f; border-bottom: 1px solid var(--line); }
|
||||
.shot .bar i { width: 11px; height: 11px; border-radius: 50%; background: #2a3440; }
|
||||
|
||||
/* ---- sections ---- */
|
||||
section { padding: 96px 0; }
|
||||
section.alt { background: var(--bg-2); border-block: 1px solid var(--line); }
|
||||
.head { max-width: 700px; margin: 0 auto 52px; text-align: center; }
|
||||
.kicker { display: block; color: var(--link); font-size: 13px; font-weight: 650; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 12px; }
|
||||
.head p { margin-top: 14px; font-size: 17.5px; color: var(--muted); }
|
||||
|
||||
.video { max-width: 1000px; margin: 0 auto; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #000;
|
||||
box-shadow: 0 40px 100px rgba(0,0,0,.5); }
|
||||
.video video { width: 100%; aspect-ratio: 16 / 9; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; }
|
||||
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 24px; transition: border-color .2s, transform .2s; }
|
||||
.card:hover { border-color: rgba(102,192,244,.3); transform: translateY(-2px); }
|
||||
.card .ico { width: 42px; height: 42px; border-radius: 10px; display: grid; place-items: center; margin-bottom: 16px;
|
||||
background: rgba(26,159,255,.12); color: var(--link); }
|
||||
.card .ico svg { width: 22px; height: 22px; }
|
||||
.card p { margin-top: 8px; color: var(--muted); font-size: 15px; }
|
||||
|
||||
.feature { display: grid; grid-template-columns: 1fr 1.35fr; gap: 56px; align-items: center; }
|
||||
.feature + .feature { margin-top: 96px; }
|
||||
.feature.flip { grid-template-columns: 1.35fr 1fr; }
|
||||
.feature.flip .copy { order: 2; }
|
||||
.feature .copy h2 { margin-top: 0; }
|
||||
.feature .copy p { margin-top: 16px; color: var(--muted); font-size: 17px; }
|
||||
.feature ul { margin: 20px 0 0; padding: 0; list-style: none; display: grid; gap: 10px; }
|
||||
.feature li { padding-left: 28px; position: relative; color: var(--text); }
|
||||
.feature li::before { content: ""; position: absolute; left: 2px; top: 7px; width: 14px; height: 8px; border-left: 2px solid var(--link);
|
||||
border-bottom: 2px solid var(--link); transform: rotate(-45deg); }
|
||||
.feature img { border-radius: 12px; border: 1px solid var(--line); box-shadow: 0 30px 80px rgba(0,0,0,.45); }
|
||||
@media (max-width: 880px) { .feature, .feature.flip { grid-template-columns: 1fr; gap: 28px; } .feature.flip .copy { order: 0; } }
|
||||
|
||||
.phones { display: flex; justify-content: center; gap: 28px; margin-top: 8px; }
|
||||
.phones img { width: 260px; border-radius: 34px; border: 8px solid #0a0f15; box-shadow: 0 30px 80px rgba(0,0,0,.5), 0 0 0 1px var(--line); }
|
||||
.phones img:last-child { transform: translateY(40px); }
|
||||
@media (max-width: 600px) { .phones img { width: 44%; border-width: 5px; border-radius: 22px; } }
|
||||
|
||||
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; counter-reset: step; }
|
||||
.steps .card { position: relative; padding-top: 64px; }
|
||||
.steps .card::before { counter-increment: step; content: counter(step); position: absolute; top: 22px; left: 24px; width: 30px; height: 30px;
|
||||
border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 14px; color: var(--bright); background: var(--action); }
|
||||
@media (max-width: 880px) { .steps { grid-template-columns: 1fr; } }
|
||||
|
||||
.downloads { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
|
||||
.dl { display: flex; flex-direction: column; gap: 6px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 26px; }
|
||||
.dl.mine { border-color: rgba(26,159,255,.55); box-shadow: 0 0 0 1px rgba(26,159,255,.25), 0 20px 50px rgba(26,159,255,.1); }
|
||||
.dl .os { display: flex; align-items: center; gap: 10px; }
|
||||
.dl .os svg { width: 24px; height: 24px; color: var(--bright); }
|
||||
.dl .need { color: var(--muted); font-size: 14px; }
|
||||
.dl .links { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
|
||||
.dl .links .btn { width: 100%; }
|
||||
.dl .tag { margin-left: auto; font-size: 11.5px; font-weight: 650; letter-spacing: 1px; text-transform: uppercase; color: var(--link); }
|
||||
|
||||
.faq { max-width: 780px; margin: 0 auto; display: grid; gap: 10px; }
|
||||
.faq details { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 0 22px; }
|
||||
.faq summary { cursor: pointer; list-style: none; padding: 18px 0; color: var(--bright); font-weight: 600; display: flex; justify-content: space-between; gap: 16px; }
|
||||
.faq summary::-webkit-details-marker { display: none; }
|
||||
.faq summary::after { content: "+"; color: var(--muted); font-size: 22px; line-height: 1; transition: transform .2s; }
|
||||
.faq details[open] summary::after { transform: rotate(45deg); }
|
||||
.faq details > div { padding: 0 0 20px; color: var(--muted); }
|
||||
.faq details > div p + p { margin-top: 10px; }
|
||||
.faq.notes section { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 18px 22px 20px; color: var(--muted); }
|
||||
.faq.notes h2 { font-size: 16px; font-weight: 600; letter-spacing: 0; color: var(--bright); margin: 0 0 10px; }
|
||||
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.split .card { padding: 36px; }
|
||||
.split .card h3 { font-size: 24px; }
|
||||
.split .card p { font-size: 16px; margin: 12px 0 24px; }
|
||||
@media (max-width: 760px) { .split { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ---- footer ---- */
|
||||
footer { border-top: 1px solid var(--line); padding: 48px 0 56px; color: var(--dim); font-size: 14px; }
|
||||
footer .wrap { display: flex; flex-wrap: wrap; gap: 24px 48px; justify-content: space-between; }
|
||||
footer .cols { display: flex; gap: 12px 28px; flex-wrap: wrap; }
|
||||
footer a { color: var(--muted); }
|
||||
footer .legal { flex-basis: 100%; font-size: 13px; }
|
||||
|
||||
/* ---- feedback page ---- */
|
||||
.page { padding: 72px 0 96px; }
|
||||
.page .head { text-align: left; margin: 0 0 36px; max-width: none; }
|
||||
.form-wrap { display: grid; grid-template-columns: 1fr 320px; gap: 28px; align-items: start; }
|
||||
@media (max-width: 900px) { .form-wrap { grid-template-columns: 1fr; } }
|
||||
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 30px; }
|
||||
aside.panel h3 { margin-bottom: 10px; }
|
||||
aside.panel p { color: var(--muted); font-size: 15px; }
|
||||
aside.panel p + h3 { margin-top: 26px; }
|
||||
.field { display: grid; gap: 8px; margin-bottom: 22px; }
|
||||
.field > label, .field > legend { color: var(--bright); font-weight: 600; font-size: 14.5px; padding: 0; }
|
||||
.field > legend { margin-bottom: 10px; } /* fieldset grids ignore gap for the legend */
|
||||
.field small { color: var(--muted); font-size: 13px; font-weight: 400; }
|
||||
.row3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||
@media (max-width: 640px) { .row3 { grid-template-columns: 1fr; } }
|
||||
fieldset { border: 0; padding: 0; margin: 0 0 22px; min-width: 0; }
|
||||
input[type=text], textarea { width: 100%; font: 15.5px/1.5 var(--font); color: var(--bright); background: #0f161e; border: 1px solid rgba(143,152,160,.22);
|
||||
border-radius: 9px; padding: 11px 13px; transition: border-color .15s, box-shadow .15s; }
|
||||
input[type=text]::placeholder, textarea::placeholder { color: var(--dim); }
|
||||
input[type=text]:focus, textarea:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px rgba(26,159,255,.2); }
|
||||
textarea { min-height: 180px; resize: vertical; }
|
||||
.kinds { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||
@media (max-width: 640px) { .kinds { grid-template-columns: repeat(2, 1fr); } }
|
||||
.kinds label { position: relative; cursor: pointer; }
|
||||
.kinds input { position: absolute; opacity: 0; inset: 0; pointer-events: none; }
|
||||
.kinds span { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 14px 8px; border-radius: 10px; font-size: 14px; font-weight: 600;
|
||||
color: var(--muted); background: #0f161e; border: 1px solid rgba(143,152,160,.18); transition: all .15s; }
|
||||
.kinds span svg { width: 20px; height: 20px; }
|
||||
.kinds input:checked + span { color: var(--bright); border-color: var(--blue); background: rgba(26,159,255,.1); }
|
||||
.kinds input:focus-visible + span { outline: 2px solid var(--link); outline-offset: 2px; }
|
||||
.trap { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
|
||||
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-top: 8px; }
|
||||
.note { color: var(--muted); font-size: 13.5px; }
|
||||
.alert { border-radius: 10px; padding: 14px 16px; margin-bottom: 20px; font-size: 15px; }
|
||||
.alert.err { background: rgba(224,87,60,.12); border: 1px solid rgba(224,87,60,.4); color: #ffb4a6; }
|
||||
.alert.err a { color: #ffd2c9; text-decoration: underline; }
|
||||
.done { text-align: center; padding: 40px 10px; }
|
||||
.done .tick { width: 64px; height: 64px; border-radius: 50%; margin: 0 auto 20px; display: grid; place-items: center; background: rgba(117,176,34,.15); color: #a4d007; }
|
||||
.done .tick svg { width: 30px; height: 30px; }
|
||||
.done p { color: var(--muted); margin: 10px auto 24px; max-width: 460px; }
|
||||
.done .cta { margin-top: 0; }
|
||||
.page h1 { font-size: clamp(34px, 5vw, 48px); }
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,143 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Send feedback · Frame Control</title>
|
||||
<meta name="description" content="Report a bug, suggest an idea or ask a question about Frame Control. No GitHub account needed.">
|
||||
<meta name="theme-color" content="#0e141b">
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/css/site.css">
|
||||
<script src="/js/site.js" defer></script>
|
||||
<script src="/js/feedback.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="top">
|
||||
<div class="wrap">
|
||||
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
|
||||
<nav aria-label="Sections">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/#setup">Setup</a>
|
||||
<a href="/#download">Download</a>
|
||||
<a href="/#faq">FAQ</a>
|
||||
<a href="/feedback/" aria-current="page">Feedback</a>
|
||||
</nav>
|
||||
<div class="end">
|
||||
<a class="btn small ghost" href="https://github.com/saphid/frame-control">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<a class="btn small coffee" data-kofi href="#" target="_blank" rel="noopener">Support</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
<div class="wrap">
|
||||
<div class="head">
|
||||
<span class="kicker">Feedback</span>
|
||||
<h1>Tell us what happened</h1>
|
||||
<p>Bugs, ideas and questions all help. What you send becomes a public issue on GitHub, where you can follow it.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-wrap">
|
||||
<div>
|
||||
<form class="panel" id="feedback" novalidate>
|
||||
<div class="alert err" id="error" role="alert" hidden></div>
|
||||
|
||||
<fieldset class="field">
|
||||
<legend>What kind of feedback?</legend>
|
||||
<div class="kinds">
|
||||
<label><input type="radio" name="kind" value="bug" checked><span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2l1.9 1.9M16 2l-1.9 1.9M9 7.1V6a3 3 0 1 1 6 0v1.1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6zM12 20v-9M6.5 13H3M21 13h-3.5M6 9 3.5 7M18 9l2.5-2M6 17l-2.5 2M18 17l2.5 2"/></svg>Bug</span></label>
|
||||
<label><input type="radio" name="kind" value="idea"><span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7V16h8v-1.3A7 7 0 0 0 12 2z"/></svg>Idea</span></label>
|
||||
<label><input type="radio" name="kind" value="question"><span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01"/></svg>Question</span></label>
|
||||
<label><input type="radio" name="kind" value="other"><span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z"/></svg>Other</span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="field">
|
||||
<label for="title">Title</label>
|
||||
<input type="text" id="title" name="title" maxlength="120" required placeholder="e.g. Live view stops after a minute on Windows">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="message">Details <small id="message-hint">What you tried, what happened, and what you expected.</small></label>
|
||||
<textarea id="message" name="message" maxlength="5000" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row3">
|
||||
<div class="field">
|
||||
<label for="os">Your computer</label>
|
||||
<input type="text" id="os" name="os" maxlength="120" placeholder="e.g. Windows 11">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="version">App version</label>
|
||||
<input type="text" id="version" name="version" maxlength="120" placeholder="e.g. v0.3.1">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="steamos">SteamOS build</label>
|
||||
<input type="text" id="steamos" name="steamos" maxlength="120" placeholder="Steam Settings → System">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="github">GitHub username <small>Optional. Add it to get notified when someone replies.</small></label>
|
||||
<input type="text" id="github" name="github" maxlength="40" autocomplete="username" placeholder="@yourname">
|
||||
</div>
|
||||
|
||||
<div class="trap" aria-hidden="true">
|
||||
<label for="website">Leave this empty</label>
|
||||
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn primary" type="submit" id="send">Send feedback</button>
|
||||
<span class="note">Everything you send is public. Don't include passwords, IP addresses or personal details.</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="panel done" id="done" hidden>
|
||||
<div class="tick"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 5 5L20 7"/></svg></div>
|
||||
<h2>Thanks, that's sent</h2>
|
||||
<p id="done-text">Your feedback is now an issue on GitHub.</p>
|
||||
<div class="cta">
|
||||
<a class="btn primary" id="issue-link" href="https://github.com/saphid/frame-control/issues" hidden>View your issue</a>
|
||||
<a class="btn ghost" href="/feedback/">Send more</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="panel">
|
||||
<h3>Reporting a bug?</h3>
|
||||
<p>The server log helps most: in the app, choose <b>Frame → Show Server Log</b> and paste the last few lines into Details.</p>
|
||||
<h3>Prefer GitHub?</h3>
|
||||
<p>You can <a href="https://github.com/saphid/frame-control/issues/new/choose">open an issue there directly</a>. First-time issues are closed until a maintainer reviews them; see <a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">CONTRIBUTING.md</a>. This form skips that queue.</p>
|
||||
<h3 data-needs-kofi>Enjoying it?</h3>
|
||||
<p data-needs-kofi>Frame Control is free. <a data-kofi href="#" target="_blank" rel="noopener">Buy me a coffee</a> if it saved you some time.</p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">
|
||||
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
|
||||
<div class="cols">
|
||||
<a href="https://github.com/saphid/frame-control">GitHub</a>
|
||||
<a href="https://github.com/saphid/frame-control/releases">Releases</a>
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/docs/frame-control.md">Docs</a>
|
||||
<a href="/feedback/">Feedback</a>
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">Contributing</a>
|
||||
<a href="/privacy/">Privacy</a>
|
||||
</div>
|
||||
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 94 KiB |
@@ -0,0 +1,313 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Frame Control: manage your Steam Frame from your computer</title>
|
||||
<meta name="description" content="Free, open-source app for the Valve Steam Frame. See what the headset sees, install Steam games and Android apps, move files and text across, and check battery and status. macOS, Windows, Linux and iPhone.">
|
||||
<meta name="theme-color" content="#0e141b">
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Frame Control">
|
||||
<meta property="og:description" content="Manage your Valve Steam Frame from your computer. Free and open source.">
|
||||
<meta property="og:image" content="/img/og.jpg">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="stylesheet" href="/css/site.css">
|
||||
<script src="/js/site.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="top">
|
||||
<div class="wrap">
|
||||
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
|
||||
<nav aria-label="Sections">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#setup">Setup</a>
|
||||
<a href="#download">Download</a>
|
||||
<a href="#faq">FAQ</a>
|
||||
<a href="/feedback/">Feedback</a>
|
||||
</nav>
|
||||
<div class="end">
|
||||
<a class="btn small ghost" href="https://github.com/saphid/frame-control">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<a class="btn small coffee" data-kofi href="#" target="_blank" rel="noopener">Support</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div class="wrap">
|
||||
<span class="eyebrow"><b>Free and open source</b><span class="sep"> · </span><span class="plats">macOS · Windows · Linux · iPhone</span></span>
|
||||
<h1>Your Steam Frame, <span>managed from your desk.</span></h1>
|
||||
<p class="lede">See what the headset sees, install games and Android apps, move files and text across, and keep an eye on battery and status. All over SSH, with nothing to install on the Frame.</p>
|
||||
<div class="cta">
|
||||
<a class="btn primary" id="hero-download" href="#download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m0 0-5-5m5 5 5-5M4 20h16"/></svg>
|
||||
<span data-label>Download Frame Control</span>
|
||||
</a>
|
||||
<a class="btn ghost" href="#trailer">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg>
|
||||
Watch the trailer
|
||||
</a>
|
||||
</div>
|
||||
<p class="fine"><span hidden>Latest: <span data-version></span> · </span><a href="#download">All platforms</a> · MIT licence · Not affiliated with Valve</p>
|
||||
<div class="shot">
|
||||
<div class="bar"><i></i><i></i><i></i></div>
|
||||
<img src="/img/home-live.jpg" width="1600" height="1000" alt="Frame Control's Home tab: a live view of the headset beside battery, storage, temperature and Wi-Fi.">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="trailer" class="alt">
|
||||
<div class="wrap">
|
||||
<div class="head">
|
||||
<span class="kicker">Trailer</span>
|
||||
<h2>Sixty-six seconds of Frame Control</h2>
|
||||
<p>Recorded against a real Steam Frame. Turn the sound on.</p>
|
||||
</div>
|
||||
<div class="video">
|
||||
<video controls playsinline preload="none" poster="/media/poster.jpg">
|
||||
<source src="/media/trailer.mp4" type="video/mp4">
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="features">
|
||||
<div class="wrap">
|
||||
<div class="head">
|
||||
<span class="kicker">Features</span>
|
||||
<h2>Everything the headset needs, one window away</h2>
|
||||
<p>Frame Control uses what SteamOS already ships. It only changes what you click.</p>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="10" rx="4"/><circle cx="8" cy="12" r="2"/><circle cx="16" cy="12" r="2"/></svg></div>
|
||||
<h3>Headset view</h3>
|
||||
<p>Live video of what the lenses show at about 30 fps, or a still of both eyes. Zoom, pan, full screen, save as PNG.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="17" height="10" rx="2"/><path d="M22 11v2M6 10v4M9.5 10v4"/></svg></div>
|
||||
<h3>Battery and status</h3>
|
||||
<p>Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 11h4M8 9v4M15 12h.01M18 10h.01"/><path d="M17.3 5H6.7a4 4 0 0 0-3.98 3.59l-.7 7A3 3 0 0 0 5 19c1 0 1.5-.5 2-1l1.4-1.4A2 2 0 0 1 9.8 16h4.4a2 2 0 0 1 1.4.6L17 18c.5.5 1 1 2 1a3 3 0 0 0 2.98-3.41l-.7-7A4 4 0 0 0 17.3 5z"/></svg></div>
|
||||
<h3>Steam games</h3>
|
||||
<p>Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="8" width="14" height="12" rx="3"/><path d="M8 8a4 4 0 0 1 8 0M8 4l1.5 2M16 4l-1.5 2M10 13h.01M14 13h.01"/></svg></div>
|
||||
<h3>Android apps</h3>
|
||||
<p>About 4,500 F-Droid apps rated for the Frame. One click installs each as its own app in your Steam library.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6M12 18v-6m0 0-3 3m3-3 3 3"/></svg></div>
|
||||
<h3>Files, games and clipboard</h3>
|
||||
<p>Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library. Send your clipboard to the headset's desktop.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8a2 2 0 0 1 2-2h2l2-2h4l2 2h2a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"/><circle cx="12" cy="13" r="3.5"/></svg></div>
|
||||
<h3>Screenshots</h3>
|
||||
<p>Browse the shots you take in the headset and save them straight to your Pictures folder.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="M17.5 14v7M14 17.5h7"/></svg></div>
|
||||
<h3>Flatpaks and display</h3>
|
||||
<p>Install desktop apps like Moonlight or VLC, and set each Android app's resolution and text size.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 4 14h7l-1 8 9-12h-7z"/></svg></div>
|
||||
<h3>One-click tools</h3>
|
||||
<p>SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="alt">
|
||||
<div class="wrap">
|
||||
<div class="feature">
|
||||
<div class="copy">
|
||||
<span class="kicker">Games</span>
|
||||
<h2>Your Steam library, rated for the Frame</h2>
|
||||
<p>See every game you own with how well it runs on the Frame, then install it onto the headset without putting it on.</p>
|
||||
<ul>
|
||||
<li>Live download progress, straight from the Steam client on the headset</li>
|
||||
<li>Search the Steam store from your computer</li>
|
||||
<li>Sideload your own Linux or Windows games, with Proton or the Linux runtime picked for you</li>
|
||||
</ul>
|
||||
</div>
|
||||
<img src="/img/games.jpg" width="1600" height="1000" loading="lazy" alt="The Games tab: owned games with Steam Frame ratings and install buttons.">
|
||||
</div>
|
||||
<div class="feature flip">
|
||||
<div class="copy">
|
||||
<span class="kicker">Android</span>
|
||||
<h2>Android apps, one click each</h2>
|
||||
<p>A catalogue of about 4,500 F-Droid apps, each tested and rated on a real Frame. Every app gets its own Lepton instance and its own tile in your Steam library.</p>
|
||||
<ul>
|
||||
<li>Filter by what works on the Frame</li>
|
||||
<li>Install any APK you have, no Android SDK needed</li>
|
||||
<li>Set resolution and text size per app</li>
|
||||
</ul>
|
||||
</div>
|
||||
<img src="/img/android.jpg" width="1600" height="1000" loading="lazy" alt="The Android tab: installed apps and the rated F-Droid catalogue.">
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="copy">
|
||||
<span class="kicker">Tools</span>
|
||||
<h2>The fiddly bits, done for you</h2>
|
||||
<p>Open an SSH session or SFTP, start Steam Link or remote desktop, change the volume, or put the headset to sleep, all from one tab.</p>
|
||||
</div>
|
||||
<img src="/img/tools.jpg" width="1600" height="600" loading="lazy" alt="The Tools tab: SSH, SFTP, Steam Link, remote desktop and power controls.">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="head">
|
||||
<span class="kicker">iPhone and iPad</span>
|
||||
<h2>Also in your pocket</h2>
|
||||
<p>The same features on iPhone and iPad, served from the Frame itself, so there's nothing to run on a computer. Build it from the repo in Xcode.</p>
|
||||
</div>
|
||||
<div class="phones">
|
||||
<img src="/img/phone-home.jpg" width="600" height="1304" loading="lazy" alt="Frame Control on iPhone: headset view and device status.">
|
||||
<img src="/img/phone-games.jpg" width="600" height="1304" loading="lazy" alt="Frame Control on iPhone: the Games tab.">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="setup" class="alt">
|
||||
<div class="wrap">
|
||||
<div class="head">
|
||||
<span class="kicker">Setup</span>
|
||||
<h2>One password, typed once</h2>
|
||||
<p>Everything else happens on your computer.</p>
|
||||
</div>
|
||||
<div class="steps">
|
||||
<div class="card">
|
||||
<h3>Turn on Developer Mode</h3>
|
||||
<p>On the Frame: Steam Settings → System → <b>Enable Developer Mode</b>, then <b>Set User Password</b> in the Developer section.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Set up the connection</h3>
|
||||
<p>Open Frame Control and choose <b>Set Up Connection</b>. It finds the headset, makes an SSH key, and asks for that password once.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>That's it</h3>
|
||||
<p>The app reaches the headset whenever it's awake and on the same network. For anywhere else, use <a href="https://github.com/saphid/frame-control/blob/main/docs/tailscale.md">Tailscale</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="download">
|
||||
<div class="wrap">
|
||||
<div class="head">
|
||||
<span class="kicker">Download</span>
|
||||
<h2>Get Frame Control</h2>
|
||||
<p>Free, with Python and adb built in. <span hidden>Version <span data-version></span>.</span> <a href="https://github.com/saphid/frame-control/releases">Release notes</a></p>
|
||||
</div>
|
||||
<div class="downloads">
|
||||
<div class="dl" data-os="mac">
|
||||
<div class="os"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M16.37 12.6c-.02-2.2 1.8-3.26 1.88-3.31-1.02-1.5-2.62-1.7-3.19-1.72-1.36-.14-2.65.8-3.34.8-.69 0-1.75-.78-2.88-.76a4.27 4.27 0 0 0-3.6 2.19c-1.53 2.66-.39 6.6 1.1 8.76.73 1.06 1.6 2.24 2.74 2.2 1.1-.04 1.51-.71 2.84-.71 1.32 0 1.7.71 2.86.69 1.18-.02 1.93-1.08 2.65-2.14.83-1.23 1.18-2.41 1.2-2.47-.03-.01-2.3-.88-2.32-3.5zM14.2 6.13c.6-.73 1.01-1.75.9-2.76-.87.04-1.92.58-2.54 1.3-.56.64-1.05 1.67-.92 2.66.97.08 1.96-.49 2.56-1.2z"/></svg><h3>macOS</h3><span class="tag" hidden>Your system</span></div>
|
||||
<span class="need">Apple Silicon</span>
|
||||
<div class="links">
|
||||
<a class="btn primary" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-arm64.dmg">Download .dmg</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dl" data-os="windows">
|
||||
<div class="os"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 5.5 10 4.5v7H3zM11 4.35 21 3v8.5H11zM3 12.5h7v7L3 18.5zM11 12.5h10V21l-10-1.4z"/></svg><h3>Windows</h3><span class="tag" hidden>Your system</span></div>
|
||||
<span class="need">Windows 10 or 11, x64</span>
|
||||
<div class="links">
|
||||
<a class="btn primary" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-Setup-x64.exe">Download installer</a>
|
||||
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-win-x64.zip">Portable .zip</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dl" data-os="linux">
|
||||
<div class="os"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3M13 15h4"/></svg><h3>Linux</h3><span class="tag" hidden>Your system</span></div>
|
||||
<span class="need">x64 or arm64 · needs <code>ssh</code></span>
|
||||
<div class="links">
|
||||
<a class="btn primary" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage">AppImage (x64)</a>
|
||||
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb">.deb (x64)</a>
|
||||
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage">AppImage (arm64)</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="faq" class="alt">
|
||||
<div class="wrap">
|
||||
<div class="head">
|
||||
<span class="kicker">FAQ</span>
|
||||
<h2>Good questions</h2>
|
||||
</div>
|
||||
<div class="faq">
|
||||
<details>
|
||||
<summary>macOS says the app is damaged or can't be checked</summary>
|
||||
<div><p>The app isn't notarized, because there's no paid Apple developer account behind it. Drag it to Applications, then clear the download quarantine once in Terminal:</p>
|
||||
<p><code>xattr -dr com.apple.quarantine "/Applications/Frame Control.app"</code></p></div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Windows SmartScreen says it protected my PC</summary>
|
||||
<div><p>The installer isn't code-signed. Choose <b>More info → Run anyway</b>, or use the portable .zip: unzip it anywhere and run <code>Frame Control.exe</code>.</p></div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>What does it change on my headset?</summary>
|
||||
<div><p>Only what you click. Installs go to your user account on the Frame, and nothing needs <code>sudo</code> except the power buttons. On your computer it adds a <code>Host frame</code> entry to <code>~/.ssh/config</code> and two SSH keys.</p></div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Is it safe to leave Developer Mode on?</summary>
|
||||
<div><p>With Developer Mode on, SSH, ADB and remote desktop are reachable on your local network. Use trusted networks, turn Developer Mode off when you don't need it, and never port-forward those ports from your router. For remote access, use Tailscale. <a href="https://github.com/saphid/frame-control#readme">Security notes</a></p></div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Is this made by Valve?</summary>
|
||||
<div><p>No. It's an unofficial hobby project, free and MIT-licensed. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p></div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap split">
|
||||
<div class="card">
|
||||
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z"/></svg></div>
|
||||
<h3>Tell us what you think</h3>
|
||||
<p>Found a bug, or want something added? Reports from Windows and Linux are especially useful. No GitHub account needed.</p>
|
||||
<a class="btn primary" href="/feedback/">Send feedback</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="ico" style="background:rgba(255,94,91,.14);color:#ff8a87"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 0 1 0 8h-1M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4zM6 2v3M10 2v3M14 2v3"/></svg></div>
|
||||
<h3 data-needs-kofi>Buy me a coffee</h3>
|
||||
<h3 data-no-kofi hidden>Like it? Pass it on</h3>
|
||||
<p data-needs-kofi>Frame Control is free and always will be. If it saves you time, a coffee keeps it going.</p>
|
||||
<p data-no-kofi hidden>Frame Control is free and always will be. The best way to help is to star the repo and tell a friend.</p>
|
||||
<a class="btn coffee" data-kofi href="#" target="_blank" rel="noopener">Support on Ko-fi</a>
|
||||
<a class="btn ghost" data-no-kofi hidden href="https://github.com/saphid/frame-control">Star on GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">
|
||||
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
|
||||
<div class="cols">
|
||||
<a href="https://github.com/saphid/frame-control">GitHub</a>
|
||||
<a href="https://github.com/saphid/frame-control/releases">Releases</a>
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/docs/frame-control.md">Docs</a>
|
||||
<a href="/feedback/">Feedback</a>
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">Contributing</a>
|
||||
<a href="/privacy/">Privacy</a>
|
||||
</div>
|
||||
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,93 @@
|
||||
// The feedback form: posts to /api/feedback (site/functions/api/feedback.js), which opens a GitHub issue.
|
||||
const form = document.getElementById("feedback");
|
||||
const errorBox = document.getElementById("error");
|
||||
const send = document.getElementById("send");
|
||||
const started = performance.now();
|
||||
|
||||
const HINTS = {
|
||||
bug: "What you tried, what happened, and what you expected.",
|
||||
idea: "What you'd like, and what it would help you do.",
|
||||
question: "What you'd like to know.",
|
||||
other: "Anything you'd like to tell us.",
|
||||
};
|
||||
|
||||
// Prefill the computer field; people rarely know the exact wording otherwise.
|
||||
(function guessOs() {
|
||||
const ua = navigator.userAgent;
|
||||
const guess = /Windows/.test(ua) ? "Windows" : /Macintosh/.test(ua) ? "macOS" : /iPhone|iPad/.test(ua) ? "iOS"
|
||||
: /Android/.test(ua) ? "" : /Linux/.test(ua) ? "Linux" : "";
|
||||
if (guess) form.elements.os.value = guess;
|
||||
})();
|
||||
|
||||
form.addEventListener("change", (e) => {
|
||||
if (e.target.name === "kind") document.getElementById("message-hint").textContent = HINTS[e.target.value];
|
||||
});
|
||||
|
||||
// A prefilled GitHub issue form (.github/ISSUE_TEMPLATE), for when this form can't reach GitHub itself.
|
||||
function githubUrl(data) {
|
||||
const params = data.kind === "idea"
|
||||
? new URLSearchParams({ template: "idea.yml", title: data.title, what: data.message })
|
||||
: new URLSearchParams({ template: "bug.yml", title: data.title, description: data.message,
|
||||
version: data.version, os: [data.os, data.steamos && `SteamOS ${data.steamos}`].filter(Boolean).join(", ") });
|
||||
return `https://github.com/saphid/frame-control/issues/new?${params}`;
|
||||
}
|
||||
|
||||
function showError(message, data) {
|
||||
errorBox.textContent = message + " ";
|
||||
if (data) {
|
||||
const a = document.createElement("a");
|
||||
a.href = githubUrl(data);
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener";
|
||||
a.textContent = "Open it on GitHub instead";
|
||||
errorBox.append(a);
|
||||
}
|
||||
errorBox.hidden = false;
|
||||
errorBox.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
errorBox.hidden = true;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
|
||||
if (data.title.trim().length < 5) {
|
||||
form.elements.title.focus();
|
||||
return showError("Give it a short title (at least 5 characters).");
|
||||
}
|
||||
if (data.message.trim().length < 10) {
|
||||
form.elements.message.focus();
|
||||
return showError("Tell us a little more in Details.");
|
||||
}
|
||||
|
||||
send.disabled = true;
|
||||
send.textContent = "Sending…";
|
||||
try {
|
||||
// The server treats anything sent sooner as a script (site/lib/feedback.js MIN_FILL_MS).
|
||||
const wait = 3100 - (performance.now() - started);
|
||||
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
const res = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...data, elapsed: Math.round(performance.now() - started) }),
|
||||
});
|
||||
const reply = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(reply.error || "Something went wrong sending that.");
|
||||
|
||||
form.hidden = true;
|
||||
document.getElementById("done").hidden = false;
|
||||
if (reply.url) {
|
||||
const link = document.getElementById("issue-link");
|
||||
link.href = reply.url;
|
||||
link.hidden = false;
|
||||
document.getElementById("done-text").textContent = `Your feedback is now issue #${reply.number} on GitHub.`
|
||||
+ (data.github ? " You'll be notified when someone replies." : " Bookmark it to follow along.");
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
} catch (err) {
|
||||
showError(err.message || "Couldn't reach the server.", data);
|
||||
} finally {
|
||||
send.disabled = false;
|
||||
send.textContent = "Send feedback";
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// Shared by every page. Change the settings here, not in the HTML.
|
||||
const SITE = {
|
||||
repo: "saphid/frame-control",
|
||||
// Ko-fi page name, the part after ko-fi.com/. Donate buttons stay hidden while it's empty.
|
||||
kofi: "alexsouthwell",
|
||||
};
|
||||
|
||||
const RELEASE = `https://github.com/${SITE.repo}/releases/latest/download/`;
|
||||
|
||||
// Donate buttons.
|
||||
for (const el of document.querySelectorAll("[data-kofi]")) {
|
||||
if (SITE.kofi) el.href = `https://ko-fi.com/${SITE.kofi}`;
|
||||
else el.hidden = true;
|
||||
}
|
||||
for (const el of document.querySelectorAll("[data-needs-kofi]")) el.hidden = !SITE.kofi;
|
||||
for (const el of document.querySelectorAll("[data-no-kofi]")) el.hidden = !!SITE.kofi;
|
||||
|
||||
// Best guess at the visitor's platform, for the hero button and the download cards.
|
||||
function detectPlatform() {
|
||||
const ua = navigator.userAgent;
|
||||
const hint = navigator.userAgentData?.platform || navigator.platform || "";
|
||||
if (/iPhone|iPad|iPod/.test(ua) || (/Mac/.test(hint) && navigator.maxTouchPoints > 1)) return "ios";
|
||||
if (/Android/.test(ua)) return null;
|
||||
if (/Mac/.test(hint) || /Macintosh/.test(ua)) return "mac";
|
||||
if (/Win/.test(hint) || /Windows/.test(ua)) return "windows";
|
||||
if (/Linux/.test(hint) || /Linux/.test(ua)) return /aarch64|arm64/i.test(ua + hint) ? "linux-arm" : "linux";
|
||||
return null;
|
||||
}
|
||||
|
||||
const PLATFORMS = {
|
||||
mac: { name: "macOS", file: "Frame-Control-mac-arm64.dmg" },
|
||||
windows: { name: "Windows", file: "Frame-Control-Setup-x64.exe" },
|
||||
linux: { name: "Linux", file: "Frame-Control-linux-x86_64.AppImage" },
|
||||
"linux-arm": { name: "Linux (arm64)", file: "Frame-Control-linux-arm64.AppImage" },
|
||||
};
|
||||
|
||||
const platform = detectPlatform();
|
||||
const hero = document.getElementById("hero-download");
|
||||
if (hero && PLATFORMS[platform]) {
|
||||
hero.href = RELEASE + PLATFORMS[platform].file;
|
||||
hero.querySelector("[data-label]").textContent = `Download for ${PLATFORMS[platform].name}`;
|
||||
}
|
||||
const card = platform && document.querySelector(`.dl[data-os="${platform.replace("-arm", "")}"]`);
|
||||
if (card) {
|
||||
card.classList.add("mine");
|
||||
card.querySelector(".tag").hidden = false;
|
||||
}
|
||||
|
||||
// Latest version number, so the page never goes stale. Fails quietly.
|
||||
const versionEls = document.querySelectorAll("[data-version]");
|
||||
if (versionEls.length) {
|
||||
fetch(`https://api.github.com/repos/${SITE.repo}/releases/latest`, { headers: { accept: "application/vnd.github+json" } })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
.then((release) => {
|
||||
for (const el of versionEls) {
|
||||
el.textContent = release.tag_name;
|
||||
el.closest("[hidden]")?.removeAttribute("hidden");
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-year]").forEach((el) => (el.textContent = new Date().getFullYear()));
|
||||
|
After Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,66 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Privacy · Frame Control</title>
|
||||
<meta name="description" content="What the Frame Control website and app collect.">
|
||||
<meta name="theme-color" content="#0e141b">
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/css/site.css">
|
||||
<script src="/js/site.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="top">
|
||||
<div class="wrap">
|
||||
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
|
||||
<nav aria-label="Sections">
|
||||
<a href="/#features">Features</a>
|
||||
<a href="/#setup">Setup</a>
|
||||
<a href="/#download">Download</a>
|
||||
<a href="/#faq">FAQ</a>
|
||||
<a href="/feedback/">Feedback</a>
|
||||
</nav>
|
||||
<div class="end">
|
||||
<a class="btn small ghost" href="https://github.com/saphid/frame-control">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<a class="btn small coffee" data-kofi href="#" target="_blank" rel="noopener">Support</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
<div class="wrap" style="max-width:780px">
|
||||
<div class="head">
|
||||
<span class="kicker">Privacy</span>
|
||||
<h1>Privacy</h1>
|
||||
<p>Short version: the app collects nothing, and the website keeps only what you choose to send.</p>
|
||||
</div>
|
||||
<div class="faq notes">
|
||||
<section><h2>The app</h2><p>Frame Control talks only to your headset (over SSH on your network), to GitHub for releases, and to Steam and F-Droid for game and app listings. It has no analytics and no accounts.</p></section>
|
||||
<section><h2>The feedback form</h2><p>What you type becomes a public GitHub issue on <a href="https://github.com/saphid/frame-control/issues">saphid/frame-control</a>. To stop abuse, the form keeps a one-way hash of your IP address for about an hour to count submissions. The address itself isn't stored or published.</p></section>
|
||||
<section><h2>This website</h2><p>Hosted on Cloudflare Pages. No cookies, no analytics, no trackers. The download section asks GitHub for the latest version number. Donations go through Ko-fi, under Ko-fi's own privacy policy.</p></section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">
|
||||
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
|
||||
<div class="cols">
|
||||
<a href="https://github.com/saphid/frame-control">GitHub</a>
|
||||
<a href="https://github.com/saphid/frame-control/releases">Releases</a>
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/docs/frame-control.md">Docs</a>
|
||||
<a href="/feedback/">Feedback</a>
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">Contributing</a>
|
||||
<a href="/privacy/">Privacy</a>
|
||||
</div>
|
||||
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -0,0 +1,74 @@
|
||||
// node --test site/test/*.test.mjs
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { buildIssue, defang, hashIp, MIN_FILL_MS, validate } from "../lib/feedback.js";
|
||||
|
||||
const form = (over = {}) => ({
|
||||
kind: "bug",
|
||||
title: "Live view freezes",
|
||||
message: "After about a minute the live view stops updating.",
|
||||
elapsed: MIN_FILL_MS + 1,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("accepts a normal report and trims it", () => {
|
||||
const { value, error } = validate(form({ title: " Live view freezes ", os: " macOS 26 " }));
|
||||
assert.equal(error, undefined);
|
||||
assert.equal(value.title, "Live view freezes");
|
||||
assert.equal(value.os, "macOS 26");
|
||||
assert.equal(value.kind, "bug");
|
||||
});
|
||||
|
||||
test("flags the honeypot as spam and asks fast senders to retry", () => {
|
||||
assert.deepEqual(validate(form({ website: "http://spam" })), { spam: true });
|
||||
assert.match(validate(form({ elapsed: 500 })).error, /again/);
|
||||
assert.deepEqual(validate(form({ elapsed: undefined })), { spam: true });
|
||||
});
|
||||
|
||||
test("rejects short, long and malformed input", () => {
|
||||
assert.match(validate(form({ title: "hi" })).error, /title/);
|
||||
assert.match(validate(form({ message: "short" })).error, /more/);
|
||||
assert.match(validate(form({ message: "x".repeat(5001) })).error, /under/);
|
||||
assert.match(validate(form({ github: "not a user!" })).error, /GitHub/);
|
||||
assert.match(validate(null).error, /JSON/);
|
||||
});
|
||||
|
||||
test("unknown kinds become other feedback", () => {
|
||||
assert.equal(validate(form({ kind: "__proto__" })).value.kind, "other");
|
||||
});
|
||||
|
||||
test("builds a labelled issue that credits a GitHub user", () => {
|
||||
const { value } = validate(form({ github: "@octocat", version: "0.3.1", steamos: "20260922" }));
|
||||
const issue = buildIssue(value);
|
||||
assert.equal(issue.title, "Bug report: Live view freezes");
|
||||
assert.deepEqual(issue.labels, ["feedback", "bug"]);
|
||||
assert.match(issue.body, /\| Frame Control version \| 0\.3\.1 \|/);
|
||||
assert.match(issue.body, /^> Sent from the website feedback form by @octocat\./);
|
||||
assert.ok(issue.body.endsWith(value.message));
|
||||
});
|
||||
|
||||
test("anonymous feedback says replies won't reach the sender", () => {
|
||||
const issue = buildIssue(validate(form({ kind: "other" })).value);
|
||||
assert.deepEqual(issue.labels, ["feedback"]);
|
||||
assert.match(issue.body, /won't see replies/);
|
||||
});
|
||||
|
||||
test("breaks mentions, issue refs and table cells in user text", () => {
|
||||
assert.equal(defang("ping @valve about #12"), "ping @\u200bvalve about #\u200b12");
|
||||
assert.equal(defang("email me@example.com"), "email me@\u200bexample.com");
|
||||
assert.equal(defang("see valve/steam#7 and GH-8"), "see valve/steam#\u200b7 and GH\u200b-8");
|
||||
assert.equal(defang("@valve @valve #3"), "&commat;valve &#\u200b64;valve &num;3");
|
||||
assert.equal(defang("end <!--"), "end <!--");
|
||||
assert.equal(defang("GH\\-1 github\\.com"), "GH\\\\-1 github\\\\.com");
|
||||
assert.equal(defang("https://github.com/a/b/issues/1"), "https://github\u200b.com/a/b/issues/1");
|
||||
const issue = buildIssue(validate(form({ os: "a | b" })).value);
|
||||
assert.match(issue.body, /\| a \\\| b \|/);
|
||||
});
|
||||
|
||||
test("hashes IPs without keeping them", async () => {
|
||||
const a = await hashIp("203.0.113.9", "salt");
|
||||
assert.equal(a.length, 24);
|
||||
assert.equal(a, await hashIp("203.0.113.9", "salt"));
|
||||
assert.notEqual(a, await hashIp("203.0.113.10", "salt"));
|
||||
assert.ok(!a.includes("203"));
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
# Cloudflare Pages project for the Frame Control website. Deploy: see site/README.md.
|
||||
name = "frame-control"
|
||||
pages_build_output_dir = "public"
|
||||
compatibility_date = "2026-07-01"
|
||||
|
||||
[vars]
|
||||
GITHUB_REPO = "saphid/frame-control"
|
||||
|
||||
# Rate limits for /api/feedback (site/functions/api/feedback.js).
|
||||
[[kv_namespaces]]
|
||||
binding = "FEEDBACK_RL"
|
||||
id = "4852de5aae9e4d9f989f6dc2bc3b2b6b"
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Plumbing for the end-to-end tests against the fake Frame (tests/fakeframe).
|
||||
|
||||
scripts/e2e.sh runs these inside the compose `host` container, where
|
||||
`ssh frame` reaches the fake Frame and FAKEFRAME_CTL is its control port.
|
||||
Each test starts from `fakeframe-ctl reset` and drives the real ui/server.py
|
||||
(started once, on a free port) over HTTP, then checks the fake's state.
|
||||
Without FRAME_E2E=1 every test here is skipped.
|
||||
"""
|
||||
import atexit
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path[:0] = [str(ROOT / 'ui'), str(ROOT / 'tests'), str(ROOT / 'tests' / 'smoke')]
|
||||
|
||||
ENABLED = os.environ.get('FRAME_E2E') == '1'
|
||||
CTL = os.environ.get('FAKEFRAME_CTL', 'http://fakeframe:9999').rstrip('/')
|
||||
FAKE_HOST = os.environ.get('FAKEFRAME_HOST', 'fakeframe')
|
||||
HOME = '/home/steamos'
|
||||
SERVER_LOG = os.path.join(tempfile.gettempdir(), 'fakeframe-e2e-server.log')
|
||||
|
||||
|
||||
def require():
|
||||
"""Call at module level: skips the module unless the fake Frame is up."""
|
||||
if not ENABLED:
|
||||
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
|
||||
|
||||
|
||||
# ---- the fake Frame's control port --------------------------------------------
|
||||
|
||||
def _ctl_request(path, body=None, timeout=60):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(CTL + path, data=data, headers={'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
def ctl(*args):
|
||||
out = _ctl_request('/ctl', {'args': list(args)})
|
||||
if 'error' in out:
|
||||
raise AssertionError(f'fakeframe-ctl {" ".join(args)}: {out["error"]}')
|
||||
return out
|
||||
|
||||
|
||||
def state():
|
||||
return _ctl_request('/state')
|
||||
|
||||
|
||||
def calls(tool=None):
|
||||
return _ctl_request('/calls' + (f'?{tool}' if tool else ''))
|
||||
|
||||
|
||||
def wait_for(check, timeout=30, what='a condition', every=0.3):
|
||||
"""Poll check() until it returns something truthy; returns that."""
|
||||
deadline = time.monotonic() + timeout
|
||||
last = None
|
||||
while time.monotonic() < deadline:
|
||||
last = check()
|
||||
if last:
|
||||
return last
|
||||
time.sleep(every)
|
||||
raise AssertionError(f'timed out after {timeout}s waiting for {what} (last: {last!r})')
|
||||
|
||||
|
||||
def reset():
|
||||
ctl('reset')
|
||||
wait_for(lambda: _ctl_request('/ping')['ok'], 30, 'the fake Frame to come back after reset')
|
||||
|
||||
|
||||
def ssh(cmd, check=True, timeout=30):
|
||||
"""Run cmd on the fake Frame through the `frame` alias, as Frame Control does."""
|
||||
r = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', 'frame', cmd],
|
||||
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=timeout)
|
||||
if check and r.returncode != 0:
|
||||
raise AssertionError(f'ssh frame {cmd!r} exited {r.returncode}: {r.stderr.strip()}')
|
||||
return r.stdout
|
||||
|
||||
|
||||
def exists(path):
|
||||
return ssh(f'test -e {path} && echo yes || echo no').strip() == 'yes'
|
||||
|
||||
|
||||
# ---- the real server ----------------------------------------------------------
|
||||
|
||||
class Server:
|
||||
proc = None
|
||||
port = None
|
||||
|
||||
@classmethod
|
||||
def start(cls):
|
||||
if cls.proc and cls.proc.poll() is None:
|
||||
return
|
||||
with socket.socket() as s:
|
||||
s.bind(('127.0.0.1', 0))
|
||||
cls.port = s.getsockname()[1]
|
||||
env = dict(os.environ, FRAME_CONTROL_LOCAL_LINKS='1')
|
||||
log = open(SERVER_LOG, 'ab')
|
||||
cls.proc = subprocess.Popen([sys.executable, str(ROOT / 'ui' / 'server.py'), '--port', str(cls.port)],
|
||||
cwd=str(ROOT), env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=log)
|
||||
log.close()
|
||||
atexit.register(cls.stop)
|
||||
wait_for(lambda: cls._up(), 20, 'ui/server.py to listen')
|
||||
|
||||
@classmethod
|
||||
def _up(cls):
|
||||
try:
|
||||
return api('GET', '/api/host')[0] == 200
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def stop(cls):
|
||||
if cls.proc and cls.proc.poll() is None:
|
||||
cls.proc.terminate()
|
||||
try:
|
||||
cls.proc.wait(10)
|
||||
except subprocess.TimeoutExpired:
|
||||
cls.proc.kill()
|
||||
|
||||
|
||||
def api(method, path, body=None, raw=None, headers=None, timeout=120):
|
||||
"""One request to the server with the headers its guards want. -> (status, JSON or bytes, headers)."""
|
||||
conn = http.client.HTTPConnection('127.0.0.1', Server.port, timeout=timeout)
|
||||
try:
|
||||
hdrs = {'X-Frame-UI': '1', **(headers or {})} # Host is 127.0.0.1:<port>, which it accepts
|
||||
data = raw if raw is not None else (json.dumps(body).encode() if body is not None else None)
|
||||
if data is not None and 'Content-Type' not in hdrs:
|
||||
hdrs['Content-Type'] = 'application/json'
|
||||
conn.request(method, path, body=data, headers=hdrs)
|
||||
r = conn.getresponse()
|
||||
payload = r.read()
|
||||
if r.getheader('Content-Type', '').startswith('application/json'):
|
||||
payload = json.loads(payload)
|
||||
return r.status, payload, dict(r.getheaders())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def ok(method, path, body=None, **kw):
|
||||
status, out, _ = api(method, path, body, **kw)
|
||||
if status != 200:
|
||||
raise AssertionError(f'{method} {path} -> {status}: {out}')
|
||||
return out
|
||||
|
||||
|
||||
def finished(started, timeout=60):
|
||||
"""Wait for a background job (server.start_job's {"job": id}); returns its final state."""
|
||||
return wait_for(lambda: (lambda j: j['done'] and j)(ok('GET', f"/api/job?id={started['job']}")),
|
||||
timeout, f"job {started['job']}")
|
||||
|
||||
|
||||
def upload(path, mode, name=None):
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read()
|
||||
return api('POST', '/api/upload', raw=data, headers={
|
||||
'X-Filename': name or os.path.basename(path), 'X-Mode': mode, 'Content-Type': 'application/octet-stream'})
|
||||
|
||||
|
||||
def wait_title_job(token, timeout=180):
|
||||
def done():
|
||||
job = ok('GET', f'/api/titles/job?token={token}')
|
||||
return job if job['done'] else None
|
||||
return wait_for(done, timeout, f'title install job {token}', every=0.5)
|
||||
|
||||
|
||||
def install_title(path, **options):
|
||||
"""Upload (or, for a folder, inspect by path) and install; returns (plan, finished job)."""
|
||||
if os.path.isdir(path):
|
||||
staged = ok('POST', '/api/titles', {'action': 'inspect', 'path': path})
|
||||
else:
|
||||
status, staged, _ = upload(path, 'title')
|
||||
if status != 200:
|
||||
raise AssertionError(f'upload -> {status}: {staged}')
|
||||
started = ok('POST', '/api/titles', {'action': 'install', 'token': staged['token'], **options})
|
||||
return staged['plan'], wait_title_job(started['job'])
|
||||
|
||||
|
||||
def launches(kind=None):
|
||||
return [r for r in state()['launches'] if kind is None or r['kind'] == kind]
|
||||
|
||||
|
||||
class FrameTestCase(unittest.TestCase):
|
||||
"""Starts the server once and the fake Frame afresh for every test."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
Server.start()
|
||||
|
||||
def setUp(self):
|
||||
reset()
|
||||
self.tmp = tempfile.mkdtemp(prefix='fakeframe-e2e-')
|
||||
self.addCleanup(subprocess.run, ['rm', '-rf', self.tmp])
|
||||
|
||||
def path(self, *parts):
|
||||
return os.path.join(self.tmp, *parts)
|
||||