Merge remote-tracking branch 'origin/main' into frame-input
No files matched your search
@@ -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,
|
||||
});
|
||||
|
||||
@@ -36,6 +36,8 @@ jobs:
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: App syntax
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js
|
||||
- name: Website
|
||||
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
|
||||
|
||||
# The server runs on each desktop OS the app ships for, on the Python version
|
||||
# the app bundles (app/build/fetch-deps.js) and, on Ubuntu, a newer one.
|
||||
|
||||
@@ -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,7 +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,12 +12,17 @@ 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's Games tab: installed games, sideloaded titles, and your Steam library with Frame ratings" width="900">
|
||||
|
||||
<a id="trailer"></a>
|
||||
<a href="https://github.com/saphid/steam-frame/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
|
||||
|
||||
<sub>The trailer: 66 seconds, with sound. Downloads the MP4 from the trailer release.</sub>
|
||||
|
||||
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
|
||||
|
||||
</div>
|
||||
@@ -173,13 +178,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,6 +33,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| SteamVR's `steamvr-v4l2cam.service` (`/opt/steamvr/bin/linuxarm64/v4l2cam --output=99`) copies the headset view (the `system.HeadsetView` mirror, one undistorted image) into the v4l2loopback device `/dev/video99` ("SteamVR"), 1920×1080 RGB24. `ffmpeg -f v4l2 -i /dev/video99` reads it at about 70 new frames/s; the first frame read can be black. The Frame's hardware encoder (`iris_encoder`, `/dev/video-enc0`) crashes ffmpeg's `h264_v4l2m2m`, so encode with `libx264 -preset ultrafast -tune zerolatency`: 720p30 takes about 0.7 of a core and 1080p60 about 1.7 (of 8). gamescope also publishes a PipeWire `gamescope` video source, but the Frame's GStreamer has no `pipewiresrc`. **Verified 2026-09-26.** | Frame Control's live video (`/api/stream`) |
|
||||
| Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card |
|
||||
| `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn |
|
||||
| **Testing VR apps without wearing the headset.** In standby SteamVR keeps OpenXR sessions hidden, so they render one frame and stop. `vrcmd` (in `/opt/steamvr/bin/linuxarm64`) settings use `section.key`: `vrcmd --set-settings-bool power.pauseCompositorOnStandby 0` and `vrcmd --set-settings-float power.turnOffScreensTimeout 3600`, then `vrcmd --handlewakeup`, keep the compositor running, and the scene app becomes visible. If it stays `visible-blurred`, the Steam dashboard is open: `SteamClient.OpenVR.VROverlay.HideDashboard()` in Steam's `SharedJSContext` (CDP on 8080) closes it. The headset view then captures with `ui/frame_vrshot.py`. Restore afterwards with `--set-settings-bool power.pauseCompositorOnStandby 1` and `--set-settings-float power.turnOffScreensTimeout 5`. The bool setter reads `true` as false, so use 1/0. A Steam launch that stalls in standby at `ShowInterstitials` or `CreatingProcess` (see `console_log.txt`) continues with `SteamClient.Apps.ContinueGameAction(<action id>, "<appid>", "<task>")`. **Verified 2026-09-27.** | Proving VR output remotely, [webxr-chromium.md](webxr-chromium.md) |
|
||||
| The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) |
|
||||
| SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks |
|
||||
| The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging |
|
||||
|
||||
|
After Width: | Height: | Size: 125 KiB |
@@ -33,14 +33,19 @@ build 20260922.6101926, kernel 6.18, aarch64):
|
||||
- **10.** `install-apps.sh remmina --vnc-host <mac>.local` installed Remmina as
|
||||
a `--user` Flatpak over SSH and wrote the profile. The desktop's
|
||||
`XDG_DATA_DIRS` includes the user Flatpak exports, so it shows up in the menu.
|
||||
The Frame can reach the Mac's Screen Sharing port (5900). The Remmina
|
||||
connection itself hasn't been tried in the headset yet (part of 11).
|
||||
The Frame can reach the Mac's Screen Sharing port (5900).
|
||||
- **11.** Answered 2026-09-27 (BUILD_ID 20260925.6191901, macOS 27.0): the
|
||||
pre-seeded profile connects and shows the Mac in its own panel. It asks for
|
||||
the Mac account login rather than the VNC password, needs scale-to-fit at
|
||||
Retina resolutions, and doesn't show the Mac cursor without
|
||||
`scripts/mac-cursor-ring.lua`. It's usable but noticeably laggy. See
|
||||
[streaming.md](streaming.md).
|
||||
|
||||
- **Panels.** An X11 window on gamescope's `:0` with its own `STEAM_GAME` id
|
||||
gets its own SteamVR overlay (`valve.steam.desktopgame.<id>`). Three were
|
||||
created side by side with `panel-on-frame.sh`. See [panels.md](panels.md).
|
||||
|
||||
Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a reboot), 17–21.
|
||||
Still open: 4, 6, 7, 12–15, 16 (off-LAN and after a reboot), 17–21.
|
||||
|
||||
## Check on the headset (in order)
|
||||
|
||||
@@ -70,12 +75,10 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a r
|
||||
`ssh frame 'command -v wl-copy xclip rsync flatpak'`.
|
||||
10. **Can Flatpaks be installed `--user` over SSH, and do they appear in the
|
||||
headset's desktop?** Test with `./scripts/install-apps.sh remmina`.
|
||||
11. **Remmina → macOS Screen Sharing:** does it connect, and is it usable at
|
||||
Retina resolutions? Is the pre-seeded profile path
|
||||
(`~/.var/app/org.remmina.Remmina/data/remmina/`) the one Remmina
|
||||
actually reads?
|
||||
12. **Moonlight Flatpak (aarch64) + Sunshine on macOS:** worth trying only if
|
||||
VNC is too slow.
|
||||
11. ~~**Remmina → macOS Screen Sharing**~~: answered 2026-09-27; see above
|
||||
and [streaming.md](streaming.md).
|
||||
12. **Moonlight Flatpak (aarch64) + Sunshine on macOS:** VNC works but is
|
||||
noticeably laggy, so this is worth trying.
|
||||
13. **KDE Connect**: is it preinstalled or installable on the Frame, and does
|
||||
it pair with KDE Connect for macOS?
|
||||
14. **Bluetooth keyboard pairing** on the Frame, for the rare times you do need
|
||||
@@ -86,8 +89,9 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a r
|
||||
runs as a lingering user service with no sudo; see [tailscale.md](tailscale.md).
|
||||
Still open: reaching the Frame from outside the home network, and the service
|
||||
starting after a reboot.
|
||||
17. **Floating panels in the headset** (see [panels.md](panels.md)): do the
|
||||
panels from `panel-on-frame.sh` show up, take input, and offer **Float in
|
||||
17. **Floating panels in the headset** (see [panels.md](panels.md)): panels
|
||||
from `panel-on-frame.sh` show up and take controller input (verified
|
||||
2026-09-27 with `mac-screen`). Still open: do they offer **Float in
|
||||
World** / **Move** / **Size**? Do floating positions survive closing and
|
||||
reopening the app, or a reboot?
|
||||
18. **`LEPTON_NO_CLEANUP=1 %command%`** as Lepton Development's launch
|
||||
@@ -129,8 +133,6 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a r
|
||||
|
||||
- `/home` and `/etc` persist across Frame OS updates. This is inferred from
|
||||
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
|
||||
|
||||
@@ -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**) |
|
||||
|
||||
@@ -34,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. |
|
||||
@@ -47,11 +47,38 @@ 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.)
|
||||
|
||||
**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:
|
||||
|
||||
```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
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
Goal: open a web VR180 or 360 player (DeoVR and DL8 embeds, WebXR samples),
|
||||
press its VR button, and watch in 3D in the headset.
|
||||
|
||||
A standalone, public version of this build (build script, the SO_PEERCRED
|
||||
patch, and a Frame-side installer that adds "Chromium XR" to the Steam library)
|
||||
is at [saphid/chromium-webxr-steam-frame](https://github.com/saphid/chromium-webxr-steam-frame).
|
||||
The build and installer now live in their own public repo,
|
||||
[saphid/chromium-webxr-steam-frame](https://github.com/saphid/chromium-webxr-steam-frame):
|
||||
a build script for an x86-64 Linux host, the SO_PEERCRED patch, and a
|
||||
Frame-side installer that adds "Chromium XR" to the Steam library. This page
|
||||
keeps the findings and what was verified on this Frame.
|
||||
|
||||
## Why Flathub Chromium can't
|
||||
|
||||
@@ -45,76 +47,52 @@ gets both.
|
||||
SteamVR (`bin/linuxarm64/vrclient.so`, `VALVE_runtime_is_steamvr`). The
|
||||
Linux backend uses Vulkan (`XR_USE_GRAPHICS_API_VULKAN`).
|
||||
|
||||
## Building it
|
||||
## Building and installing it
|
||||
|
||||
[`scripts/build-chromium-xr.sh`](../scripts/build-chromium-xr.sh)
|
||||
cross-compiles arm64 Linux Chromium on an x64 Linux host. It doesn't need
|
||||
sudo: the arm64 sysroot comes from Chromium's own script. It needs about
|
||||
90 GB of disk. It shallow-fetches the CL ref (patchset 44), runs
|
||||
`gclient sync --no-history`, installs the sysroot, applies one extra seccomp
|
||||
fix (below), builds `chrome` with `symbol_level=0` and proprietary codecs, and
|
||||
packs `chromium-xr-arm64.tar.xz` (about 145 MB, GPU libraries included).
|
||||
Progress is logged to `~/chromium-xr/stage`. The build aborts if the disk
|
||||
holding `~/chromium-xr` drops below 12 GB free.
|
||||
Follow the [public repo's README](https://github.com/saphid/chromium-webxr-steam-frame#build).
|
||||
In short: `build/build.sh` on an x64 Linux host (no sudo, about 90 GB of
|
||||
disk) produces `chromium-xr-arm64.tar.xz` (about 145 MB), and
|
||||
`frame/install.sh` on the Frame unpacks it to `~/chromium-xr`, installs the
|
||||
`chromium-xr` launcher in `~/.local/bin`, and adds the Steam library shortcut
|
||||
through the Steam client's DevTools port, the same way as T3 Code
|
||||
([apks.md](apks.md)). Launching the shortcut gives Chromium its own panel,
|
||||
`valve.steam.desktopgame.<appid>`, like any other app.
|
||||
|
||||
First run, 2026-09-25, on a 12-core, 31 GB x64 Linux box: 9 h 33 min for
|
||||
First build, 2026-09-25, on a 12-thread, 31 GB x64 Linux box: 9 h 33 min for
|
||||
94,835 steps, giving Chromium 156.0.8071.0. A rebuild after a one-file change
|
||||
takes under a minute, plus about 4 minutes to repack.
|
||||
|
||||
**The extra fix.** The CL's XR seccomp policy refuses `getsockopt`. SteamVR's
|
||||
To debug from the Mac, launch it as a panel with DevTools on the Frame
|
||||
(verified 2026-09-27):
|
||||
`scripts/panel-on-frame.sh -- '~/.local/bin/chromium-xr' --remote-debugging-port=9223 URL`
|
||||
([panels.md](panels.md)). DevTools has no authentication. It listens on
|
||||
loopback, but with the userspace Tailscale from [tailscale.md](tailscale.md)
|
||||
running, loopback ports are reachable from your tailnet. Close the browser
|
||||
when you're done. Chromium runs one browser per profile, so close the
|
||||
Steam-launched one first or the flag is ignored.
|
||||
|
||||
**The SO_PEERCRED fix.** The XR seccomp policy refuses `getsockopt`. SteamVR's
|
||||
client calls `getsockopt(SOL_SOCKET, SO_PEERCRED)` inside `xrCreateInstance`,
|
||||
so the XR process died with a seccomp crash (arm64 syscall 209). The script
|
||||
allows that one option. That's needed but not enough: `launch` still turns
|
||||
so the XR process died with a seccomp crash (arm64 syscall 209). The patch
|
||||
allows that one option. It's needed but not enough: the launcher still turns
|
||||
seccomp off (below), so the patch only matters once that's fixed too.
|
||||
|
||||
## Running it on the Frame
|
||||
**Seccomp is off.** The launcher passes `--disable-seccomp-filter-sandbox`.
|
||||
With the XR seccomp policy on, SteamVR's client reads `/proc/self/status`
|
||||
through Chrome's file broker and gets the broker's pid. SteamVR then binds
|
||||
the app to the wrong process ("Unable to init path manager:
|
||||
VRInitError_Init_Internal") and `xrCreateInstance` fails. The broker can't
|
||||
answer `/proc/self` for another process, so fixing this needs a change in
|
||||
Chromium's broker client or in the CL. The namespace sandbox stays on, but
|
||||
seccomp is off for every process, so use this profile for VR sites rather
|
||||
than everyday browsing.
|
||||
|
||||
[`scripts/chromium-xr.sh`](../scripts/chromium-xr.sh):
|
||||
|
||||
```sh
|
||||
BUILD_HOST=my-linux-box scripts/chromium-xr.sh install # your build host; scp, unpack to ~/chromium-xr
|
||||
scripts/chromium-xr.sh launch [URL] # its own VR panel, --enable-features=OpenXR
|
||||
scripts/chromium-xr.sh steam # adds "Chromium XR" to the Steam library
|
||||
scripts/chromium-xr.sh check # prints isSessionSupported('immersive-vr')
|
||||
```
|
||||
|
||||
It runs natively, not as a Flatpak. `launch` opens it as its own panel on
|
||||
gamescope's X display, the same way as [`panel-on-frame.sh`](../scripts/panel-on-frame.sh) ([panels.md](panels.md)), so
|
||||
the Plasma desktop doesn't need to be open. It uses its own profile
|
||||
(`~/.config/chromium-xr`) and DevTools on loopback port 9223, so it doesn't
|
||||
collide with the Flatpak's 9222. When a page enters VR, Chrome asks
|
||||
**Allow VR?** in the browser panel; choose *Allow this time* or *Allow while
|
||||
visiting the site*.
|
||||
|
||||
Both ways of starting it run
|
||||
[`frame/chromium-xr/launch.sh`](../frame/chromium-xr/launch.sh), copied to
|
||||
`~/Applications/ChromiumXR/launch.sh` on the Frame, which holds Chrome's flags.
|
||||
It sits outside `~/chromium-xr` so `install` doesn't delete it.
|
||||
|
||||
**From the Steam library (verified 2026-09-26).** `steam` adds a non-Steam
|
||||
shortcut called "Chromium XR" (with the Flathub Chromium icon, if that's
|
||||
installed) through the Steam client's DevTools port, the same way as T3 Code
|
||||
([apks.md](apks.md)), without restarting Steam. It saves the app id in
|
||||
`~/Applications/ChromiumXR/shortcut-appid`, so rerunning it, even after you
|
||||
rename the shortcut in the library, doesn't add a second one. On this Frame
|
||||
the shortcut app id is 2240749789. Launching it from the library gives
|
||||
Chromium its own panel, `valve.steam.desktopgame.2240749789`, like any other
|
||||
app. A Steam launch doesn't open a DevTools port, so `check` needs `launch`.
|
||||
Chromium runs one browser per profile: while the Steam-launched one is open,
|
||||
`launch` opens its URL in that window, without DevTools, then prints
|
||||
`failed: ... exited` because no new window appeared. Close it first.
|
||||
|
||||
**Seccomp is off.** The wrapper passes `--disable-seccomp-filter-sandbox`. With
|
||||
the XR seccomp policy on, SteamVR's client reads `/proc/self/status` through
|
||||
Chrome's file broker and gets the broker's pid. SteamVR then binds the app to
|
||||
the wrong process ("Unable to init path manager: VRInitError_Init_Internal")
|
||||
and `xrCreateInstance` fails. The broker can't answer `/proc/self` for another
|
||||
process, so fixing this needs a change in Chromium's broker client or in the
|
||||
CL. The namespace sandbox stays on, but seccomp is off for every process, so
|
||||
use this profile for VR sites rather than everyday browsing. DevTools on
|
||||
port 9223 has no authentication. It listens on loopback, but with the
|
||||
userspace Tailscale from [tailscale.md](tailscale.md) running, loopback ports
|
||||
are reachable from your tailnet. Close the browser when you're done.
|
||||
**Upstream (2026-09-27).** CL 8441736 (the XR sandbox) has merged into
|
||||
Chromium, still refusing `getsockopt`; CL 8132979 is still in review. Valve
|
||||
and the CLs' author are working on Steam Frame support
|
||||
([utzcoz/chromium-webxr-linux#5](https://github.com/utzcoz/chromium-webxr-linux/issues/5)).
|
||||
Both sandbox problems above, with the patch, are reported in
|
||||
[utzcoz/chromium-webxr-linux#7](https://github.com/utzcoz/chromium-webxr-linux/issues/7).
|
||||
|
||||
**Verified 2026-09-26** (Frame BUILD_ID 20260922.6101926, SteamVR 2.17.10,
|
||||
this build):
|
||||
@@ -138,9 +116,26 @@ this build):
|
||||
[`webxr_vr_video`](https://threejs.org/examples/webxr_vr_video.html) demo,
|
||||
a stereo 360 video, played in 3D after pressing Enter VR.
|
||||
|
||||
**Not verified yet:**
|
||||
- **Launched from the Steam library, verified remotely 2026-09-27** with
|
||||
nobody wearing the headset (standby workaround in
|
||||
[how-the-frame-works.md](how-the-frame-works.md)). The installer's Steam
|
||||
shortcut starts Chromium, and SteamVR takes it as scene app
|
||||
`steam.app.<shortcut id>`. A minimal WebXR session that clears every frame
|
||||
to red ran at about 75 frames per second, and the stereo headset capture
|
||||
showed both eyes solid red. Steam preloads its overlay
|
||||
(`gameoverlayrenderer.so`), which crashed Chromium's zygote about 30 s after
|
||||
a Steam launch. The public repo's launcher now removes it from
|
||||
`LD_PRELOAD`. With the headset outside its playspace, SteamVR shows
|
||||
passthrough wherever the page leaves transparent pixels.
|
||||
|
||||
- Frame rate and dropped frames during playback (nothing was measured; it
|
||||
looked fine).
|
||||
- Third-party VR180 players (DeoVR and DL8 web embeds).
|
||||
- Controller and hand input inside a WebXR page.
|
||||
- **Frame rate and input, measured 2026-09-27** (standby workaround, red
|
||||
test session): 72 fps with every frame at 13.9–14 ms over 16 s, and SteamVR
|
||||
dropped frames only at startup. The right controller showed up as an
|
||||
`oculus-touch` `tracked-pointer` with an `xr-standard` gamepad and a
|
||||
25-joint hand, with poses on every frame. A real squeeze reached the page
|
||||
as `squeezestart`/`squeeze`. Haptics aren't exposed (no actuators).
|
||||
Details are in the public repo's technical notes.
|
||||
|
||||
**Not verified yet:** trigger, thumbstick and face buttons, the left
|
||||
controller, bare-hand tracking, and third-party VR180 players (DeoVR and
|
||||
DL8 web embeds).
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Frame-side: start the WebXR Chromium build (~/chromium-xr). The Steam
|
||||
# library shortcut "Chromium XR" runs this, and so does
|
||||
# `scripts/chromium-xr.sh launch` (which adds a DevTools port). Extra
|
||||
# arguments go to Chrome, so a URL opens that page.
|
||||
#
|
||||
# Lives in ~/Applications/ChromiumXR, outside ~/chromium-xr, so reinstalling
|
||||
# the build doesn't delete it.
|
||||
set -euo pipefail
|
||||
|
||||
CHROME="$HOME/chromium-xr/chrome"
|
||||
[[ -x "$CHROME" ]] || { echo "launch.sh: no build at $CHROME (run chromium-xr.sh install)" >&2; exit 1; }
|
||||
|
||||
# Without --no-first-run and --password-store=basic, startup can stop at a
|
||||
# first-run or keyring prompt.
|
||||
# --disable-seccomp-filter-sandbox: under the XR seccomp policy, SteamVR's
|
||||
# client reads /proc/self/status through the file broker, gets the broker's
|
||||
# pid, and SteamVR binds the app to the wrong process, so xrCreateInstance
|
||||
# fails. The namespace sandbox stays on, but seccomp is off for every
|
||||
# process, so keep this profile for VR sites.
|
||||
exec "$CHROME" \
|
||||
--user-data-dir="$HOME/.config/chromium-xr" \
|
||||
--enable-features=OpenXR \
|
||||
--ozone-platform=x11 \
|
||||
--no-first-run --no-default-browser-check --password-store=basic \
|
||||
--disable-seccomp-filter-sandbox \
|
||||
"$@"
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs
|
||||
# (8441736 + 8132979, bug 506004811), plus a one-option seccomp fix, so WebXR
|
||||
# immersive-vr works on the Frame.
|
||||
# Needs ~90 GB free, no sudo. Takes hours; run it detached on the build host:
|
||||
# scp scripts/build-chromium-xr.sh buildhost:chromium-xr/build.sh
|
||||
# ssh buildhost 'cd ~/chromium-xr && tmux new -d -s chromium-xr "./build.sh > build.log 2>&1"'
|
||||
# Progress: ~/chromium-xr/stage. Output: ~/chromium-xr/chromium-xr-arm64.tar.xz,
|
||||
# which scripts/chromium-xr.sh install copies to the Frame.
|
||||
# Re-running resumes: existing checkout and out/XR are reused.
|
||||
set -euo pipefail
|
||||
W=~/chromium-xr
|
||||
cd "$W"
|
||||
stage(){ echo "$(date -Is) $*" | tee -a "$W/stage"; }
|
||||
# Returns non-zero below 12 GB free; set -e turns that into an exit at top level.
|
||||
guard(){ avail=$(df --output=avail -BG "$W" | tail -n 1 | tr -dc 0-9); if [ "$avail" -lt 12 ]; then stage "ABORT: only ${avail}G free for $W"; return 3; fi; }
|
||||
[ -d depot_tools ] || git clone -q https://chromium.googlesource.com/chromium/tools/depot_tools.git
|
||||
export PATH="$W/depot_tools:$PATH" DEPOT_TOOLS_UPDATE=1 DEPOT_TOOLS_METRICS=0
|
||||
CL_REF=refs/changes/79/8132979/44
|
||||
if [ ! -f .gclient ]; then
|
||||
cat > .gclient <<'G'
|
||||
solutions = [{ "name": "src", "url": "https://chromium.googlesource.com/chromium/src.git",
|
||||
"managed": False, "custom_deps": {}, "custom_vars": { "checkout_nacl": False } }]
|
||||
target_os = ["linux"]
|
||||
target_cpu = ["arm64"]
|
||||
G
|
||||
fi
|
||||
# Keyed on a real commit, so an interrupted first fetch is retried on re-run.
|
||||
if ! git -C src rev-parse -q --verify HEAD >/dev/null 2>&1; then
|
||||
stage "clone src at $CL_REF"
|
||||
mkdir -p src
|
||||
[ -d src/.git ] || git -C src init -q
|
||||
git -C src remote get-url origin >/dev/null 2>&1 || git -C src remote add origin https://chromium.googlesource.com/chromium/src.git
|
||||
git -C src fetch -q --depth=1 origin "$CL_REF"
|
||||
git -C src checkout -q FETCH_HEAD
|
||||
fi
|
||||
guard
|
||||
stage "src at $(git -C src log -1 --format='%h %s')"
|
||||
rev=$(git -C src rev-parse HEAD)
|
||||
# Sync once per revision: once the patch below is applied, gclient sync
|
||||
# refuses to run on the modified checkout, so re-runs must skip it.
|
||||
if [ "$(cat "$W/synced" 2>/dev/null)" != "$rev" ]; then
|
||||
stage "gclient sync"
|
||||
gclient sync --nohooks --no-history -D --shallow --revision "src@$rev" -j 8
|
||||
guard
|
||||
stage "runhooks"
|
||||
gclient runhooks
|
||||
src/build/linux/sysroot_scripts/install-sysroot.py --arch=arm64
|
||||
echo "$rev" > "$W/synced"
|
||||
fi
|
||||
guard
|
||||
cd src
|
||||
# CL 8441736's XR seccomp policy refuses getsockopt, and SteamVR's IPC client
|
||||
# calls getsockopt(SO_PEERCRED) inside xrCreateInstance, which crashes the XR
|
||||
# process (verified on the Frame 2026-09-26). Allow only that option.
|
||||
IFS= read -r -d '' PEERCRED_PATCH <<'P' || true
|
||||
diff --git a/sandbox/policy/linux/bpf_xr_policy_linux.cc b/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||
index 435e13d396..297453f582 100644
|
||||
--- a/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||
+++ b/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "sandbox/linux/system_headers/linux_syscalls.h"
|
||||
#include "sandbox/policy/linux/sandbox_linux.h"
|
||||
|
||||
+using sandbox::bpf_dsl::AllOf;
|
||||
using sandbox::bpf_dsl::Allow;
|
||||
using sandbox::bpf_dsl::Arg;
|
||||
using sandbox::bpf_dsl::Error;
|
||||
@@ -27,8 +28,8 @@ XrProcessPolicy::~XrProcessPolicy() = default;
|
||||
ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
|
||||
switch (system_call_number) {
|
||||
// The runtime reaches its compositor over an AF_UNIX socket and passes fds
|
||||
- // with SCM_RIGHTS, neither of which the GPU policy allows. get/setsockopt
|
||||
- // stay disallowed; add a narrow level/optname restriction if ever needed.
|
||||
+ // with SCM_RIGHTS, neither of which the GPU policy allows. setsockopt
|
||||
+ // stays disallowed; getsockopt is limited to SO_PEERCRED below.
|
||||
#if defined(__NR_getpeername)
|
||||
case __NR_getpeername:
|
||||
#endif
|
||||
@@ -49,6 +50,16 @@ ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
|
||||
case __NR_get_robust_list:
|
||||
#endif
|
||||
return Allow();
|
||||
+#if defined(__NR_getsockopt)
|
||||
+ case __NR_getsockopt: {
|
||||
+ // SteamVR's IPC client checks who is on the other end of its socket
|
||||
+ // with SO_PEERCRED. Nothing else is readable.
|
||||
+ const Arg<int> level(1);
|
||||
+ const Arg<int> optname(2);
|
||||
+ return If(AllOf(level == SOL_SOCKET, optname == SO_PEERCRED), Allow())
|
||||
+ .Else(Error(EPERM));
|
||||
+ }
|
||||
+#endif
|
||||
#if defined(__NR_kill)
|
||||
case __NR_kill: {
|
||||
// SteamVR probes its sibling processes for liveness with kill(pid, 0).
|
||||
P
|
||||
if ! printf '%s\n' "$PEERCRED_PATCH" | git apply --reverse --check 2>/dev/null; then
|
||||
printf '%s\n' "$PEERCRED_PATCH" | git apply
|
||||
stage "applied SO_PEERCRED patch"
|
||||
fi
|
||||
mkdir -p out/XR
|
||||
cat > out/XR/args.gn <<'A'
|
||||
target_os = "linux"
|
||||
target_cpu = "arm64"
|
||||
is_debug = false
|
||||
is_official_build = false
|
||||
is_component_build = false
|
||||
dcheck_always_on = false
|
||||
symbol_level = 0
|
||||
blink_symbol_level = 0
|
||||
v8_symbol_level = 0
|
||||
proprietary_codecs = true
|
||||
ffmpeg_branding = "Chrome"
|
||||
use_remoteexec = false
|
||||
use_siso = true
|
||||
treat_warnings_as_errors = false
|
||||
A
|
||||
stage "gn gen"
|
||||
gn gen out/XR
|
||||
gn args out/XR --list=enable_openxr --short | tee -a "$W/stage"
|
||||
stage "build"
|
||||
( while sleep 600; do guard || { pkill -u "$(id -u)" -f "siso|ninja"; exit 3; }; done ) &
|
||||
GUARD=$!
|
||||
trap 'kill $GUARD 2>/dev/null || true' EXIT
|
||||
autoninja -C out/XR chrome chrome_sandbox chrome_crashpad_handler
|
||||
stage "package"
|
||||
cd out/XR
|
||||
files=(chrome chrome_sandbox chrome_crashpad_handler *.pak *.bin icudtl.dat locales)
|
||||
# GPU libraries aren't produced by every config; pack the ones that exist.
|
||||
for f in libEGL.so libGLESv2.so libvk_swiftshader.so libvulkan.so.1 vk_swiftshader_icd.json; do
|
||||
[ -e "$f" ] && files+=("$f")
|
||||
done
|
||||
tar -cJf "$W/chromium-xr-arm64.tar.xz" "${files[@]}"
|
||||
stage "DONE $(ls -la $W/chromium-xr-arm64.tar.xz)"
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac-side: install and launch the WebXR-enabled Chromium build on the Frame.
|
||||
#
|
||||
# Flathub Chromium can't enter immersive WebXR on Linux: upstream only wires
|
||||
# the OpenXR device on Windows (see docs/webxr-chromium.md). This deploys an
|
||||
# arm64 build with the Linux OpenXR CLs, made on a Linux host by
|
||||
# scripts/build-chromium-xr.sh, into ~/chromium-xr on the Frame (not a
|
||||
# Flatpak, so SteamVR's sockets and the XR sandbox work unmodified).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/chromium-xr.sh install [TARBALL] # default: scp from $BUILD_HOST
|
||||
# scripts/chromium-xr.sh launch [URL] # opens as its own panel in the headset
|
||||
# scripts/chromium-xr.sh steam # adds "Chromium XR" to the Steam library
|
||||
# scripts/chromium-xr.sh check # isSessionSupported via DevTools
|
||||
set -euo pipefail
|
||||
|
||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||
BUILD_HOST=${BUILD_HOST:-}
|
||||
BUILD_TARBALL=${BUILD_TARBALL:-chromium-xr/chromium-xr-arm64.tar.xz}
|
||||
DEVTOOLS_PORT=${DEVTOOLS_PORT:-9223}
|
||||
STEAM_NAME=${STEAM_NAME:-Chromium XR}
|
||||
here=${0:A:h}
|
||||
wrapper='~/Applications/ChromiumXR/launch.sh'
|
||||
|
||||
# frame/chromium-xr/launch.sh holds Chrome's flags; both launch paths run it.
|
||||
push_wrapper() {
|
||||
# Write then rename, so a dropped connection can't leave a torn script.
|
||||
ssh "$FRAME_ALIAS" 'mkdir -p ~/Applications/ChromiumXR && cd ~/Applications/ChromiumXR && cat > launch.sh.new && chmod +x launch.sh.new && mv launch.sh.new launch.sh' \
|
||||
< "$here/../frame/chromium-xr/launch.sh"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
install)
|
||||
tarball=${2:-}
|
||||
if [[ -z "$tarball" ]]; then
|
||||
[[ -n "$BUILD_HOST" ]] || { print -u2 "Pass a tarball, or set BUILD_HOST to the build machine"; exit 2; }
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
tarball=$tmp/chromium-xr-arm64.tar.xz
|
||||
scp -q "$BUILD_HOST:$BUILD_TARBALL" "$tarball"
|
||||
fi
|
||||
ssh "$FRAME_ALIAS" 'rm -rf ~/chromium-xr.new && mkdir -p ~/chromium-xr.new'
|
||||
ssh "$FRAME_ALIAS" 'tar -xJf - -C ~/chromium-xr.new' < "$tarball"
|
||||
# Check the new build runs before replacing the old one.
|
||||
ssh "$FRAME_ALIAS" '~/chromium-xr.new/chrome --version && rm -rf ~/chromium-xr && mv ~/chromium-xr.new ~/chromium-xr'
|
||||
;;
|
||||
launch)
|
||||
# Its own VR panel on gamescope's X display, so the Plasma desktop doesn't
|
||||
# need to be open. DevTools is only on for this path (for `check`).
|
||||
push_wrapper
|
||||
exec "$here/panel-on-frame.sh" --name chromium-xr -- "$wrapper" \
|
||||
--remote-debugging-port="$DEVTOOLS_PORT" \
|
||||
"${2:-https://immersive-web.github.io/webxr-samples/}"
|
||||
;;
|
||||
steam)
|
||||
# A non-Steam shortcut, added through the Steam client's DevTools port
|
||||
# without restarting Steam (see docs/apks.md). Launching it from the
|
||||
# library gives Chromium its own panel like any game. Safe to rerun: it
|
||||
# refreshes the wrapper and only adds the shortcut if it's missing.
|
||||
ssh "$FRAME_ALIAS" 'test -x ~/chromium-xr/chrome' ||
|
||||
{ print -u2 "No build in ~/chromium-xr on the Frame: run 'chromium-xr.sh install' first"; exit 1; }
|
||||
push_wrapper
|
||||
# The app id is kept next to the wrapper, so renaming the shortcut in the
|
||||
# library doesn't make a rerun add a second one.
|
||||
shortcuts=$here/../frame/android/steam_shortcuts.py
|
||||
home=$(ssh "$FRAME_ALIAS" 'printf %s "$HOME"')
|
||||
saved=$(ssh "$FRAME_ALIAS" 'cat ~/Applications/ChromiumXR/shortcut-appid 2>/dev/null || true')
|
||||
existing=$(ssh "$FRAME_ALIAS" python3 - list < "$shortcuts" |
|
||||
python3 -c 'import json,sys
|
||||
apps = json.load(sys.stdin)
|
||||
ids = [a["appid"] for a in apps if str(a["appid"]) == sys.argv[2]] or [a["appid"] for a in apps if a["name"] == sys.argv[1]]
|
||||
print(ids[0] if ids else "")' "$STEAM_NAME" "$saved")
|
||||
if [[ -n "$existing" ]]; then
|
||||
print -r -- "The shortcut is already in the Steam library (app id $existing)"
|
||||
else
|
||||
icon=''
|
||||
for dir in /var/lib/flatpak '~/.local/share/flatpak'; do
|
||||
candidate=$dir/exports/share/icons/hicolor/256x256/apps/org.chromium.Chromium.png
|
||||
if ssh "$FRAME_ALIAS" "test -f $candidate"; then icon=${candidate/#\~/$home}; break; fi
|
||||
done
|
||||
existing=$(ssh "$FRAME_ALIAS" python3 - add ${(q)STEAM_NAME} ${(q)home}/Applications/ChromiumXR/launch.sh ${(q)home} ${(q)icon} < "$shortcuts")
|
||||
[[ "$existing" == <-> ]] || { print -u2 -r -- "Steam didn't return a shortcut app id: $existing"; exit 1; }
|
||||
print -r -- "Added $STEAM_NAME to the Steam library (shortcut app id $existing)"
|
||||
fi
|
||||
ssh "$FRAME_ALIAS" "printf '%s\n' $existing > ~/Applications/ChromiumXR/shortcut-appid"
|
||||
;;
|
||||
check)
|
||||
# DevTools listens on the Frame's loopback only; evaluate there.
|
||||
ssh "$FRAME_ALIAS" python3 - "$DEVTOOLS_PORT" <<'EOF'
|
||||
import json, sys, urllib.request, base64, os, socket, struct
|
||||
port = int(sys.argv[1])
|
||||
tabs = json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json", timeout=10))
|
||||
page = next((t for t in tabs if t["type"] == "page"), None)
|
||||
if page is None:
|
||||
sys.exit("no open page: run 'chromium-xr.sh launch' first")
|
||||
path = page["webSocketDebuggerUrl"].split(f":{port}", 1)[1]
|
||||
s = socket.create_connection(("127.0.0.1", port), timeout=30)
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
s.sendall(f"GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n".encode())
|
||||
s.recv(4096)
|
||||
msg = json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {
|
||||
"expression": "navigator.xr ? navigator.xr.isSessionSupported('immersive-vr') : 'no navigator.xr'",
|
||||
"awaitPromise": True}}).encode()
|
||||
mask = os.urandom(4)
|
||||
hdr = bytes([0x81]) + (bytes([0x80 | len(msg)]) if len(msg) < 126
|
||||
else bytes([0x80 | 126]) + struct.pack(">H", len(msg)))
|
||||
s.sendall(hdr + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(msg)))
|
||||
buf = b""
|
||||
reply = None
|
||||
while reply is None:
|
||||
chunk = s.recv(65536)
|
||||
if not chunk:
|
||||
sys.exit("DevTools closed the connection")
|
||||
buf += chunk
|
||||
# Consume every complete frame already buffered before reading again.
|
||||
while len(buf) >= 2:
|
||||
n = buf[1] & 0x7F
|
||||
off = 2
|
||||
if n == 126:
|
||||
if len(buf) < 4:
|
||||
break
|
||||
n, off = struct.unpack(">H", buf[2:4])[0], 4
|
||||
elif n == 127:
|
||||
if len(buf) < 10:
|
||||
break
|
||||
n, off = struct.unpack(">Q", buf[2:10])[0], 10
|
||||
if len(buf) < off + n:
|
||||
break
|
||||
frame, buf = buf[off:off + n], buf[off + n:]
|
||||
msg = json.loads(frame)
|
||||
if msg.get("id") == 1:
|
||||
reply = msg
|
||||
break
|
||||
print("immersive-vr supported:", reply["result"]["result"].get("value"))
|
||||
EOF
|
||||
;;
|
||||
*) sed -n '2,14p' "$0"; exit 2 ;;
|
||||
esac
|
||||
@@ -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,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"
|
||||
@@ -35,7 +35,7 @@ def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign
|
||||
foreign_label adds a non-android `label` attribute after android:label.
|
||||
"""
|
||||
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
|
||||
'application', package, 'junk', 'label'] # the second 'label' has no android id
|
||||
'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
|
||||
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
|
||||
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
|
||||
|
||||
@@ -48,7 +48,8 @@ def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign
|
||||
none = 0xffffffff
|
||||
chunks = (pool(strings) + resmap
|
||||
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
|
||||
(2, none, frame_apk.T_REF, version_ref)])
|
||||
(2, none, frame_apk.T_REF, version_ref),
|
||||
(11, none, frame_apk.T_INT_DEC, 210)])
|
||||
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
|
||||
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
|
||||
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
|
||||
@@ -108,6 +109,7 @@ class ApkInfo(unittest.TestCase):
|
||||
self.assertEqual(info['package'], 'com.example.demo')
|
||||
self.assertEqual(info['label'], 'App label') # the default, not French
|
||||
self.assertEqual(info['version'], '2.1')
|
||||
self.assertEqual(info['version_code'], 210)
|
||||
self.assertEqual(info['min_sdk'], 26)
|
||||
self.assertEqual(info['abis'], ['arm64-v8a', 'x86_64'])
|
||||
self.assertEqual(info['icon_png'], b'hi') # largest-density PNG, skipping the XML icon
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Offline version lookup with small index-v2 fixtures."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
|
||||
import frame_apk_versions as versions
|
||||
import frame_catalog
|
||||
import frame_android
|
||||
|
||||
|
||||
def build(code, sdk=30, abis=None):
|
||||
return {'manifest': {'versionName': str(code), 'versionCode': code,
|
||||
'usesSdk': {'minSdkVersion': sdk}, 'nativecode': abis or []},
|
||||
'file': {'name': f'/example_{code}.apk', 'sha256': str(code).zfill(64)}}
|
||||
|
||||
|
||||
class VersionsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
data = os.path.join(self.tmp.name, 'data')
|
||||
os.mkdir(data)
|
||||
for name, builds in [('index-v2.json', [build(5, 33), build(4, abis=['x86_64']),
|
||||
build(3, abis=['arm64-v8a', 'x86_64']), build(2)]),
|
||||
('index-v2.archive.json', [build(1, 21), build(2)]),
|
||||
('index-v2.izzy.json', [])]:
|
||||
with open(os.path.join(data, name), 'w') as f:
|
||||
json.dump({'packages': {'org.example.app': {'versions': {str(i): b for i, b in enumerate(builds)}}}}, f)
|
||||
self.enter_patch(patch.object(frame_catalog, 'CATALOG', self.tmp.name))
|
||||
self.enter_patch(patch.dict(os.environ, {'FRAME_CONTROL_APP': ''}))
|
||||
self.network = self.enter_patch(patch.object(frame_catalog.urllib.request, 'urlopen', side_effect=AssertionError('network used')))
|
||||
|
||||
def enter_patch(self, p):
|
||||
result = p.start()
|
||||
self.addCleanup(p.stop)
|
||||
return result
|
||||
|
||||
def test_filter_order_archive_and_dedup(self):
|
||||
result = versions.alternatives('org.example.app')
|
||||
self.assertEqual([v['version_code'] for v in result['versions']], [3, 2, 1])
|
||||
self.assertEqual(result['versions'][-1]['source'], 'F-Droid archive')
|
||||
self.assertEqual(result['versions'][-1]['url'], 'https://f-droid.org/archive/example_1.apk')
|
||||
self.assertEqual(result['errors'], [])
|
||||
self.network.assert_not_called()
|
||||
|
||||
def test_current_version(self):
|
||||
result = versions.alternatives('org.example.app', 3)
|
||||
self.assertEqual([v['version_code'] for v in result['versions']], [2, 1])
|
||||
|
||||
def test_fallback(self):
|
||||
result = versions.alternatives('com.missing.app')
|
||||
self.assertEqual(result['versions'], [])
|
||||
self.assertEqual([v['source'] for v in result['links']], ['APKMirror', 'APKPure', 'Uptodown', 'F-Droid', 'GitHub'])
|
||||
self.assertTrue(all('com.missing.app' in v['url'] for v in result['links']))
|
||||
self.assertIn('Android 11', result['note'])
|
||||
self.assertIn('arm64-v8a', result['note'])
|
||||
|
||||
def test_failed_indexes_keep_search_links(self):
|
||||
with patch.object(frame_catalog, 'load_index', side_effect=OSError('offline')):
|
||||
result = versions.alternatives('org.example.app')
|
||||
self.assertEqual(len(result['errors']), 3)
|
||||
self.assertEqual(len(result['links']), 5)
|
||||
|
||||
def test_no_compatible_versions(self):
|
||||
with patch.object(frame_catalog, 'load_index', return_value={}):
|
||||
result = versions.alternatives('org.example.app')
|
||||
self.assertEqual(result['versions'], [])
|
||||
self.assertEqual(len(result['links']), 5)
|
||||
|
||||
def test_reduction_memory_cache_and_refresh(self):
|
||||
repo = versions.REPOS[0][1]
|
||||
index = frame_catalog.load_index(repo)
|
||||
self.assertEqual([v['version_code'] for v in index['org.example.app']], [3, 2])
|
||||
self.assertEqual(set(index['org.example.app'][0]),
|
||||
{'version', 'version_code', 'min_sdk', 'abis', 'name', 'sha256'})
|
||||
raw = os.path.join(self.tmp.name, 'data', 'index-v2.json')
|
||||
self.assertTrue(os.path.exists(raw)) # the catalogue build reads it
|
||||
os.utime(raw, ns=(1, 1))
|
||||
with patch.object(frame_catalog.json, 'load', side_effect=AssertionError('reparsed')):
|
||||
self.assertIs(frame_catalog.load_index(repo), index)
|
||||
path = raw + '.installable-v1'
|
||||
with open(path, 'w') as f:
|
||||
json.dump({}, f)
|
||||
os.utime(path, ns=(1, 1))
|
||||
self.assertEqual(frame_catalog.load_index(repo, cached_only=True), {})
|
||||
payload = json.dumps({'packages': {'org.example.app': {'versions': {'x': build(9)}}}}).encode()
|
||||
with patch.object(frame_catalog.urllib.request, 'urlopen', return_value=io.BytesIO(payload)) as fetch:
|
||||
self.assertEqual(frame_catalog.load_index(repo)['org.example.app'][0]['version_code'], 9)
|
||||
fetch.assert_called_once()
|
||||
self.assertTrue(os.path.exists(raw))
|
||||
|
||||
def test_malformed_entries_are_skipped(self):
|
||||
raw = os.path.join(self.tmp.name, 'odd.json')
|
||||
with open(raw, 'w') as f:
|
||||
json.dump({'packages': {'a.b': {'versions': {'x': {'manifest': {}}, 'y': None, 'z': build(4)}},
|
||||
'c.d': {'versions': None}, 'e.f': []}}, f)
|
||||
self.assertEqual([v['version_code'] for v in frame_catalog._reduce_index(raw)['a.b']], [4])
|
||||
|
||||
def test_one_failing_repo_keeps_the_others(self):
|
||||
real = frame_catalog.load_index
|
||||
def load(repo, cached_only=False):
|
||||
if 'izzy' in repo:
|
||||
raise KeyError('file')
|
||||
return real(repo, cached_only=cached_only)
|
||||
with patch.object(frame_catalog, 'load_index', side_effect=load):
|
||||
result = versions.alternatives('org.example.app')
|
||||
self.assertEqual([v['version_code'] for v in result['versions']], [3, 2, 1])
|
||||
self.assertEqual(len(result['errors']), 1)
|
||||
|
||||
def test_newer_raw_index_outdates_reduced_copy(self):
|
||||
repo = versions.REPOS[0][1]
|
||||
frame_catalog.load_index(repo)
|
||||
raw = os.path.join(self.tmp.name, 'data', 'index-v2.json')
|
||||
with open(raw, 'w') as f:
|
||||
json.dump({'packages': {'org.example.app': {'versions': {'x': build(8)}}}}, f)
|
||||
os.utime(raw, ns=(time.time_ns() + 10**9,) * 2)
|
||||
self.assertEqual(frame_catalog.load_index(repo)['org.example.app'][0]['version_code'], 8)
|
||||
|
||||
def test_concurrent_requests_share_download(self):
|
||||
raw = os.path.join(self.tmp.name, 'data', 'index-v2.json')
|
||||
os.remove(raw)
|
||||
payload = json.dumps({'packages': {'org.example.app': {'versions': {'x': build(7)}}}}).encode()
|
||||
with patch.object(frame_catalog.urllib.request, 'urlopen', side_effect=lambda *a, **k: io.BytesIO(payload)) as fetch:
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
indexes = list(pool.map(frame_catalog.load_index, [versions.REPOS[0][1]] * 4))
|
||||
fetch.assert_called_once()
|
||||
self.assertTrue(all(index is indexes[0] for index in indexes))
|
||||
|
||||
def test_failed_refresh_preserves_cache(self):
|
||||
repo = versions.REPOS[0][1]
|
||||
index = frame_catalog.load_index(repo)
|
||||
path = os.path.join(self.tmp.name, 'data', 'index-v2.json.installable-v1')
|
||||
os.utime(path, ns=(1, 1))
|
||||
os.utime(os.path.join(self.tmp.name, 'data', 'index-v2.json'), ns=(1, 1))
|
||||
with patch.object(frame_catalog.urllib.request, 'urlopen', return_value=io.BytesIO(b'{')):
|
||||
with self.assertRaises(ValueError):
|
||||
frame_catalog.load_index(repo)
|
||||
self.assertEqual(frame_catalog.load_index(repo, cached_only=True), index)
|
||||
self.assertFalse(any(name.endswith('.part') for name in os.listdir(os.path.dirname(path))))
|
||||
|
||||
def test_stream_boundaries_and_invalid_index(self):
|
||||
raw = os.path.join(self.tmp.name, 'stream.json')
|
||||
with open(raw, 'w') as f:
|
||||
json.dump({'repo': {'description': 'é' * 70000}, 'packages': {
|
||||
'org.example.app': {'metadata': {'text': 'escaped " packages { }' * 6000},
|
||||
'versions': {'x': build(7)}}}, 'tail': {}}, f)
|
||||
self.assertEqual(frame_catalog._reduce_index(raw)['org.example.app'][0]['version_code'], 7)
|
||||
for invalid in ('{}', '{"packages": []}', '{"packages": {', '{"packages": {}} trailing'):
|
||||
with open(raw, 'w') as f:
|
||||
f.write(invalid)
|
||||
with self.assertRaises(ValueError):
|
||||
frame_catalog._reduce_index(raw)
|
||||
|
||||
def test_cap_and_preferred_build(self):
|
||||
records = [dict(version=str(i), version_code=i, min_sdk=21, abis=[],
|
||||
name='/app_%s.apk' % i, sha256=str(i)) for i in range(20)]
|
||||
records += [dict(records[-1], version_code=21, abis=['arm64-v8a'], name='/arm.apk'),
|
||||
dict(records[-1], version_code=22, abis=['arm64-v8a', 'x86_64'], name='/all.apk')]
|
||||
with patch.object(frame_catalog, 'load_index', return_value={'org.example.app': records}):
|
||||
result = versions.alternatives('org.example.app')
|
||||
self.assertEqual(result['total'], 22)
|
||||
self.assertEqual(len(result['versions']), 8)
|
||||
self.assertEqual(len({v['version'] for v in result['versions']}), 8)
|
||||
self.assertEqual([v['version_code'] for v in result['versions']], [21, 18, 17, 16, 15, 14, 13, 12])
|
||||
|
||||
def test_android_names_and_verdict(self):
|
||||
for sdk, name in [(23, 'Android 6.0'), (30, 'Android 11'), (32, 'Android 12L'), (33, 'Android 13'), (99, 'Android API 99')]:
|
||||
self.assertEqual(versions.android_name(sdk), name)
|
||||
info = {'package': 'org.example.app', 'label': 'Example', 'version': '5.0',
|
||||
'version_code': 50, 'min_sdk': 33, 'abis': ['arm64-v8a']}
|
||||
description = versions.describe(info)
|
||||
for text in ['org.example.app', '5.0', 'code 50', 'Android 13', 'arm64-v8a', 'cannot install']:
|
||||
self.assertIn(text, description)
|
||||
info.update(min_sdk=30, abis=[])
|
||||
self.assertIn('can install', versions.describe(info))
|
||||
info['abis'] = ['armeabi-v7a']
|
||||
self.assertIn('no arm64-v8a build', versions.describe(info))
|
||||
|
||||
def test_install_resolves_index_hash(self):
|
||||
versions.alternatives('org.example.app')
|
||||
with patch.object(versions, 'alternatives', side_effect=AssertionError('recomputed')), \
|
||||
patch.object(frame_catalog, 'fetch_apk', return_value='/tmp/example.apk') as fetch, \
|
||||
patch.object(frame_android, 'apk_info', return_value={'package': 'org.example.app', 'version_code': 1}), \
|
||||
patch.object(frame_android, 'install', return_value={'label': 'Example'}) as install:
|
||||
versions.install('org.example.app', 'https://f-droid.org/archive/example_1.apk')
|
||||
self.assertEqual(fetch.call_args[0][0]['h'], str(1).zfill(64))
|
||||
install.assert_called_once_with('/tmp/example.apk', source='F-Droid archive')
|
||||
with self.assertRaises(frame_android.FrameError):
|
||||
versions.install('org.example.app', 'https://evil.example/app.apk')
|
||||
|
||||
def test_install_checks_identity_without_network_refresh(self):
|
||||
versions.alternatives('org.example.app')
|
||||
for name in ('index-v2.json', 'index-v2.archive.json'):
|
||||
os.utime(os.path.join(self.tmp.name, 'data', name + '.installable-v1'), ns=(1, 1))
|
||||
for info in ({'package': 'wrong.package', 'version_code': 1},
|
||||
{'package': 'org.example.app', 'version_code': 99}):
|
||||
with patch.object(frame_catalog, 'fetch_apk', return_value='/tmp/example.apk'), \
|
||||
patch.object(frame_android, 'apk_info', return_value=info), \
|
||||
patch.object(frame_android, 'install') as install:
|
||||
with self.assertRaises(frame_android.FrameError):
|
||||
versions.install('org.example.app', 'https://f-droid.org/archive/example_1.apk')
|
||||
install.assert_not_called()
|
||||
self.network.assert_not_called()
|
||||
|
||||
|
||||
class UploadVersionsTest(unittest.TestCase):
|
||||
def test_endpoint_validation(self):
|
||||
import server
|
||||
for query in ('', 'package=', 'package=foo', 'package=a..b', 'package=a.1b',
|
||||
'package=a.b/path', 'package=a.b&package=c.d', 'package=a.b&code=-1',
|
||||
'package=a.b&code=x', 'package=a.b&code=', 'package=a.b&code=1&code=2'):
|
||||
handler = object.__new__(server.Handler)
|
||||
handler.path = '/api/apk-versions?' + query
|
||||
with patch.object(handler, 'local_request', return_value=True), \
|
||||
patch.object(handler, 'send_json') as reply, \
|
||||
patch.object(versions, 'alternatives') as lookup:
|
||||
handler.do_GET()
|
||||
self.assertEqual(reply.call_args[0][1], 400, query)
|
||||
lookup.assert_not_called()
|
||||
handler.path = '/api/apk-versions?package=org.example_app.demo&code=123'
|
||||
with patch.object(handler, 'local_request', return_value=True), \
|
||||
patch.object(handler, 'send_json') as reply, \
|
||||
patch.object(versions, 'alternatives', return_value={'total': 0}) as lookup:
|
||||
handler.do_GET()
|
||||
lookup.assert_called_once_with('org.example_app.demo', 123)
|
||||
reply.assert_called_once_with({'total': 0})
|
||||
|
||||
def test_blocked_uploads_do_not_lookup_before_reply(self):
|
||||
import server
|
||||
info = {'package': 'org.example.app', 'label': 'Example', 'version': '5',
|
||||
'version_code': 5, 'min_sdk': 33, 'abis': [], 'icon_png': None}
|
||||
for mode in ('apkinfo', 'apk'):
|
||||
handler = object.__new__(server.Handler)
|
||||
handler.headers = {'X-Filename': 'app.apk', 'X-Mode': mode, 'Content-Length': '1'}
|
||||
handler.rfile = io.BytesIO(b'x')
|
||||
with patch.object(frame_android, 'apk_info', return_value=dict(info)), \
|
||||
patch.object(versions, 'alternatives', side_effect=AssertionError('lookup during upload')) as lookup, \
|
||||
patch.object(server, 'ensure_master') as ssh:
|
||||
if mode == 'apkinfo':
|
||||
reply = handler.upload()
|
||||
self.assertNotIn('alternatives', reply['apk'])
|
||||
self.assertIn('API 33', reply['apk']['blocker'])
|
||||
else:
|
||||
with self.assertRaises(server.Failure) as error:
|
||||
handler.upload()
|
||||
self.assertEqual(error.exception.status, 400)
|
||||
self.assertEqual(error.exception.apk['package'], info['package'])
|
||||
lookup.assert_not_called()
|
||||
ssh.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -212,6 +212,23 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertNotEqual(status, 200, body)
|
||||
self.assertNotIn("job", body)
|
||||
|
||||
def test_android_install_of_another_version_runs_as_a_job(self):
|
||||
pkg = "org.example.frame_control.not_in_any_repo"
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
import frame_apk_versions
|
||||
# The job must fail on the cached lookup, before any download: nothing is cached for this package.
|
||||
self.assertEqual(frame_apk_versions._versions(pkg, cached_only=True)[0], [])
|
||||
status, started = self.post("/api/android", {"action": "install", "package": pkg,
|
||||
"url": f"https://f-droid.org/repo/{pkg}_1.apk"})
|
||||
self.assertEqual(status, 200, started)
|
||||
for _ in range(200):
|
||||
job = json.loads(self.request("GET", f"/api/job?id={started['job']}", headers={"X-Frame-UI": "1"})[2])
|
||||
if job["done"]:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertTrue(job["done"])
|
||||
self.assertIn("no longer available", job["error"])
|
||||
|
||||
def test_unknown_routes(self):
|
||||
self.assertEqual(self.request("GET", "/nope")[0], 404)
|
||||
self.assertEqual(self.post("/api/nope", {})[0], 404)
|
||||
|
||||
@@ -6,7 +6,7 @@ apps, the lepton-show-flatscreen marker; plus a non-Steam shortcut, so it shows
|
||||
in the Steam library and gets its own SteamVR panel. Nothing goes through
|
||||
Lepton Development, which wipes its apps on exit. See docs/apks.md.
|
||||
|
||||
Python stdlib only. CLI: python3 ui/frame_android.py {install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
|
||||
Python stdlib only. CLI: python3 ui/frame_android.py {info APK|versions APK-or-PKG|install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
|
||||
"""
|
||||
import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib
|
||||
|
||||
@@ -276,7 +276,15 @@ def probe(pkg, wait=20):
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['help']
|
||||
try:
|
||||
if cmd == 'install':
|
||||
if cmd in ('info', 'versions'):
|
||||
import frame_apk_versions
|
||||
if cmd == 'info':
|
||||
print(frame_apk_versions.describe(apk_info(args[0])))
|
||||
return
|
||||
info = apk_info(args[0]) if os.path.isfile(args[0]) or args[0].lower().endswith('.apk') else None
|
||||
r = frame_apk_versions.alternatives(
|
||||
info['package'] if info else args[0], info.get('version_code') if info else None)
|
||||
elif cmd == 'install':
|
||||
r = install(args[0], flatscreen='--vr' not in args)
|
||||
elif cmd == 'list':
|
||||
r = list_apps()
|
||||
|
||||
@@ -229,6 +229,7 @@ def apk_info(path):
|
||||
min_sdk = sdk.get('minSdkVersion')
|
||||
info = {
|
||||
'package': package,
|
||||
'version_code': manifest.get('versionCode', (None, None))[1],
|
||||
'version': _text(manifest.get('versionName'), res) or '',
|
||||
'label': _text(app.get('label'), res) or package,
|
||||
'abis': sorted({n.split('/')[1] for n in names if n.startswith('lib/') and n.count('/') >= 2}),
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Explain APK requirements and find installable versions in F-Droid's indexes."""
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import frame_android
|
||||
import frame_catalog
|
||||
|
||||
ANDROID = dict(enumerate([
|
||||
'1.0', '1.1', '1.5', '1.6', '2.0', '2.0.1', '2.1', '2.2', '2.3', '2.3.3',
|
||||
'3.0', '3.1', '3.2', '4.0', '4.0.3', '4.1', '4.2', '4.3', '4.4', '4.4W',
|
||||
'5.0', '5.1', '6.0', '7.0', '7.1', '8.0', '8.1', '9', '10', '11', '12',
|
||||
'12L', '13', '14', '15', '16',
|
||||
], 1))
|
||||
REPOS = (('F-Droid', 'https://f-droid.org/repo/'),
|
||||
('F-Droid archive', 'https://f-droid.org/archive/'),
|
||||
('IzzyOnDroid', 'https://apt.izzysoft.de/fdroid/repo/'))
|
||||
NOTE = ('Pick a version whose minimum is Android 11 or lower and that has an '
|
||||
'arm64-v8a build (or no native code). Installable does not mean every feature works.')
|
||||
|
||||
|
||||
def android_name(sdk):
|
||||
return 'Android ' + ANDROID[sdk] if sdk in ANDROID else f'Android API {sdk}'
|
||||
|
||||
|
||||
def describe(info):
|
||||
sdk = info.get('min_sdk')
|
||||
minimum = f'{android_name(sdk)} (API {sdk})' if sdk else 'not specified'
|
||||
try:
|
||||
frame_android.check_installable(info)
|
||||
verdict = 'Lepton can install this APK. Features may still need services Lepton lacks.'
|
||||
except frame_android.FrameError as e:
|
||||
verdict = f'Lepton cannot install this APK: {e}'
|
||||
return (f"{info['package']} · {info.get('version') or '?'} "
|
||||
f"(code {info.get('version_code') if info.get('version_code') is not None else '?'})\n"
|
||||
f"Minimum: {minimum}\nABIs: {', '.join(info['abis']) or 'no native code'}\n{verdict}")
|
||||
|
||||
|
||||
def search_links(package):
|
||||
q = quote(package, safe='')
|
||||
return [{'source': name, 'url': url} for name, url in (
|
||||
('APKMirror', 'https://www.apkmirror.com/?' + urlencode({'post_type': 'app_release', 's': package})),
|
||||
('APKPure', 'https://apkpure.com/search?q=' + q),
|
||||
('Uptodown', 'https://en.uptodown.com/android/search/' + q),
|
||||
('F-Droid', 'https://search.f-droid.org/?q=' + q),
|
||||
('GitHub', 'https://github.com/search?type=repositories&q=' + q),
|
||||
)]
|
||||
|
||||
|
||||
def _versions(package, cached_only=False):
|
||||
versions, errors, seen = [], [], set()
|
||||
for source, repo in REPOS:
|
||||
try:
|
||||
index = frame_catalog.load_index(repo, cached_only=cached_only)
|
||||
except Exception as e: # one bad repo (dropped download, odd index) mustn't hide the others
|
||||
errors.append(f'Could not check {source}: {e}')
|
||||
continue
|
||||
for v in index.get(package, []):
|
||||
url = repo + v['name'].lstrip('/')
|
||||
key = (v['version_code'], v.get('sha256') or url)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
versions.append(dict(v, url=url, source=source))
|
||||
return versions, errors
|
||||
|
||||
|
||||
def alternatives(package, current_version_code=None):
|
||||
"""At most eight releases, preferring arm64-only builds over universal builds."""
|
||||
versions, errors = _versions(package)
|
||||
versions = [v for v in versions if v['version_code'] != current_version_code]
|
||||
total = len(versions)
|
||||
versions.sort(key=lambda v: (v['abis'] == ['arm64-v8a'], v['version_code']), reverse=True)
|
||||
releases = {}
|
||||
for v in versions:
|
||||
releases.setdefault(v['version'], v)
|
||||
versions = sorted(releases.values(), key=lambda v: v['version_code'], reverse=True)[:8]
|
||||
return {'package': package, 'versions': versions, 'total': total,
|
||||
'links': search_links(package), 'note': NOTE, 'errors': errors}
|
||||
|
||||
|
||||
def install(package, url):
|
||||
# Resolve the selection again: the client cannot supply a trusted hash or arbitrary URL.
|
||||
records, _ = _versions(package, cached_only=True)
|
||||
version = next((v for v in records if v['url'] == url), None)
|
||||
if not version:
|
||||
raise frame_android.FrameError('That version is no longer available; check the APK again')
|
||||
apk = frame_catalog.fetch_apk({'a': version['url'], 'h': version['sha256'], 'n': package})
|
||||
info = frame_android.apk_info(apk)
|
||||
if info['package'] != package or info.get('version_code') != version['version_code']:
|
||||
raise frame_android.FrameError('The downloaded APK does not match the selected version')
|
||||
return frame_android.install(apk, source=version['source'])
|
||||
@@ -2,7 +2,7 @@
|
||||
list, verified downloads, installs into per-app Lepton instances, and
|
||||
compatibility reports. Python stdlib only.
|
||||
"""
|
||||
import hashlib, os, shutil, sys, tempfile, threading, time, urllib.error, urllib.request
|
||||
import hashlib, json, os, shutil, sys, tempfile, threading, time, urllib.error, urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CATALOG = os.path.join(ROOT, 'apk-catalog')
|
||||
@@ -17,12 +17,149 @@ import frame_compat_db as compat_db # noqa: E402
|
||||
# the per-user cache (FRAME_CONTROL_APP is set by app/main.js).
|
||||
CACHE = (str(frame_host.cache_dir('apk')) if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG
|
||||
else os.path.join(CATALOG, 'data', 'cache'))
|
||||
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
|
||||
# Repo base URL -> local name of its index; every APK download must come from one of these.
|
||||
INDEX_FILES = {'https://f-droid.org/repo/': 'index-v2.json',
|
||||
'https://f-droid.org/archive/': 'index-v2.archive.json',
|
||||
'https://apt.izzysoft.de/fdroid/repo/': 'index-v2.izzy.json'}
|
||||
APK_HOSTS = tuple(INDEX_FILES)
|
||||
_lock = threading.Lock()
|
||||
_cache = {'mtime': None, 'sig': None, 'apps': None, 'by_pkg': None}
|
||||
_env = {}
|
||||
|
||||
|
||||
_index_lock = threading.Lock()
|
||||
_indexes = {}
|
||||
|
||||
|
||||
class _IndexReader:
|
||||
"""Decode one object member at a time; never retain the whole raw index."""
|
||||
def __init__(self, stream):
|
||||
self.stream, self.buffer = stream, ''
|
||||
self.decoder = json.JSONDecoder()
|
||||
|
||||
def fill(self):
|
||||
chunk = self.stream.read(1 << 16)
|
||||
if not chunk:
|
||||
raise ValueError('incomplete F-Droid index')
|
||||
self.buffer += chunk
|
||||
|
||||
def peek(self):
|
||||
self.buffer = self.buffer.lstrip()
|
||||
while not self.buffer:
|
||||
self.fill()
|
||||
self.buffer = self.buffer.lstrip()
|
||||
return self.buffer[0]
|
||||
|
||||
def expect(self, char):
|
||||
if self.peek() != char:
|
||||
raise ValueError('invalid F-Droid index')
|
||||
self.buffer = self.buffer[1:]
|
||||
|
||||
def value(self):
|
||||
self.peek()
|
||||
while True:
|
||||
try:
|
||||
value, end = self.decoder.raw_decode(self.buffer)
|
||||
self.buffer = self.buffer[end:]
|
||||
return value
|
||||
except json.JSONDecodeError:
|
||||
self.fill()
|
||||
|
||||
def members(self):
|
||||
self.expect('{')
|
||||
if self.peek() != '}':
|
||||
while True:
|
||||
key = self.value()
|
||||
if not isinstance(key, str):
|
||||
raise ValueError('invalid F-Droid index key')
|
||||
self.expect(':')
|
||||
yield key
|
||||
if self.peek() == '}':
|
||||
break
|
||||
self.expect(',')
|
||||
self.expect('}')
|
||||
|
||||
|
||||
def _reduce_index(path):
|
||||
from pick import installable
|
||||
packages = {}
|
||||
found = False
|
||||
with open(path, encoding='utf-8') as f:
|
||||
reader = _IndexReader(f)
|
||||
for key in reader.members():
|
||||
if key != 'packages':
|
||||
reader.value()
|
||||
continue
|
||||
found = True
|
||||
for package in reader.members():
|
||||
records, entry = [], reader.value()
|
||||
versions = entry.get('versions') if isinstance(entry, dict) else None
|
||||
for v in (versions.values() if isinstance(versions, dict) else ()):
|
||||
# Skip malformed entries rather than losing the whole repo.
|
||||
if not (isinstance(v, dict) and isinstance(v.get('manifest'), dict)
|
||||
and isinstance(v.get('file'), dict) and v['file'].get('name')):
|
||||
continue
|
||||
if not installable(v):
|
||||
continue
|
||||
m, file = v['manifest'], v['file']
|
||||
records.append({'version': m.get('versionName', ''),
|
||||
'version_code': m.get('versionCode', 0),
|
||||
'min_sdk': m.get('usesSdk', {}).get('minSdkVersion', 1),
|
||||
'abis': m.get('nativecode') or [],
|
||||
'name': file['name'], 'sha256': file.get('sha256')})
|
||||
if records:
|
||||
packages[package] = records
|
||||
if reader.buffer.strip() or f.read().strip():
|
||||
raise ValueError('trailing data in F-Droid index')
|
||||
if not found:
|
||||
raise ValueError('invalid F-Droid index')
|
||||
return packages
|
||||
|
||||
|
||||
def load_index(repo, cached_only=False):
|
||||
"""Compact installable records by package, cached on disk and by mtime in memory."""
|
||||
if repo not in APK_HOSTS:
|
||||
raise ValueError('unexpected index URL')
|
||||
directory = CACHE if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG else os.path.join(CATALOG, 'data')
|
||||
filename = INDEX_FILES[repo]
|
||||
raw = os.path.join(directory, filename)
|
||||
path = raw + '.installable-v1'
|
||||
with _index_lock:
|
||||
mtime = os.stat(path).st_mtime_ns if os.path.exists(path) else None
|
||||
# A newer raw index (the catalogue script refreshed it) outdates the reduced copy.
|
||||
newer_raw = mtime is not None and os.path.exists(raw) and os.stat(raw).st_mtime_ns > mtime
|
||||
if mtime is not None and (cached_only or (time.time() - mtime / 1e9 < 86400 and not newer_raw)):
|
||||
cached = _indexes.get(path)
|
||||
if cached is None or cached[0] != mtime:
|
||||
with open(path) as f:
|
||||
cached = (mtime, json.load(f))
|
||||
_indexes[path] = cached
|
||||
return cached[1]
|
||||
if cached_only:
|
||||
return {}
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix=filename, suffix='.part', dir=directory)
|
||||
os.close(fd)
|
||||
try:
|
||||
if os.path.exists(raw) and time.time() - os.path.getmtime(raw) < 86400:
|
||||
index = _reduce_index(raw)
|
||||
refreshed = os.stat(raw).st_mtime_ns
|
||||
else:
|
||||
with open(tmp, 'wb') as f, urllib.request.urlopen(repo + 'index-v2.json', timeout=30) as r:
|
||||
shutil.copyfileobj(r, f, 1 << 20)
|
||||
index = _reduce_index(tmp)
|
||||
refreshed = time.time_ns()
|
||||
with open(tmp, 'w') as f:
|
||||
json.dump(index, f, separators=(',', ':'))
|
||||
os.utime(tmp, ns=(refreshed, refreshed))
|
||||
os.replace(tmp, path)
|
||||
_indexes[path] = (os.stat(path).st_mtime_ns, index)
|
||||
return index
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
|
||||
|
||||
def catalog():
|
||||
"""Rated apps with the database's reports applied."""
|
||||
path = os.path.join(CATALOG, 'site', 'apps.js')
|
||||
|
||||
@@ -254,10 +254,10 @@
|
||||
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
||||
.and-col { display: grid; gap: 22px; align-content: start; }
|
||||
.rep-item .s { white-space: normal; }
|
||||
#repDlg, #titleDlg, #wiDlg, #pwDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
#repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
|
||||
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
@@ -703,6 +703,16 @@
|
||||
<span class="last" id="lastLog">Ready</span>
|
||||
<span class="sub" id="drawerHint">Show ▴</span>
|
||||
</div>
|
||||
<dialog id="apkAltDlg" aria-labelledby="apkAltTitle">
|
||||
<h2 id="apkAltTitle">Try another APK version</h2>
|
||||
<p id="apkAltReason"></p>
|
||||
<div class="list" id="apkAltVersions"></div>
|
||||
<p class="sub" id="apkAltNote"></p>
|
||||
<div id="apkAltLinks"></div>
|
||||
<p class="sub" id="apkAltErrors"></p>
|
||||
<div class="actions"><button id="apkAltClose">Close</button></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="repDlg" aria-labelledby="repTitle">
|
||||
<form method="dialog" id="repForm">
|
||||
<h2 id="repTitle">Report an APK</h2>
|
||||
@@ -1457,12 +1467,56 @@ function upload(file, mode) {
|
||||
xhr.onload = () => {
|
||||
$("prog").style.display = "none";
|
||||
let data; try { data = JSON.parse(xhr.responseText); } catch { data = { error: `HTTP ${xhr.status}` }; }
|
||||
if (data.apk?.blocker && xhr.status >= 300) checkApkAlternatives(data.apk);
|
||||
xhr.status < 300 ? resolve(data) : reject(new Error(data.error));
|
||||
};
|
||||
xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); };
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
let apkLookup = 0;
|
||||
async function checkApkAlternatives(apk) {
|
||||
const lookup = ++apkLookup;
|
||||
$("apkAltReason").textContent = apk.blocker;
|
||||
$("apkAltVersions").textContent = "Checking F-Droid for older versions…";
|
||||
$("apkAltVersions").onclick = null;
|
||||
for (const id of ["apkAltNote", "apkAltErrors", "apkAltLinks"]) $(id).textContent = "";
|
||||
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
|
||||
const query = new URLSearchParams({package: apk.package});
|
||||
if (apk.version_code != null) query.set("code", apk.version_code);
|
||||
try {
|
||||
const result = await api(`/api/apk-versions?${query}`);
|
||||
if (lookup === apkLookup && $("apkAltDlg").open) showApkAlternatives(apk.blocker, result);
|
||||
} catch (e) {
|
||||
if (lookup === apkLookup) $("apkAltVersions").textContent = e.message;
|
||||
}
|
||||
}
|
||||
function showApkAlternatives(reason, result) {
|
||||
$("apkAltReason").textContent = reason;
|
||||
$("apkAltNote").textContent = `${result.versions.length} of ${result.total} compatible versions. ${result.note}`;
|
||||
$("apkAltErrors").textContent = result.errors.join(" · ");
|
||||
$("apkAltLinks").innerHTML = result.links.map(l => `<a href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.source)}</a>`).join(" · ");
|
||||
$("apkAltVersions").innerHTML = result.versions.length ? result.versions.map((v, i) =>
|
||||
`<div class="item"><div class="grow"><div class="t">${esc(v.version || "?")} <span class="sub">code ${esc(v.version_code)}</span></div>
|
||||
<div class="s">${esc(v.source)} · minimum API ${esc(v.min_sdk)} · ${esc(v.abis.join(", ") || "no native code")}</div></div>
|
||||
<button class="small" data-version="${i}" ${v.sha256 ? "" : "disabled"}>Install</button></div>`).join("") :
|
||||
`<p>No compatible version found in F-Droid. Try the searches below.</p>`;
|
||||
$("apkAltVersions").onclick = async e => {
|
||||
const b = e.target.closest("[data-version]"); if (!b) return;
|
||||
const v = result.versions[+b.dataset.version];
|
||||
if (installing.has(result.package)) return;
|
||||
b.disabled = true; b.textContent = "Installing…";
|
||||
const res = await runJob(`Install ${result.package} ${v.version}`, result.package, () => api("/api/android", {
|
||||
action: "install", package: result.package, url: v.url
|
||||
}));
|
||||
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
|
||||
await loadAndroid();
|
||||
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
|
||||
};
|
||||
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
|
||||
}
|
||||
$("apkAltClose").onclick = () => $("apkAltDlg").close();
|
||||
|
||||
const TITLE_EXT = /\.(zip|exe)$/i;
|
||||
async function sendFiles(files, dirs = new Set()) {
|
||||
for (const [i, f] of files.entries()) {
|
||||
@@ -2043,6 +2097,7 @@ $("repFile").onchange = async () => {
|
||||
const { apk } = await upload(file, "apkinfo");
|
||||
$("repPkg").value = apk.package; $("repVer").value = apk.version; $("repLabel").value = apk.label;
|
||||
if (!$("repSrc").value) $("repSrc").value = file.name;
|
||||
if (apk.blocker) checkApkAlternatives(apk);
|
||||
$("repFileNote").textContent = apk.blocker ? `Note: ${apk.blocker}` : `${apk.package} ${apk.version}`;
|
||||
} catch (e) { $("repFileNote").textContent = e.message; }
|
||||
$("repFile").value = "";
|
||||
|
||||
@@ -38,6 +38,7 @@ from urllib.parse import parse_qs, unquote, urlparse
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import frame_android # noqa: E402
|
||||
import frame_apk_versions # noqa: E402
|
||||
import frame_catalog # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_store # noqa: E402
|
||||
@@ -94,9 +95,10 @@ exit 1
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
def __init__(self, message, status=502):
|
||||
def __init__(self, message, status=502, apk=None):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.apk = apk
|
||||
|
||||
|
||||
# What ssh prints when it never reached the Frame, and what to tell the user
|
||||
@@ -719,16 +721,28 @@ def open_thing(body):
|
||||
raise Failure("unknown target", 400)
|
||||
|
||||
|
||||
def apk_versions(query):
|
||||
args = parse_qs(query, keep_blank_values=True)
|
||||
packages, codes = args.get('package', []), args.get('code', [])
|
||||
if len(packages) != 1 or not frame_android.PKG_RE.match(packages[0]):
|
||||
raise Failure('invalid Android package id', 400)
|
||||
if codes and (len(codes) != 1 or not re.fullmatch(r'[0-9]{1,19}', codes[0])):
|
||||
raise Failure('invalid version code', 400)
|
||||
return frame_apk_versions.alternatives(packages[0], int(codes[0]) if codes else None)
|
||||
|
||||
|
||||
def android(body):
|
||||
"""Android apps, each in its own persistent Lepton instance (frame_android.py)."""
|
||||
action, pkg = body.get("action"), str(body.get("package", ""))
|
||||
ensure_master()
|
||||
try:
|
||||
if action == "install":
|
||||
frame_catalog.app(pkg) # an unknown package fails now, not in the background
|
||||
url = body.get("url")
|
||||
if not url:
|
||||
frame_catalog.app(pkg) # an unknown package fails now, not in the background
|
||||
|
||||
def work():
|
||||
m = frame_catalog.install(pkg)
|
||||
m = frame_apk_versions.install(pkg, url) if url else frame_catalog.install(pkg)
|
||||
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
|
||||
"app": m}
|
||||
return start_job(f"Install {pkg}", work)
|
||||
@@ -1462,8 +1476,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def send_json(self, obj, status=200):
|
||||
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
||||
|
||||
def send_error_json(self, message, status):
|
||||
def send_error_json(self, message, status, apk=None):
|
||||
body, offline_status = error_body(message)
|
||||
if apk is not None:
|
||||
body["apk"] = apk
|
||||
self.send_json(body, offline_status or status)
|
||||
|
||||
def do_GET(self):
|
||||
@@ -1478,6 +1494,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
||||
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||
"computer": "Mac" if frame_host.MAC else "PC"})
|
||||
elif path == "/api/apk-versions":
|
||||
self.send_json(apk_versions(url.query))
|
||||
elif path == "/api/android":
|
||||
ensure_master()
|
||||
self.send_json({"apps": frame_android.list_apps()})
|
||||
@@ -1519,7 +1537,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
except Failure as e:
|
||||
self.send_error_json(str(e), e.status)
|
||||
self.send_error_json(str(e), e.status, e.apk)
|
||||
except frame_android.FrameError as e:
|
||||
self.send_error_json(str(e), 502)
|
||||
except Exception as e:
|
||||
@@ -1545,7 +1563,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise Failure("request body must be a JSON object", 400)
|
||||
self.send_json(handler(body))
|
||||
except Failure as e:
|
||||
self.send_error_json(str(e), e.status)
|
||||
self.send_error_json(str(e), e.status, e.apk)
|
||||
except (ValueError, TypeError) as e:
|
||||
self.send_json({"error": f"bad request: {e}"}, 400)
|
||||
except frame_android.FrameError as e:
|
||||
@@ -1650,6 +1668,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
keep = True # stage_title owns tmp now, and removes it on failure
|
||||
return stage_title(str(dest), temp_dir=str(tmp))
|
||||
if mode == "apk":
|
||||
try:
|
||||
info = frame_android.apk_info(str(dest))
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e), 400)
|
||||
try:
|
||||
frame_android.check_installable(info)
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e), 400, {"package": info["package"], "version_code": info.get("version_code"), "blocker": str(e)})
|
||||
ensure_master()
|
||||
try:
|
||||
m = frame_android.install(str(dest), source=name)
|
||||
|
||||