diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS new file mode 100644 index 0000000..ba2edd6 --- /dev/null +++ b/.github/APPROVED_CONTRIBUTORS @@ -0,0 +1,9 @@ +# GitHub handles approved to bypass contribution auto-close +# Format: +# 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 diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..32009be --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..af55e89 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/idea.yml b/.github/ISSUE_TEMPLATE/idea.yml new file mode 100644 index 0000000..9f95ac7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/idea.yml @@ -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 diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml new file mode 100644 index 0000000..1f8a8c7 --- /dev/null +++ b/.github/workflows/approve-contributor.yml @@ -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, + }); + diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 654da64..9239b5d 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -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. diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml new file mode 100644 index 0000000..8695373 --- /dev/null +++ b/.github/workflows/issue-gate.yml @@ -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', + }); diff --git a/.github/workflows/issue-triage-labels.yml b/.github/workflows/issue-triage-labels.yml new file mode 100644 index 0000000..0ce3f50 --- /dev/null +++ b/.github/workflows/issue-triage-labels.yml @@ -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}`); + } diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 0000000..b5674c2 --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -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); diff --git a/.github/workflows/remove-inprogress-on-close.yml b/.github/workflows/remove-inprogress-on-close.yml new file mode 100644 index 0000000..b1e9454 --- /dev/null +++ b/.github/workflows/remove-inprogress-on-close.yml @@ -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, + }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f42d9fb --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index d9b4681..75167b0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ See what the headset sees, install games and Android apps, move files and text a [![Checks](https://img.shields.io/github/actions/workflow/status/saphid/steam-frame/checks.yml?branch=main&label=checks)](https://github.com/saphid/steam-frame/actions/workflows/checks.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-66c0f4)](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)
@@ -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 diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 0000000..a933f10 --- /dev/null +++ b/site/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.wrangler/ +.dev.vars diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..1180f4e --- /dev/null +++ b/site/README.md @@ -0,0 +1,35 @@ +# Website + +The Frame Control website, , 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. diff --git a/site/functions/api/feedback.js b/site/functions/api/feedback.js new file mode 100644 index 0000000..20ab7fc --- /dev/null +++ b/site/functions/api/feedback.js @@ -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." }); diff --git a/site/lib/feedback.js b/site/lib/feedback.js new file mode 100644 index 0000000..9b089df --- /dev/null +++ b/site/lib/feedback.js @@ -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(""); +} diff --git a/site/package.json b/site/package.json new file mode 100644 index 0000000..8898b26 --- /dev/null +++ b/site/package.json @@ -0,0 +1 @@ +{ "type": "module", "private": true } diff --git a/site/public/404.html b/site/public/404.html new file mode 100644 index 0000000..c01a92b --- /dev/null +++ b/site/public/404.html @@ -0,0 +1,54 @@ + + + + + +Not found · Frame Control + + + + + + + + + +
+ +
+ +
+
+ 404 +

That page isn't here

+

It may have moved. Try the home page, or tell us what you were looking for.

+ +
+
+ + + + diff --git a/site/public/_headers b/site/public/_headers new file mode 100644 index 0000000..d1448e4 --- /dev/null +++ b/site/public/_headers @@ -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 diff --git a/site/public/apple-touch-icon.png b/site/public/apple-touch-icon.png new file mode 100644 index 0000000..2cf8257 Binary files /dev/null and b/site/public/apple-touch-icon.png differ diff --git a/site/public/css/site.css b/site/public/css/site.css new file mode 100644 index 0000000..d589e0b --- /dev/null +++ b/site/public/css/site.css @@ -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); } diff --git a/site/public/favicon.png b/site/public/favicon.png new file mode 100644 index 0000000..88b92e9 Binary files /dev/null and b/site/public/favicon.png differ diff --git a/site/public/feedback/index.html b/site/public/feedback/index.html new file mode 100644 index 0000000..b03e181 --- /dev/null +++ b/site/public/feedback/index.html @@ -0,0 +1,137 @@ + + + + + +Send feedback · Frame Control + + + + + + + + + + +
+ +
+ +
+
+
+ Feedback +

Tell us what happened

+

Bugs, ideas and questions all help. What you send becomes a public issue on GitHub, where you can follow it.

+
+ +
+
+
+ + +
+ What kind of feedback? +
+ + + + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + +
+ + Everything you send is public. Don't include passwords, IP addresses or personal details. +
+
+ + +
+ + +
+
+
+ + + + diff --git a/site/public/img/android.jpg b/site/public/img/android.jpg new file mode 100644 index 0000000..1aeeb25 Binary files /dev/null and b/site/public/img/android.jpg differ diff --git a/site/public/img/games.jpg b/site/public/img/games.jpg new file mode 100644 index 0000000..651049c Binary files /dev/null and b/site/public/img/games.jpg differ diff --git a/site/public/img/home-live.jpg b/site/public/img/home-live.jpg new file mode 100644 index 0000000..0620b3b Binary files /dev/null and b/site/public/img/home-live.jpg differ diff --git a/site/public/img/icon.png b/site/public/img/icon.png new file mode 100644 index 0000000..b4e5a57 Binary files /dev/null and b/site/public/img/icon.png differ diff --git a/site/public/img/og.jpg b/site/public/img/og.jpg new file mode 100644 index 0000000..7b0a5aa Binary files /dev/null and b/site/public/img/og.jpg differ diff --git a/site/public/img/phone-games.jpg b/site/public/img/phone-games.jpg new file mode 100644 index 0000000..71d4028 Binary files /dev/null and b/site/public/img/phone-games.jpg differ diff --git a/site/public/img/phone-home.jpg b/site/public/img/phone-home.jpg new file mode 100644 index 0000000..a5c23c9 Binary files /dev/null and b/site/public/img/phone-home.jpg differ diff --git a/site/public/img/tools.jpg b/site/public/img/tools.jpg new file mode 100644 index 0000000..90bae7b Binary files /dev/null and b/site/public/img/tools.jpg differ diff --git a/site/public/index.html b/site/public/index.html new file mode 100644 index 0000000..5578b44 --- /dev/null +++ b/site/public/index.html @@ -0,0 +1,313 @@ + + + + + +Frame Control: manage your Steam Frame from your computer + + + + + + + + + + + + + + +
+ +
+ +
+
+
+ Free and open source · macOS · Windows · Linux · iPhone +

Your Steam Frame, managed from your desk.

+

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.

+ +

All platforms · MIT licence · Not affiliated with Valve

+
+
+ Frame Control's Home tab: a live view of the headset beside battery, storage, temperature and Wi-Fi. +
+
+
+ +
+
+
+ Trailer +

Sixty-six seconds of Frame Control

+

Recorded against a real Steam Frame. Turn the sound on.

+
+
+ +
+
+
+ +
+
+
+ Features +

Everything the headset needs, one window away

+

Frame Control uses what SteamOS already ships. It only changes what you click.

+
+
+
+
+

Headset view

+

Live video of what the lenses show at about 30 fps, or a still of both eyes. Zoom, pan, full screen, save as PNG.

+
+
+
+

Battery and status

+

Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.

+
+
+
+

Steam games

+

Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.

+
+
+
+

Android apps

+

About 4,500 F-Droid apps rated for the Frame. One click installs each as its own app in your Steam library.

+
+
+
+

Files, games and clipboard

+

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.

+
+
+
+

Screenshots

+

Browse the shots you take in the headset and save them straight to your Pictures folder.

+
+
+
+

Flatpaks and display

+

Install desktop apps like Moonlight or VLC, and set each Android app's resolution and text size.

+
+
+
+

One-click tools

+

SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.

+
+
+
+
+ +
+
+
+
+ Games +

Your Steam library, rated for the Frame

+

See every game you own with how well it runs on the Frame, then install it onto the headset without putting it on.

+
    +
  • Live download progress, straight from the Steam client on the headset
  • +
  • Search the Steam store from your computer
  • +
  • Sideload your own Linux or Windows games, with Proton or the Linux runtime picked for you
  • +
+
+ The Games tab: owned games with Steam Frame ratings and install buttons. +
+
+
+ Android +

Android apps, one click each

+

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.

+
    +
  • Filter by what works on the Frame
  • +
  • Install any APK you have, no Android SDK needed
  • +
  • Set resolution and text size per app
  • +
+
+ The Android tab: installed apps and the rated F-Droid catalogue. +
+
+
+ Tools +

The fiddly bits, done for you

+

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.

+
+ The Tools tab: SSH, SFTP, Steam Link, remote desktop and power controls. +
+
+
+ +
+
+
+ iPhone and iPad +

Also in your pocket

+

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.

+
+
+ Frame Control on iPhone: headset view and device status. + Frame Control on iPhone: the Games tab. +
+
+
+ +
+
+
+ Setup +

One password, typed once

+

Everything else happens on your computer.

+
+
+
+

Turn on Developer Mode

+

On the Frame: Steam Settings → System → Enable Developer Mode, then Set User Password in the Developer section.

+
+
+

Set up the connection

+

Open Frame Control and choose Set Up Connection. It finds the headset, makes an SSH key, and asks for that password once.

+
+
+

That's it

+

The app reaches the headset whenever it's awake and on the same network. For anywhere else, use Tailscale.

+
+
+
+
+ +
+
+
+ Download +

Get Frame Control

+

Free, with Python and adb built in. Release notes

+
+
+
+

macOS

+ Apple Silicon + +
+
+

Windows

+ Windows 10 or 11, x64 + +
+
+

Linux

+ x64 or arm64 · needs ssh + +
+
+
+
+ +
+
+
+ FAQ +

Good questions

+
+
+
+ macOS says the app is damaged or can't be checked +

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:

+

xattr -dr com.apple.quarantine "/Applications/Frame Control.app"

+
+
+ Windows SmartScreen says it protected my PC +

The installer isn't code-signed. Choose More info → Run anyway, or use the portable .zip: unzip it anywhere and run Frame Control.exe.

+
+
+ What does it change on my headset? +

Only what you click. Installs go to your user account on the Frame, and nothing needs sudo except the power buttons. On your computer it adds a Host frame entry to ~/.ssh/config and two SSH keys.

+
+
+ Is it safe to leave Developer Mode on? +

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. Security notes

+
+
+ Is this made by Valve? +

No. It's an unofficial hobby project, free and MIT-licensed. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.

+
+
+
+
+ +
+
+
+
+

Tell us what you think

+

Found a bug, or want something added? Reports from Windows and Linux are especially useful. No GitHub account needed.

+ Send feedback +
+
+
+

Buy me a coffee

+ +

Frame Control is free and always will be. If it saves you time, a coffee keeps it going.

+ + Support on Ko-fi + +
+
+
+
+ + + + diff --git a/site/public/js/feedback.js b/site/public/js/feedback.js new file mode 100644 index 0000000..663f661 --- /dev/null +++ b/site/public/js/feedback.js @@ -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"; + } +}); diff --git a/site/public/js/site.js b/site/public/js/site.js new file mode 100644 index 0000000..f7f8592 --- /dev/null +++ b/site/public/js/site.js @@ -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())); diff --git a/site/public/media/poster.jpg b/site/public/media/poster.jpg new file mode 100644 index 0000000..43d2918 Binary files /dev/null and b/site/public/media/poster.jpg differ diff --git a/site/public/media/trailer.mp4 b/site/public/media/trailer.mp4 new file mode 100644 index 0000000..f00c028 Binary files /dev/null and b/site/public/media/trailer.mp4 differ diff --git a/site/public/privacy/index.html b/site/public/privacy/index.html new file mode 100644 index 0000000..d1f637e --- /dev/null +++ b/site/public/privacy/index.html @@ -0,0 +1,60 @@ + + + + + +Privacy · Frame Control + + + + + + + + + +
+ +
+ +
+
+
+ Privacy +

Privacy

+

Short version: the app collects nothing, and the website keeps only what you choose to send.

+
+
+
The app

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.

+
The feedback form

What you type becomes a public GitHub issue on saphid/frame-control. 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.

+
This website

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.

+
+
+
+ + + + diff --git a/site/public/robots.txt b/site/public/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/site/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/site/test/feedback.test.mjs b/site/test/feedback.test.mjs new file mode 100644 index 0000000..c0a18ad --- /dev/null +++ b/site/test/feedback.test.mjs @@ -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")); +}); diff --git a/site/wrangler.toml b/site/wrangler.toml new file mode 100644 index 0000000..f7e1355 --- /dev/null +++ b/site/wrangler.toml @@ -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"