diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index 2a31ff99..09fa8860 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -52,6 +52,7 @@ import { createApiToken, listApiTokens, revokeApiToken } from './auth/api-token' import { checkAndCacheSandbоxHealth, getCachedHealth } from './health/checker'; import { sendEmail, buildInterestThankYouEmail, buildInterestNotificationEmail, buildTemplateReviewEmail } from './email/resend'; import * as blog from './blog/handler'; +import * as releases from './releases/handler'; import { sandboxHeaders, sandboxUrl } from './sandbox/fetch'; // Export Durable Objects @@ -837,6 +838,12 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick { + try { + const resp = await fetch(GITHUB_LATEST, { + headers: { + // GitHub rejects API requests without a User-Agent. + 'User-Agent': 'orcabot-controlplane', + Accept: 'application/vnd.github+json', + }, + // Cache at the edge so repeated page loads don't re-hit GitHub. + cf: { cacheTtl: 300, cacheEverything: true }, + }); + + if (!resp.ok) { + return Response.json( + { error: `GitHub returned ${resp.status}` }, + { status: 502, headers: { 'Cache-Control': 'public, max-age=60' } } + ); + } + + const gh = (await resp.json()) as { + tag_name?: string; + name?: string; + body?: string; + html_url?: string; + published_at?: string; + assets?: Array<{ + name: string; + size: number; + browser_download_url: string; + content_type: string; + download_count: number; + }>; + }; + + const release: LatestRelease = { + version: (gh.tag_name || '').replace(/^v/, ''), + name: gh.name || gh.tag_name || '', + notes: gh.body || '', + htmlUrl: gh.html_url || `https://github.com/${RELEASES_REPO}/releases/latest`, + publishedAt: gh.published_at || '', + assets: (gh.assets || []).map((a) => ({ + name: a.name, + size: a.size, + downloadUrl: a.browser_download_url, + contentType: a.content_type, + downloadCount: a.download_count, + })), + }; + + return Response.json(release, { + headers: { 'Cache-Control': 'public, max-age=300' }, + }); + } catch { + return Response.json( + { error: 'Failed to fetch latest release' }, + { status: 502 } + ); + } +} diff --git a/frontend/src/app/download/download.css b/frontend/src/app/download/download.css new file mode 100644 index 00000000..00ec9301 --- /dev/null +++ b/frontend/src/app/download/download.css @@ -0,0 +1,225 @@ +/* Copyright 2026 Rob Macrae. All rights reserved. */ +/* On-brand skin for the /download page (mirrors the splash/header palette). */ + +.dl-page { + min-height: 100vh; + background: + radial-gradient(1200px 600px at 50% -10%, rgba(59, 130, 246, 0.15), transparent 60%), + linear-gradient(180deg, #06101f 0%, #081426 100%); + color: #e6eefc; +} + +.dl-main { + max-width: 46rem; + margin: 0 auto; + padding: 3rem 1.5rem 5rem; +} + +.dl-hero { + text-align: center; + padding: 2rem 0 1rem; +} + +.dl-orca { + width: 84px; + height: 84px; + object-fit: contain; + margin: 0 auto 1rem; + display: block; +} + +.dl-hero h1 { + font-size: 2.25rem; + font-weight: 800; + margin: 0 0 0.5rem; + color: #fff; +} + +.dl-sub { + color: rgba(230, 238, 252, 0.7); + margin: 0 0 1.75rem; + font-size: 1.05rem; +} + +.dl-cta { + display: flex; + justify-content: center; + margin-bottom: 1rem; +} + +.dl-btn { + display: inline-flex; + flex-direction: column; + align-items: center; + text-decoration: none; + font-weight: 700; + border-radius: 12px; + padding: 14px 28px; + line-height: 1.2; +} + +.dl-btn-primary { + color: #06101f; + background: linear-gradient(180deg, #cfe0ff, #8fb4ff); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.4) inset, + 0 10px 30px rgba(59, 130, 246, 0.35); + font-size: 1.05rem; + transition: filter 0.15s ease; +} + +.dl-btn-primary:hover { + filter: brightness(1.05); +} + +.dl-btn-sub { + font-weight: 600; + font-size: 0.8rem; + opacity: 0.75; + margin-top: 2px; +} + +.dl-meta { + color: rgba(230, 238, 252, 0.6); + font-size: 0.9rem; +} + +.dl-meta a { + color: #9fc0ff; + text-decoration: none; +} + +.dl-meta a:hover { + text-decoration: underline; +} + +.dl-version { + font-weight: 700; + color: #cfe0ff; +} + +.dl-badge { + display: inline-block; + margin-left: 0.5rem; + padding: 1px 8px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: #7ee0a8; + background: rgba(46, 204, 113, 0.14); + border: 1px solid rgba(46, 204, 113, 0.35); + border-radius: 999px; + vertical-align: middle; +} + +.dl-card { + margin-top: 2rem; + padding: 1.5rem 1.75rem; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.dl-card h2 { + font-size: 1.15rem; + font-weight: 700; + margin: 0 0 1rem; + color: #fff; +} + +.dl-notes { + line-height: 1.7; + color: rgba(230, 238, 252, 0.85); + word-wrap: break-word; +} + +.dl-notes h1, +.dl-notes h2, +.dl-notes h3 { + color: #fff; + margin: 1.25rem 0 0.5rem; + font-size: 1.05rem; +} + +.dl-notes ul, +.dl-notes ol { + padding-left: 1.25rem; +} + +.dl-notes li { + margin: 0.25rem 0; +} + +.dl-notes a { + color: #9fc0ff; +} + +.dl-notes code { + background: rgba(255, 255, 255, 0.08); + padding: 0.1rem 0.35rem; + border-radius: 5px; + font-size: 0.9em; +} + +.dl-notes pre { + background: rgba(0, 0, 0, 0.3); + padding: 1rem; + border-radius: 10px; + overflow-x: auto; +} + +.dl-notes pre code { + background: none; + padding: 0; +} + +.dl-assets { + list-style: none; + padding: 0; + margin: 0; +} + +.dl-assets li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + gap: 1rem; +} + +.dl-assets li:last-child { + border-bottom: none; +} + +.dl-assets a { + color: #cfe0ff; + text-decoration: none; + word-break: break-all; +} + +.dl-assets a:hover { + text-decoration: underline; +} + +.dl-muted { + color: rgba(230, 238, 252, 0.5); + font-size: 0.85rem; + white-space: nowrap; +} + +.dl-error { + color: #ffb4b4; +} + +.dl-error a { + color: #9fc0ff; +} + +.dl-foot { + text-align: center; + margin-top: 2.5rem; +} diff --git a/frontend/src/app/download/page.tsx b/frontend/src/app/download/page.tsx new file mode 100644 index 00000000..677f5a3f --- /dev/null +++ b/frontend/src/app/download/page.tsx @@ -0,0 +1,135 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary + +// REVISION: download-page-v1 +"use client"; + +import "./download.css"; +import * as React from "react"; +import Markdown from "react-markdown"; +import { SiteHeader } from "@/components/SiteHeader"; +import { + getLatestRelease, + pickMacInstaller, + formatBytes, + type LatestRelease, +} from "@/lib/api/releases"; + +const GITHUB_RELEASES = "https://github.com/Hyper-Int/OrcaBot/releases/latest"; + +export default function DownloadPage() { + const [release, setRelease] = React.useState(null); + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + let cancelled = false; + getLatestRelease() + .then((r) => { + if (!cancelled) { + setRelease(r); + setLoading(false); + } + }) + .catch((e) => { + if (!cancelled) { + setError(e instanceof Error ? e.message : "Failed to load release"); + setLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, []); + + const primary = release ? pickMacInstaller(release.assets) : null; + const assets = release ? release.assets : []; + + return ( +
+ +
+
+ +

Download Orcabot

+

+ Run Claude Code, Codex, and a full sandboxed stack locally — no setup. +

+ + {loading &&

Loading the latest release…

} + + {error && ( +

+ Couldn’t load the release.{" "} + Download on GitHub → +

+ )} + + {release && ( + <> + +
+ v{release.version} + Latest + {release.publishedAt && ( + + {" · "} + {new Date(release.publishedAt).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })} + + )} + {" · "} + + View on GitHub ↗ + +
+ + )} +
+ + {release && release.notes && ( +
+

What’s new{release.name ? ` — ${release.name}` : ""}

+
+ {release.notes} +
+
+ )} + + {release && assets.length > 0 && ( +
+

Assets

+
    + {assets.map((a) => ( +
  • + {a.name} + {formatBytes(a.size)} +
  • + ))} +
+
+ )} + +

+ macOS 13+ · Apple Silicon. Existing installs update automatically. +

+
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 0db8801c..5798c62e 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -628,6 +628,19 @@ export default function Home() { Terms of Service and{" "} Privacy Policy

+ + Download the macOS app → + Explore { + const res = await fetch(API.cloudflare.releasesLatest, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + throw new Error(`Failed to load latest release (${res.status})`); + } + return (await res.json()) as LatestRelease; +} + +/** Human-readable file size, e.g. 1038066117 → "990 MB". */ +export function formatBytes(bytes: number): string { + if (!bytes || bytes < 0) return ""; + const units = ["B", "KB", "MB", "GB"]; + let n = bytes; + let i = 0; + while (n >= 1024 && i < units.length - 1) { + n /= 1024; + i++; + } + return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${units[i]}`; +} + +/** + * The primary macOS installer to feature on the download button. Prefers a + * `.dmg`, falls back to a `.zip` (salvaged builds ship a zip). Ignores the + * updater tarball (`.app.tar.gz`) and signature/manifest files. + */ +export function pickMacInstaller(assets: ReleaseAsset[]): ReleaseAsset | null { + const installable = assets.filter( + (a) => !a.name.endsWith(".tar.gz") && !a.name.endsWith(".sig") && a.name !== "latest.json" + ); + return ( + installable.find((a) => a.name.endsWith(".dmg")) || + installable.find((a) => a.name.endsWith(".zip")) || + null + ); +}