Website with a feedback form that opens GitHub issues, and pi's contributor gate
- site/: landing page, /feedback/ and /privacy/ on Cloudflare Pages (frame-control.pages.dev). POST /api/feedback validates the form and opens a labelled issue with a fine-grained token; honeypot, minimum fill time and KV rate limits keep spam out. Ko-fi donate buttons appear once the page name is set in site/public/js/site.js. - .github: the issue and PR gate from badlogic/pi-mono. New contributors' issues and PRs are auto-closed; a maintainer replying lgtmi/lgtm approves them via APPROVED_CONTRIBUTORS. Issue templates and CONTRIBUTING.md. - CI runs the website tests; README points feedback at the form. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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,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,226 @@
|
||||
# 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:
|
||||
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 }}"
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -35,6 +35,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,
|
||||
});
|
||||
@@ -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,7 +12,7 @@ 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) · [Trailer](#trailer) · [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>
|
||||
|
||||
@@ -172,13 +172,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
|
||||
|
||||
@@ -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,82 @@
|
||||
// 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 });
|
||||
|
||||
if (env.FEEDBACK_RL) {
|
||||
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." });
|
||||
}
|
||||
}
|
||||
|
||||
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,86 @@
|
||||
// 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 refs would
|
||||
// cross-link, so break both with a zero-width space.
|
||||
export function defang(text) {
|
||||
return text.replace(/@(?=[A-Za-z0-9])/g, "@").replace(/(^|\s)#(?=\d)/g, "$1#");
|
||||
}
|
||||
|
||||
// Returns { error } or { value } with every field trimmed and bounded.
|
||||
export function validate(input, now = Date.now()) {
|
||||
if (!input || typeof input !== "object") return { error: "Send the form as JSON." };
|
||||
if (oneLine(input.website, 200)) return { spam: true };
|
||||
const started = Number(input.started);
|
||||
if (!Number.isFinite(started) || now - started < MIN_FILL_MS) return { spam: true };
|
||||
|
||||
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);
|
||||
|
||||
const lines = [defang(value.message), ""];
|
||||
if (details.length) {
|
||||
lines.push("| | |", "|---|---|", ...details.map(([k, v]) => `| ${k} | ${defang(v).replace(/\|/g, "\\|")} |`), "");
|
||||
}
|
||||
lines.push(
|
||||
"---",
|
||||
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.",
|
||||
);
|
||||
|
||||
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,54 @@
|
||||
<!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 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="/privacy/">Privacy</a>
|
||||
</div>
|
||||
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve.</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,192 @@
|
||||
/* 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 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; } }
|
||||
|
||||
/* ---- 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); }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
.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: 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 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,137 @@
|
||||
<!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 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="/privacy/">Privacy</a>
|
||||
</div>
|
||||
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve.</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: 99 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> · macOS · Windows · Linux · iPhone</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="1000" 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,91 @@
|
||||
// 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 = Date.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));
|
||||
data.started = started;
|
||||
|
||||
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 {
|
||||
const res = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
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: "",
|
||||
};
|
||||
|
||||
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,60 @@
|
||||
<!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 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">
|
||||
<details open><summary>The app</summary><div><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></div></details>
|
||||
<details open><summary>The feedback form</summary><div><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></div></details>
|
||||
<details open><summary>This website</summary><div><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></div></details>
|
||||
</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="/privacy/">Privacy</a>
|
||||
</div>
|
||||
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -0,0 +1,69 @@
|
||||
// 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 now = 1_800_000_000_000;
|
||||
const form = (over = {}) => ({
|
||||
kind: "bug",
|
||||
title: "Live view freezes",
|
||||
message: "After about a minute the live view stops updating.",
|
||||
started: now - 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 " }), now);
|
||||
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 and too-fast submissions as spam", () => {
|
||||
assert.deepEqual(validate(form({ website: "http://spam" }), now), { spam: true });
|
||||
assert.deepEqual(validate(form({ started: now - 500 }), now), { spam: true });
|
||||
assert.deepEqual(validate(form({ started: undefined }), now), { spam: true });
|
||||
});
|
||||
|
||||
test("rejects short, long and malformed input", () => {
|
||||
assert.match(validate(form({ title: "hi" }), now).error, /title/);
|
||||
assert.match(validate(form({ message: "short" }), now).error, /more/);
|
||||
assert.match(validate(form({ message: "x".repeat(5001) }), now).error, /under/);
|
||||
assert.match(validate(form({ github: "not a user!" }), now).error, /GitHub/);
|
||||
assert.match(validate(null, now).error, /JSON/);
|
||||
});
|
||||
|
||||
test("unknown kinds become other feedback", () => {
|
||||
assert.equal(validate(form({ kind: "__proto__" }), now).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" }), now);
|
||||
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, /by @octocat\.$/);
|
||||
});
|
||||
|
||||
test("anonymous feedback says replies won't reach the sender", () => {
|
||||
const issue = buildIssue(validate(form({ kind: "other" }), now).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 @valve about #12");
|
||||
assert.equal(defang("email me@example.com"), "email me@example.com");
|
||||
const issue = buildIssue(validate(form({ os: "a | b" }), now).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"
|
||||