source for getorbyt.com getorbyt.com/
client bsky orbytapp app orbyt bluesky getorbyt orbytvideo atproto video
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Revert "Add dynamic AltStore source management with admin UI"

This reverts commit 6ea5faa526719dc06839f65bcb6d810e116c061d.

+49 -642
+1 -1
astro.config.mjs
··· 15 15 }, 16 16 routes: { 17 17 strategy: 'include', 18 - include: ['/@*', '/@*/*', '/altstore/source.json', '/api/admin/*', '/admin/*'], 18 + include: ['/@*', '/@*/*'], 19 19 }, 20 20 }), 21 21 });
+1 -2
package.json
··· 10 10 "check": "astro check", 11 11 "preview": "astro preview", 12 12 "astro": "astro", 13 - "altstore:r2": "bash ./scripts/altstore-r2.sh", 14 - "altstore:release": "bash ./scripts/altstore-r2.sh release" 13 + "altstore:r2": "bash ./scripts/altstore-r2.sh" 15 14 }, 16 15 "repository": { 17 16 "type": "git",
+47
public/altstore/source.json
··· 1 + { 2 + "name": "Orbyt", 3 + "subtitle": "Video app for Bluesky", 4 + "description": "Orbyt is a video-first social app for the Bluesky network, built on the AT Protocol. Available for EU and Japan via AltStore PAL.", 5 + "iconURL": "https://getorbyt.com/altstore/orbyt-icon.png", 6 + "headerURL": "https://getorbyt.com/images/orbyt-logotype.png", 7 + "website": "https://getorbyt.com", 8 + "tintColor": "#9B59B6", 9 + "fediUsername": "orbyt", 10 + "featuredApps": [ 11 + "com.getorbyt.app" 12 + ], 13 + "apps": [ 14 + { 15 + "name": "Orbyt", 16 + "bundleIdentifier": "com.getorbyt.app", 17 + "marketplaceID": "6751679299", 18 + "developerName": "Orbyt", 19 + "subtitle": "Video app for Bluesky", 20 + "localizedDescription": "Orbyt is a video-first social app for the Bluesky network.\n\n• Browse video feeds from Bluesky\n• Create and share short-form videos\n• Connect with the AT Protocol community\n• Built with React Native and Expo", 21 + "iconURL": "https://getorbyt.com/altstore/orbyt-icon.png", 22 + "tintColor": "#9B59B6", 23 + "category": "social", 24 + "screenshots": [], 25 + "versions": [ 26 + { 27 + "version": "1.1.2", 28 + "buildVersion": "62", 29 + "date": "2026-03-12", 30 + "localizedDescription": "Latest release.", 31 + "downloadURL": "https://downloads.getorbyt.com/manifest.json", 32 + "size": 70307293, 33 + "minOSVersion": "16.4" 34 + } 35 + ], 36 + "appPermissions": { 37 + "privacy": { 38 + "NSCameraUsageDescription": "We need camera access to record videos for posts and take profile photos.", 39 + "NSMicrophoneUsageDescription": "We need microphone access to record audio with your videos.", 40 + "NSPhotoLibraryUsageDescription": "We need access to your photo library to select videos for posts and photos for your profile.", 41 + "NSPhotoLibraryAddUsageDescription": "We need permission to save your videos to the camera roll." 42 + } 43 + } 44 + } 45 + ], 46 + "news": [] 47 + }
-65
scripts/altstore-r2.sh
··· 12 12 Usage: 13 13 ./scripts/altstore-r2.sh setup <bucket> [adp_dir] 14 14 ./scripts/altstore-r2.sh upload <bucket> [adp_dir] 15 - ./scripts/altstore-r2.sh release <bucket> <version> <build> <date> <size> [adp_dir] 16 15 ./scripts/altstore-r2.sh set-source-url <manifest_url> 17 16 ./scripts/altstore-r2.sh check <manifest_url> 18 17 ··· 20 19 setup Create the R2 bucket if needed, enable the public r2.dev URL, 21 20 upload the ADP directory, and update public/altstore/source.json. 22 21 upload Upload the ADP directory to an existing R2 bucket. 23 - release Upload ADP to R2 then call the admin API to publish a new version. 24 - Requires ADMIN_SECRET env var. After this, open the admin UI to confirm. 25 22 set-source-url Update public/altstore/source.json to point at a manifest URL. 26 23 check Fetch a manifest URL and print the HTTP status. 27 24 ··· 31 28 ALTSTORE_CUSTOM_MANIFEST_URL 32 29 Preferred manifest URL after upload. Defaults to 33 30 https://downloads.getorbyt.com/manifest.json. 34 - ADMIN_SECRET Required for the release command. Bearer token for the admin API. 35 - ADMIN_API_URL Override the admin API URL (default: https://getorbyt.com/api/admin/altstore/release). 36 - ALTSTORE_DESCRIPTION Override the changelog text for the release command. 37 - ALTSTORE_MIN_OS Override the minimum iOS version (default: 16.4). 38 31 EOF 39 32 } 40 33 ··· 206 199 echo "Next: deploy the site so https://getorbyt.com/altstore/source.json serves the updated metadata." 207 200 } 208 201 209 - publish_release() { 210 - local bucket="$1" 211 - local version="$2" 212 - local build="$3" 213 - local date="$4" 214 - local size="$5" 215 - local adp_dir="${6:-$DEFAULT_ADP_DIR}" 216 - local api_url="${ADMIN_API_URL:-https://getorbyt.com/api/admin/altstore/release}" 217 - local description="${ALTSTORE_DESCRIPTION:-}" 218 - local min_os="${ALTSTORE_MIN_OS:-16.4}" 219 - 220 - [[ -n "${ADMIN_SECRET:-}" ]] || fail "ADMIN_SECRET env var is required for the release command" 221 - 222 - echo "==> Uploading ADP to R2..." 223 - upload_adp "$bucket" "$adp_dir" 224 - 225 - echo 226 - echo "==> Publishing release v${version} (build ${build}) to admin API..." 227 - 228 - local payload 229 - payload="$(node --input-type=commonjs - <<NODE 230 - const body = { 231 - version: ${version@Q}, 232 - buildVersion: ${build@Q}, 233 - date: ${date@Q}, 234 - size: ${size}, 235 - downloadURL: "${DEFAULT_CUSTOM_MANIFEST_URL}", 236 - minOSVersion: ${min_os@Q}, 237 - }; 238 - if (${description@Q}) body.localizedDescription = ${description@Q}; 239 - process.stdout.write(JSON.stringify(body)); 240 - NODE 241 - )" 242 - 243 - local http_code 244 - http_code="$(curl -sS -o /tmp/altstore_release_response.json -w '%{http_code}' \ 245 - -X POST \ 246 - -H "Content-Type: application/json" \ 247 - -H "Authorization: Bearer ${ADMIN_SECRET}" \ 248 - -d "$payload" \ 249 - "$api_url")" 250 - 251 - if [[ "$http_code" == "200" ]]; then 252 - echo "Release published successfully." 253 - echo 254 - echo "Next: open https://getorbyt.com/admin/altstore to confirm and verify the release." 255 - else 256 - echo "Admin API returned HTTP ${http_code}:" 257 - cat /tmp/altstore_release_response.json 2>/dev/null || true 258 - echo 259 - fail "Release publish failed (HTTP ${http_code})" 260 - fi 261 - } 262 - 263 202 main() { 264 203 local command="${1:-}" 265 204 ··· 272 211 [[ $# -ge 2 ]] || fail "upload requires a bucket name" 273 212 ensure_adp_dir "${3:-$DEFAULT_ADP_DIR}" 274 213 upload_adp "$2" "${3:-$DEFAULT_ADP_DIR}" 275 - ;; 276 - release) 277 - [[ $# -ge 6 ]] || fail "release requires: <bucket> <version> <build> <date> <size> [adp_dir]" 278 - publish_release "$2" "$3" "$4" "$5" "$6" "${7:-$DEFAULT_ADP_DIR}" 279 214 ;; 280 215 set-source-url) 281 216 [[ $# -eq 2 ]] || fail "set-source-url requires a manifest URL"
-7
src/env.d.ts
··· 1 1 /// <reference path="../worker-configuration.d.ts" /> 2 2 3 - declare namespace Cloudflare { 4 - interface Env { 5 - orbyt_altstore_adp: R2Bucket; 6 - ADMIN_SECRET: string; 7 - } 8 - } 9 - 10 3 declare module 'cloudflare:workers' { 11 4 export const env: Env; 12 5 }
-353
src/pages/admin/altstore.astro
··· 1 - --- 2 - export const prerender = false; 3 - 4 - import { env } from 'cloudflare:workers'; 5 - import { readSource } from '../../utils/altstore-source'; 6 - 7 - const COOKIE_NAME = 'altstore_admin'; 8 - const SOURCE_URL = 'https://getorbyt.com/altstore/source.json'; 9 - const MANIFEST_URL = 'https://downloads.getorbyt.com/manifest.json'; 10 - const RELEASE_API = '/api/admin/altstore/release'; 11 - 12 - // --- Auth --- 13 - const secret = env.ADMIN_SECRET; 14 - 15 - // ?token=... in URL → set cookie + redirect clean 16 - const urlToken = Astro.url.searchParams.get('token'); 17 - if (urlToken && urlToken === secret) { 18 - Astro.cookies.set(COOKIE_NAME, secret, { 19 - httpOnly: true, 20 - secure: true, 21 - sameSite: 'lax', 22 - maxAge: 60 * 60 * 24 * 30, 23 - path: '/', 24 - }); 25 - return Astro.redirect('/admin/altstore'); 26 - } 27 - 28 - const cookieToken = Astro.cookies.get(COOKIE_NAME)?.value; 29 - const isAuthed = cookieToken === secret; 30 - 31 - // POST from login form 32 - if (Astro.request.method === 'POST' && !isAuthed) { 33 - const form = await Astro.request.formData(); 34 - const submitted = form.get('token'); 35 - if (submitted === secret) { 36 - Astro.cookies.set(COOKIE_NAME, secret, { 37 - httpOnly: true, 38 - secure: true, 39 - sameSite: 'lax', 40 - maxAge: 60 * 60 * 24 * 30, 41 - path: '/', 42 - }); 43 - return Astro.redirect('/admin/altstore'); 44 - } 45 - } 46 - 47 - // Read current source for display 48 - let currentVersion = null as null | { version: string; buildVersion: string; date: string; localizedDescription?: string; downloadURL: string; size: number }; 49 - let versionHistory: typeof currentVersion[] = []; 50 - 51 - if (isAuthed) { 52 - try { 53 - const source = await readSource(env.orbyt_altstore_adp); 54 - const versions = source.apps[0]?.versions ?? []; 55 - currentVersion = versions[0] ?? null; 56 - versionHistory = versions.slice(1, 5); 57 - } catch { 58 - // continue with null 59 - } 60 - } 61 - 62 - const today = new Date().toISOString().split('T')[0]; 63 - --- 64 - 65 - <!DOCTYPE html> 66 - <html lang="en"> 67 - <head> 68 - <meta charset="UTF-8" /> 69 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 70 - <title>AltStore Admin · Orbyt</title> 71 - <style> 72 - *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 73 - body { font-family: system-ui, -apple-system, sans-serif; background: #0f0f13; color: #e0e0e8; min-height: 100vh; padding: 2rem 1rem; } 74 - .container { max-width: 720px; margin: 0 auto; } 75 - h1 { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.25rem; } 76 - .subtitle { color: #888; font-size: 0.85rem; margin-bottom: 2rem; } 77 - .card { background: #1a1a22; border: 1px solid #2a2a36; border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; } 78 - .card h2 { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: #9B59B6; margin-bottom: 1rem; } 79 - .meta-row { display: flex; gap: 0.5rem; align-items: baseline; margin-bottom: 0.4rem; font-size: 0.9rem; } 80 - .meta-label { color: #888; min-width: 100px; font-size: 0.8rem; } 81 - .meta-value { color: #e0e0e8; font-family: monospace; font-size: 0.85rem; word-break: break-all; } 82 - .badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 20px; font-size: 0.75rem; font-weight: 600; background: #9B59B6; color: #fff; margin-left: 0.5rem; } 83 - .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } 84 - .form-group { display: flex; flex-direction: column; gap: 0.35rem; } 85 - .form-group.full { grid-column: 1 / -1; } 86 - label { font-size: 0.78rem; color: #888; font-weight: 500; } 87 - input, textarea { background: #0f0f13; border: 1px solid #2a2a36; border-radius: 8px; color: #e0e0e8; font-size: 0.9rem; padding: 0.6rem 0.75rem; width: 100%; font-family: inherit; } 88 - input:focus, textarea:focus { outline: none; border-color: #9B59B6; } 89 - textarea { resize: vertical; min-height: 80px; } 90 - .btn { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.6rem 1.2rem; border-radius: 8px; font-size: 0.9rem; font-weight: 600; cursor: pointer; border: none; transition: opacity 0.15s; } 91 - .btn:hover { opacity: 0.85; } 92 - .btn:disabled { opacity: 0.4; cursor: not-allowed; } 93 - .btn-primary { background: #9B59B6; color: #fff; } 94 - .btn-secondary { background: #2a2a36; color: #e0e0e8; } 95 - .btn-danger { background: #c0392b; color: #fff; } 96 - .btn-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 1rem; } 97 - .status-line { font-size: 0.8rem; margin-top: 0.75rem; min-height: 1.2em; } 98 - .ok { color: #2ecc71; } 99 - .err { color: #e74c3c; } 100 - .warn { color: #f39c12; } 101 - .history-item { padding: 0.6rem 0; border-bottom: 1px solid #2a2a36; font-size: 0.82rem; color: #888; } 102 - .history-item:last-child { border-bottom: none; } 103 - .history-item strong { color: #ccc; } 104 - .link { color: #9B59B6; text-decoration: none; font-size: 0.82rem; } 105 - .link:hover { text-decoration: underline; } 106 - .login-wrap { max-width: 360px; margin: 8vh auto; } 107 - .login-wrap h1 { margin-bottom: 1.5rem; } 108 - .divider { height: 1px; background: #2a2a36; margin: 0.5rem 0 1rem; } 109 - @media (max-width: 500px) { .form-grid { grid-template-columns: 1fr; } } 110 - </style> 111 - </head> 112 - <body> 113 - <div class="container"> 114 - 115 - {!isAuthed ? ( 116 - <div class="login-wrap"> 117 - <h1>AltStore Admin</h1> 118 - <div class="card"> 119 - <h2>Sign in</h2> 120 - <form method="POST"> 121 - <div class="form-group" style="margin-bottom:1rem"> 122 - <label for="token">Admin secret</label> 123 - <input id="token" name="token" type="password" placeholder="Enter ADMIN_SECRET" required autofocus /> 124 - </div> 125 - <button type="submit" class="btn btn-primary" style="width:100%">Sign in</button> 126 - </form> 127 - </div> 128 - </div> 129 - ) : ( 130 - <> 131 - <h1>AltStore Admin <span style="color:#9B59B6">· Orbyt</span></h1> 132 - <p class="subtitle"> 133 - Source: <a class="link" href={SOURCE_URL} target="_blank">{SOURCE_URL}</a> 134 - </p> 135 - 136 - <!-- Current Release --> 137 - <div class="card"> 138 - <h2>Current release</h2> 139 - {currentVersion ? ( 140 - <> 141 - <div class="meta-row"> 142 - <span class="meta-label">Version</span> 143 - <span class="meta-value">{currentVersion.version} <span class="badge">build {currentVersion.buildVersion}</span></span> 144 - </div> 145 - <div class="meta-row"> 146 - <span class="meta-label">Date</span> 147 - <span class="meta-value">{currentVersion.date}</span> 148 - </div> 149 - <div class="meta-row"> 150 - <span class="meta-label">Size</span> 151 - <span class="meta-value">{(currentVersion.size / 1_000_000).toFixed(1)} MB</span> 152 - </div> 153 - <div class="meta-row"> 154 - <span class="meta-label">Changelog</span> 155 - <span class="meta-value">{currentVersion.localizedDescription ?? '—'}</span> 156 - </div> 157 - <div class="meta-row"> 158 - <span class="meta-label">Download</span> 159 - <span class="meta-value">{currentVersion.downloadURL}</span> 160 - </div> 161 - </> 162 - ) : ( 163 - <p style="color:#888;font-size:0.9rem">No releases published yet. Add one below.</p> 164 - )} 165 - 166 - {versionHistory.length > 0 && ( 167 - <> 168 - <div class="divider" style="margin-top:1rem"></div> 169 - <p style="font-size:0.75rem;color:#888;margin-bottom:0.5rem">Previous releases</p> 170 - {versionHistory.map((v) => v && ( 171 - <div class="history-item"> 172 - <strong>{v.version}</strong> (build {v.buildVersion}) · {v.date} 173 - </div> 174 - ))} 175 - </> 176 - )} 177 - </div> 178 - 179 - <!-- Publish New Release --> 180 - <div class="card"> 181 - <h2>Publish new release</h2> 182 - <form id="release-form"> 183 - <div class="form-grid"> 184 - <div class="form-group"> 185 - <label for="version">Version *</label> 186 - <input id="version" name="version" placeholder="1.1.3" required /> 187 - </div> 188 - <div class="form-group"> 189 - <label for="buildVersion">Build * </label> 190 - <input id="buildVersion" name="buildVersion" placeholder="63" required /> 191 - </div> 192 - <div class="form-group"> 193 - <label for="date">Date *</label> 194 - <input id="date" name="date" type="date" value={today} required /> 195 - </div> 196 - <div class="form-group"> 197 - <label for="size">Size (bytes) *</label> 198 - <input id="size" name="size" type="number" placeholder="70307293" required /> 199 - </div> 200 - <div class="form-group full"> 201 - <label for="localizedDescription">Changelog</label> 202 - <textarea id="localizedDescription" name="localizedDescription" placeholder="What's new in this release…"></textarea> 203 - </div> 204 - <div class="form-group full"> 205 - <label for="downloadURL">Download URL (manifest.json)</label> 206 - <input id="downloadURL" name="downloadURL" value="https://downloads.getorbyt.com/manifest.json" /> 207 - </div> 208 - <div class="form-group"> 209 - <label for="minOSVersion">Min iOS version</label> 210 - <input id="minOSVersion" name="minOSVersion" value="16.4" /> 211 - </div> 212 - </div> 213 - <div class="btn-row"> 214 - <button type="submit" class="btn btn-primary" id="publish-btn">Publish release</button> 215 - </div> 216 - <p class="status-line" id="release-status"></p> 217 - </form> 218 - </div> 219 - 220 - <!-- Quick Actions --> 221 - <div class="card"> 222 - <h2>Quick actions</h2> 223 - <div class="btn-row"> 224 - <button class="btn btn-secondary" id="check-source-btn">Verify source.json</button> 225 - <button class="btn btn-secondary" id="check-manifest-btn">Verify manifest.json</button> 226 - <button class="btn btn-secondary" id="federate-btn">Federate with AltStore</button> 227 - </div> 228 - <p class="status-line" id="action-status"></p> 229 - </div> 230 - 231 - <!-- CLI Release Checklist --> 232 - <div class="card"> 233 - <h2>CLI release checklist</h2> 234 - <p style="font-size:0.82rem;color:#888;margin-bottom:1rem">Run these steps before publishing above. The <code style="color:#ccc">release</code> command handles steps 1–2 automatically.</p> 235 - <pre style="background:#0f0f13;border:1px solid #2a2a36;border-radius:8px;padding:1rem;font-size:0.78rem;overflow-x:auto;color:#b0b0c0;line-height:1.7"># 1) In orbyt-app — download + extract the new ADP from App Store Connect 236 - ./scripts/altstore-pal.sh download &lt;ADP_ID&gt; 237 - unzip orbyt-adp-&lt;ADP_ID&gt;.zip -d adp-extracted 238 - 239 - # 2) In orbyt-site — upload ADP to R2 (one command) 240 - ADMIN_SECRET=... npm run altstore:release -- orbyt-altstore-adp 1.1.3 63 2026-04-18 70307293 ../orbyt-app/adp-extracted 241 - 242 - # 3) Fill in the form above and click "Publish release" 243 - 244 - # 4) Optionally redeploy if you changed any other site files 245 - npm run build &amp;&amp; npx wrangler deploy</pre> 246 - </div> 247 - </> 248 - )} 249 - 250 - </div> 251 - 252 - {isAuthed && ( 253 - <script define:vars={{ RELEASE_API, SOURCE_URL, MANIFEST_URL }}> 254 - const releaseForm = document.getElementById('release-form'); 255 - const releaseStatus = document.getElementById('release-status'); 256 - const publishBtn = document.getElementById('publish-btn'); 257 - const actionStatus = document.getElementById('action-status'); 258 - 259 - releaseForm.addEventListener('submit', async (e) => { 260 - e.preventDefault(); 261 - publishBtn.disabled = true; 262 - releaseStatus.textContent = 'Publishing…'; 263 - releaseStatus.className = 'status-line warn'; 264 - 265 - const fd = new FormData(releaseForm); 266 - const body = { 267 - version: fd.get('version'), 268 - buildVersion: fd.get('buildVersion'), 269 - date: fd.get('date'), 270 - size: Number(fd.get('size')), 271 - localizedDescription: fd.get('localizedDescription') || undefined, 272 - downloadURL: fd.get('downloadURL') || undefined, 273 - minOSVersion: fd.get('minOSVersion') || undefined, 274 - }; 275 - 276 - try { 277 - const res = await fetch(RELEASE_API, { 278 - method: 'POST', 279 - credentials: 'include', 280 - headers: { 'Content-Type': 'application/json' }, 281 - body: JSON.stringify(body), 282 - }); 283 - const data = await res.json(); 284 - if (res.ok) { 285 - releaseStatus.textContent = `✓ Published ${data.version.version} (build ${data.version.buildVersion})`; 286 - releaseStatus.className = 'status-line ok'; 287 - setTimeout(() => location.reload(), 1500); 288 - } else { 289 - releaseStatus.textContent = `✗ Error: ${data.error}`; 290 - releaseStatus.className = 'status-line err'; 291 - } 292 - } catch (err) { 293 - releaseStatus.textContent = `✗ Network error: ${err.message}`; 294 - releaseStatus.className = 'status-line err'; 295 - } finally { 296 - publishBtn.disabled = false; 297 - } 298 - }); 299 - 300 - document.getElementById('check-source-btn').addEventListener('click', async () => { 301 - actionStatus.textContent = 'Checking source.json…'; 302 - actionStatus.className = 'status-line warn'; 303 - try { 304 - const res = await fetch(SOURCE_URL, { cache: 'no-store' }); 305 - actionStatus.textContent = res.ok 306 - ? `✓ source.json → ${res.status} OK` 307 - : `✗ source.json → ${res.status}`; 308 - actionStatus.className = res.ok ? 'status-line ok' : 'status-line err'; 309 - } catch (err) { 310 - actionStatus.textContent = `✗ ${err.message}`; 311 - actionStatus.className = 'status-line err'; 312 - } 313 - }); 314 - 315 - document.getElementById('check-manifest-btn').addEventListener('click', async () => { 316 - actionStatus.textContent = 'Checking manifest.json…'; 317 - actionStatus.className = 'status-line warn'; 318 - try { 319 - const res = await fetch(MANIFEST_URL, { cache: 'no-store' }); 320 - actionStatus.textContent = res.ok 321 - ? `✓ manifest.json → ${res.status} OK` 322 - : `✗ manifest.json → ${res.status}`; 323 - actionStatus.className = res.ok ? 'status-line ok' : 'status-line err'; 324 - } catch (err) { 325 - actionStatus.textContent = `✗ ${err.message}`; 326 - actionStatus.className = 'status-line err'; 327 - } 328 - }); 329 - 330 - document.getElementById('federate-btn').addEventListener('click', async () => { 331 - if (!confirm('Federate your source with AltStore? Only needed once (or after URL changes).')) return; 332 - actionStatus.textContent = 'Federating…'; 333 - actionStatus.className = 'status-line warn'; 334 - try { 335 - const res = await fetch('https://api.altstore.io/federate', { 336 - method: 'POST', 337 - headers: { 'Content-Type': 'application/json' }, 338 - body: JSON.stringify({ source: SOURCE_URL }), 339 - }); 340 - const text = await res.text(); 341 - actionStatus.textContent = res.ok 342 - ? `✓ Federated successfully` 343 - : `ℹ Federation response ${res.status}: ${text.slice(0, 120)}`; 344 - actionStatus.className = res.ok ? 'status-line ok' : 'status-line warn'; 345 - } catch (err) { 346 - actionStatus.textContent = `✗ ${err.message}`; 347 - actionStatus.className = 'status-line err'; 348 - } 349 - }); 350 - </script> 351 - )} 352 - </body> 353 - </html>
-16
src/pages/altstore/source.json.ts
··· 1 - import type { APIRoute } from 'astro'; 2 - import { env } from 'cloudflare:workers'; 3 - import { readSource } from '../../utils/altstore-source'; 4 - 5 - export const prerender = false; 6 - 7 - export const GET: APIRoute = async () => { 8 - const source = await readSource(env.orbyt_altstore_adp); 9 - 10 - return new Response(JSON.stringify(source, null, 2), { 11 - headers: { 12 - 'Content-Type': 'application/json', 13 - 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600', 14 - }, 15 - }); 16 - };
-79
src/pages/api/admin/altstore/release.ts
··· 1 - import type { APIRoute } from 'astro'; 2 - import { env } from 'cloudflare:workers'; 3 - import { 4 - readSource, 5 - writeSource, 6 - checkBearerAuth, 7 - DEFAULT_DOWNLOAD_URL, 8 - DEFAULT_MIN_OS, 9 - } from '../../../../utils/altstore-source'; 10 - 11 - export const prerender = false; 12 - 13 - const COOKIE_NAME = 'altstore_admin'; 14 - 15 - function isAuthorized(request: Request, cookies: { get(name: string): { value: string } | undefined }): boolean { 16 - if (checkBearerAuth(request, env.ADMIN_SECRET)) return true; 17 - return cookies.get(COOKIE_NAME)?.value === env.ADMIN_SECRET; 18 - } 19 - 20 - export const POST: APIRoute = async ({ request, cookies }) => { 21 - if (!isAuthorized(request, cookies)) { 22 - return new Response(JSON.stringify({ error: 'Unauthorized' }), { 23 - status: 401, 24 - headers: { 'Content-Type': 'application/json' }, 25 - }); 26 - } 27 - 28 - let body: Record<string, unknown>; 29 - try { 30 - body = await request.json(); 31 - } catch { 32 - return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { 33 - status: 400, 34 - headers: { 'Content-Type': 'application/json' }, 35 - }); 36 - } 37 - 38 - const { version, buildVersion, date, localizedDescription, size, downloadURL, minOSVersion } = 39 - body as Record<string, unknown>; 40 - 41 - if (!version || !buildVersion || !date || size === undefined) { 42 - return new Response( 43 - JSON.stringify({ error: 'Missing required fields: version, buildVersion, date, size' }), 44 - { status: 400, headers: { 'Content-Type': 'application/json' } } 45 - ); 46 - } 47 - 48 - const newVersion = { 49 - version: String(version), 50 - buildVersion: String(buildVersion), 51 - date: String(date), 52 - ...(localizedDescription ? { localizedDescription: String(localizedDescription) } : {}), 53 - downloadURL: downloadURL ? String(downloadURL) : DEFAULT_DOWNLOAD_URL, 54 - size: Number(size), 55 - minOSVersion: minOSVersion ? String(minOSVersion) : DEFAULT_MIN_OS, 56 - }; 57 - 58 - const source = await readSource(env.orbyt_altstore_adp); 59 - source.apps[0].versions.unshift(newVersion); 60 - await writeSource(env.orbyt_altstore_adp, source); 61 - 62 - return new Response(JSON.stringify({ success: true, version: newVersion }), { 63 - headers: { 'Content-Type': 'application/json' }, 64 - }); 65 - }; 66 - 67 - export const GET: APIRoute = async ({ request, cookies }) => { 68 - if (!isAuthorized(request, cookies)) { 69 - return new Response(JSON.stringify({ error: 'Unauthorized' }), { 70 - status: 401, 71 - headers: { 'Content-Type': 'application/json' }, 72 - }); 73 - } 74 - 75 - const source = await readSource(env.orbyt_altstore_adp); 76 - return new Response(JSON.stringify(source, null, 2), { 77 - headers: { 'Content-Type': 'application/json' }, 78 - }); 79 - };
-119
src/utils/altstore-source.ts
··· 1 - export interface AppVersion { 2 - version: string; 3 - buildVersion: string; 4 - date: string; 5 - localizedDescription?: string; 6 - downloadURL: string; 7 - size: number; 8 - minOSVersion?: string; 9 - } 10 - 11 - export interface AltStoreSource { 12 - name: string; 13 - subtitle: string; 14 - description: string; 15 - iconURL: string; 16 - headerURL: string; 17 - website: string; 18 - tintColor: string; 19 - fediUsername: string; 20 - featuredApps: string[]; 21 - apps: Array<{ 22 - name: string; 23 - bundleIdentifier: string; 24 - marketplaceID: string; 25 - developerName: string; 26 - subtitle: string; 27 - localizedDescription: string; 28 - iconURL: string; 29 - tintColor: string; 30 - category: string; 31 - screenshots: unknown[]; 32 - versions: AppVersion[]; 33 - appPermissions: { 34 - privacy: Record<string, string>; 35 - }; 36 - }>; 37 - news: unknown[]; 38 - } 39 - 40 - export const SOURCE_KEY = 'altstore-source.json'; 41 - export const DEFAULT_DOWNLOAD_URL = 'https://downloads.getorbyt.com/manifest.json'; 42 - export const DEFAULT_MIN_OS = '16.4'; 43 - 44 - export const BASE_SOURCE: AltStoreSource = { 45 - name: 'Orbyt', 46 - subtitle: 'Video app for Bluesky', 47 - description: 48 - 'Orbyt is a video-first social app for the Bluesky network, built on the AT Protocol. Available for EU and Japan via AltStore PAL.', 49 - iconURL: 'https://getorbyt.com/altstore/orbyt-icon.png', 50 - headerURL: 'https://getorbyt.com/images/orbyt-logotype.png', 51 - website: 'https://getorbyt.com', 52 - tintColor: '#9B59B6', 53 - fediUsername: 'orbyt', 54 - featuredApps: ['com.getorbyt.app'], 55 - apps: [ 56 - { 57 - name: 'Orbyt', 58 - bundleIdentifier: 'com.getorbyt.app', 59 - marketplaceID: '6751679299', 60 - developerName: 'Orbyt', 61 - subtitle: 'Video app for Bluesky', 62 - localizedDescription: 63 - 'Orbyt is a video-first social app for the Bluesky network.\n\n• Browse video feeds from Bluesky\n• Create and share short-form videos\n• Connect with the AT Protocol community\n• Built with React Native and Expo', 64 - iconURL: 'https://getorbyt.com/altstore/orbyt-icon.png', 65 - tintColor: '#9B59B6', 66 - category: 'social', 67 - screenshots: [], 68 - versions: [ 69 - { 70 - version: '1.1.2', 71 - buildVersion: '62', 72 - date: '2026-03-12', 73 - localizedDescription: 'Latest release.', 74 - downloadURL: DEFAULT_DOWNLOAD_URL, 75 - size: 70307293, 76 - minOSVersion: DEFAULT_MIN_OS, 77 - }, 78 - ], 79 - appPermissions: { 80 - privacy: { 81 - NSCameraUsageDescription: 82 - 'We need camera access to record videos for posts and take profile photos.', 83 - NSMicrophoneUsageDescription: 84 - 'We need microphone access to record audio with your videos.', 85 - NSPhotoLibraryUsageDescription: 86 - 'We need access to your photo library to select videos for posts and photos for your profile.', 87 - NSPhotoLibraryAddUsageDescription: 88 - 'We need permission to save your videos to the camera roll.', 89 - }, 90 - }, 91 - }, 92 - ], 93 - news: [], 94 - }; 95 - 96 - export async function readSource(bucket: R2Bucket): Promise<AltStoreSource> { 97 - try { 98 - const obj = await bucket.get(SOURCE_KEY); 99 - if (obj) { 100 - const text = await obj.text(); 101 - return JSON.parse(text) as AltStoreSource; 102 - } 103 - } catch { 104 - // Fall through to base source 105 - } 106 - return structuredClone(BASE_SOURCE); 107 - } 108 - 109 - export async function writeSource(bucket: R2Bucket, source: AltStoreSource): Promise<void> { 110 - await bucket.put(SOURCE_KEY, JSON.stringify(source, null, 2), { 111 - httpMetadata: { contentType: 'application/json' }, 112 - }); 113 - } 114 - 115 - export function checkBearerAuth(request: Request, secret: string): boolean { 116 - const auth = request.headers.get('Authorization'); 117 - if (!auth?.startsWith('Bearer ')) return false; 118 - return auth.slice(7) === secret; 119 - }