| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { connectLink } from "@g1t/contracts/discovery"; |
| 3 | import { Apple, Bell, Download, Link2, Monitor, RefreshCw, Server, Smartphone, Terminal } from "lucide-react"; |
| 4 | import type { ReactNode } from "react"; |
| 5 | import { useLoaderData } from "react-router"; |
| 6 | |
| 7 | import type { Route } from "./+types/download"; |
| 8 | import { QrCode } from "../components/qr-code"; |
| 9 | import { ButtonLink } from "../components/ui"; |
| 10 | import { Card } from "../components/ui/card"; |
| 11 | import { HOSTED_ADDRESSES } from "../lib/addresses"; |
| 12 | import { addresses } from "../lib/addresses.server"; |
| 13 | import { page } from "../lib/meta"; |
| 14 | |
| 15 | /** |
| 16 | * g1t.sh/download: the desktop app, from its newest release, and the phone |
| 17 | * app. On a self-hosted g1t the phone section offers a `g1t://connect` |
| 18 | * link and its QR code, which point the app at this g1t. The release |
| 19 | * is `desktop/latest.json` in the g1t-downloads bucket, as |
| 20 | * scripts/desktop-release.mjs writes it: what the app's updater reads |
| 21 | * (`platforms`), and the list this page shows (`downloads`). The visitor's |
| 22 | * own platform, read from the browser, comes first. |
| 23 | */ |
| 24 | |
| 25 | export type DesktopDownload = { |
| 26 | platform: "windows" | "macos" | "linux"; |
| 27 | arch: "x64" | "arm64"; |
| 28 | kind: string; |
| 29 | name: string; |
| 30 | size: number; |
| 31 | sha256: string; |
| 32 | }; |
| 33 | |
| 34 | export type DesktopRelease = { |
| 35 | version: string; |
| 36 | pub_date: string; |
| 37 | notes?: string; |
| 38 | downloads: DesktopDownload[]; |
| 39 | }; |
| 40 | |
| 41 | type Platform = DesktopDownload["platform"]; |
| 42 | |
| 43 | /** The phone app's newest Android release (scripts/mobile-release.mjs): `mobile/latest.json`. */ |
| 44 | export type MobileRelease = { |
| 45 | version: string; |
| 46 | build: number | null; |
| 47 | pub_date: string; |
| 48 | notes?: string; |
| 49 | downloads: { platform: "android"; kind: "apk"; name: string; size: number; sha256: string }[]; |
| 50 | }; |
| 51 | |
| 52 | /** A manifest as read from the bucket, or null when it is not one. */ |
| 53 | async function readJson<T>(bucket: R2Bucket | undefined, key: string, check: (value: Partial<T>) => boolean): Promise<T | null> { |
| 54 | try { |
| 55 | const found = bucket ? await bucket.get(key) : null; |
| 56 | if (!found) return null; |
| 57 | const read = (await found.json()) as Partial<T>; |
| 58 | return read && check(read) ? (read as T) : null; |
| 59 | } catch (error) { |
| 60 | console.error(`download: ${key} could not be read`, error); |
| 61 | return null; |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | /** Which platform a browser says it is on; null when it does not say. */ |
| 66 | export function platformOf(userAgent: string | null): Platform | null { |
| 67 | if (!userAgent) return null; |
| 68 | if (/Windows/i.test(userAgent)) return "windows"; |
| 69 | if (/Mac OS X|Macintosh/i.test(userAgent)) return "macos"; |
| 70 | if (/Linux|X11/i.test(userAgent) && !/Android/i.test(userAgent)) return "linux"; |
| 71 | return null; |
| 72 | } |
| 73 | |
| 74 | export function meta(args: Route.MetaArgs) { |
| 75 | return page(args, { |
| 76 | title: "Download g1t for your desktop and phone", |
| 77 | description: "g1t in a window of its own, for Windows, macOS and Linux, and on your phone: notifications, a badge, g1t:// links, and updates that install themselves.", |
| 78 | }); |
| 79 | } |
| 80 | |
| 81 | export async function loader({ request }: Route.LoaderArgs) { |
| 82 | const bucket = env.DOWNLOADS; |
| 83 | let release: DesktopRelease | null = null; |
| 84 | try { |
| 85 | const found = bucket ? await bucket.get("desktop/latest.json") : null; |
| 86 | if (found) { |
| 87 | const read = (await found.json()) as Partial<DesktopRelease>; |
| 88 | if (read && typeof read.version === "string" && Array.isArray(read.downloads)) { |
| 89 | release = { version: read.version, pub_date: read.pub_date ?? "", notes: read.notes, downloads: read.downloads }; |
| 90 | } |
| 91 | } |
| 92 | } catch (error) { |
| 93 | console.error("download: the release could not be read", error); |
| 94 | } |
| 95 | const mobile = await readJson<MobileRelease>(bucket, "mobile/latest.json", (read) => typeof read.version === "string" && Array.isArray(read.downloads)); |
| 96 | const { site } = addresses(); |
| 97 | const userAgent = request.headers.get("user-agent") ?? ""; |
| 98 | return { |
| 99 | release, |
| 100 | platform: platformOf(userAgent), |
| 101 | android: /Android/i.test(userAgent), |
| 102 | mobile, |
| 103 | site, |
| 104 | hosted: site === HOSTED_ADDRESSES.site, |
| 105 | }; |
| 106 | } |
| 107 | |
| 108 | const PLATFORMS: { id: Platform; name: string; icon: ReactNode; note: string }[] = [ |
| 109 | { id: "windows", name: "Windows", icon: <Monitor size={16} />, note: "Windows 10 and 11. Installs for you alone, no administrator needed." }, |
| 110 | { id: "macos", name: "macOS", icon: <Apple size={16} />, note: "macOS 11 and later. Apple silicon and Intel are separate downloads." }, |
| 111 | { id: "linux", name: "Linux", icon: <Terminal size={16} />, note: "An AppImage that runs anywhere, or a package for Debian and Ubuntu." }, |
| 112 | ]; |
| 113 | |
| 114 | const KINDS: Record<string, string> = { |
| 115 | installer: "Installer", |
| 116 | dmg: "Disk image", |
| 117 | appimage: "AppImage", |
| 118 | deb: "Debian package", |
| 119 | rpm: "RPM package", |
| 120 | }; |
| 121 | |
| 122 | function archLabel(download: DesktopDownload): string { |
| 123 | if (download.platform === "macos") return download.arch === "arm64" ? "Apple silicon" : "Intel"; |
| 124 | return download.arch === "arm64" ? "arm64" : "x64"; |
| 125 | } |
| 126 | |
| 127 | function size(bytes: number): string { |
| 128 | if (!bytes) return ""; |
| 129 | return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(0)} MB` : `${Math.ceil(bytes / 1024)} KB`; |
| 130 | } |
| 131 | |
| 132 | const WHAT: { icon: ReactNode; title: string; text: string }[] = [ |
| 133 | { icon: <Bell size={16} />, title: "Notifications from your system", text: "A message to you, a mention, or an agent finished, while the window is behind another; the icon carries a badge until you have looked." }, |
| 134 | { icon: <Link2 size={16} />, title: "g1t:// links", text: "A g1t:// link opens the page in the app, from anywhere on your computer. Links to other sites open in your browser." }, |
| 135 | { icon: <RefreshCw size={16} />, title: "Updates install themselves", text: "The app downloads a new version in the background, signed by g1t, and asks only to restart. Everything you see is the site itself, so most changes need no update at all." }, |
| 136 | { icon: <Server size={16} />, title: "Your own g1t", text: "Running g1t yourself? The tray's \u201cUse another g1t\u201d points the app at your server, and the phone app below works with it too." }, |
| 137 | ]; |
| 138 | |
| 139 | export default function DownloadPage() { |
| 140 | const { release, platform, site, hosted, mobile, android } = useLoaderData<typeof loader>(); |
| 141 | const ordered = platform ? [...PLATFORMS.filter((p) => p.id === platform), ...PLATFORMS.filter((p) => p.id !== platform)] : PLATFORMS; |
| 142 | const yours = platform && release ? release.downloads.filter((d) => d.platform === platform) : []; |
| 143 | const featured = yours.find((d) => d.kind === "installer" || d.kind === "dmg" || d.kind === "appimage") ?? yours[0] ?? null; |
| 144 | const href = (download: DesktopDownload) => `/downloads/desktop/${release!.version}/${download.name}`; |
| 145 | |
| 146 | // A marketing page, drawn like Pricing: the site's header and footer, |
| 147 | // never the app's rail (lib/chrome.ts, MARKETING). |
| 148 | return ( |
| 149 | <main className="mx-auto max-w-4xl px-4 py-12"> |
| 150 | <p className="text-sm font-medium text-accent">Desktop and phone apps</p> |
| 151 | <h1 className="mt-2 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">g1t on your desktop, and in your pocket</h1> |
| 152 | <p className="mt-3 max-w-2xl text-muted"> |
| 153 | The whole of g1t in a window of its own: chat, agents, artifacts and code, for Windows, macOS and Linux, with notifications |
| 154 | from your system and updates that install themselves. |
| 155 | {release && ( |
| 156 | <> |
| 157 | {" "} |
| 158 | Version {release.version} |
| 159 | {release.pub_date && ( |
| 160 | <> |
| 161 | , released{" "} |
| 162 | {new Date(release.pub_date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })} |
| 163 | </> |
| 164 | )} |
| 165 | . |
| 166 | </> |
| 167 | )} |
| 168 | </p> |
| 169 | <div className="mt-8"> |
| 170 | {!release && ( |
| 171 | <Card className="p-5"> |
| 172 | <p className="font-medium">The first release is on its way.</p> |
| 173 | <p className="mt-1 text-sm text-muted"> |
| 174 | The desktop app is built and being released now. Until it is here, g1t works as an installed web app too: in your browser's menu, choose |
| 175 | Install or Add to Dock. |
| 176 | </p> |
| 177 | </Card> |
| 178 | )} |
| 179 | |
| 180 | {release && featured && ( |
| 181 | <Card className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between"> |
| 182 | <div> |
| 183 | <p className="font-medium">For your {PLATFORMS.find((p) => p.id === platform)?.name}</p> |
| 184 | <p className="mt-1 text-sm text-muted"> |
| 185 | {KINDS[featured.kind] ?? featured.kind}, {archLabel(featured)} |
| 186 | {featured.size ? `, ${size(featured.size)}` : ""}. |
| 187 | {yours.length > 1 ? " The other downloads for it are below." : ""} |
| 188 | </p> |
| 189 | </div> |
| 190 | <ButtonLink to={href(featured)} reloadDocument className="shrink-0"> |
| 191 | <Download size={16} /> |
| 192 | Download g1t {release.version} |
| 193 | </ButtonLink> |
| 194 | </Card> |
| 195 | )} |
| 196 | |
| 197 | {release && !featured && platform && ( |
| 198 | <Card className="p-5"> |
| 199 | <p className="font-medium">Not yet for {PLATFORMS.find((p) => p.id === platform)?.name}.</p> |
| 200 | <p className="mt-1 text-sm text-muted"> |
| 201 | Version {release.version} is out for {Array.from(new Set(release.downloads.map((d) => PLATFORMS.find((p) => p.id === d.platform)?.name))).join(" and ")}; |
| 202 | yours is built in a later release. Until then, g1t works as an installed web app: in your browser's menu, choose Install or Add to Dock. |
| 203 | </p> |
| 204 | </Card> |
| 205 | )} |
| 206 | |
| 207 | <div className="mt-10 grid gap-3 sm:grid-cols-2"> |
| 208 | {WHAT.map((item) => ( |
| 209 | <div key={item.title} className="flex gap-3 rounded-xl border border-line bg-surface p-5"> |
| 210 | <span className="mt-0.5 shrink-0 text-accent">{item.icon}</span> |
| 211 | <span> |
| 212 | <span className="block font-medium">{item.title}</span> |
| 213 | <span className="mt-1 block text-sm text-muted">{item.text}</span> |
| 214 | </span> |
| 215 | </div> |
| 216 | ))} |
| 217 | </div> |
| 218 | |
| 219 | {release && ( |
| 220 | <section className="mt-14"> |
| 221 | <h2 className="text-xl font-semibold tracking-tight">All downloads</h2> |
| 222 | <p className="mt-1 text-sm text-muted"> |
| 223 | Every file of version {release.version}, with its SHA-256 in{" "} |
| 224 | <a href={`/downloads/desktop/${release.version}/SHA256SUMS`} className="text-accent hover:underline"> |
| 225 | SHA256SUMS |
| 226 | </a> |
| 227 | . |
| 228 | </p> |
| 229 | <div className="mt-4 space-y-3"> |
| 230 | {ordered.map((p) => { |
| 231 | const files = release.downloads.filter((d) => d.platform === p.id); |
| 232 | // A platform this release lacks: it is built on a machine not yet registered. |
| 233 | if (files.length === 0) { |
| 234 | return ( |
| 235 | <Card key={p.id} tone="plain" className="flex flex-wrap items-center gap-2 rounded-xl px-4 py-3"> |
| 236 | <span className="text-faint">{p.icon}</span> |
| 237 | <span className="font-medium text-muted">{p.name}</span> |
| 238 | <span className="text-sm text-muted">· Coming in a later release. {p.note}</span> |
| 239 | </Card> |
| 240 | ); |
| 241 | } |
| 242 | return ( |
| 243 | <Card key={p.id} tone="plain" divided className="rounded-xl"> |
| 244 | <div className="flex items-center gap-2 px-4 py-3"> |
| 245 | <span className="text-accent">{p.icon}</span> |
| 246 | <span className="font-medium">{p.name}</span> |
| 247 | <span className="text-sm text-muted">· {p.note}</span> |
| 248 | </div> |
| 249 | {files.map((d) => ( |
| 250 | <div key={d.name} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-2.5 text-sm"> |
| 251 | <a href={href(d)} className="font-medium text-fg hover:underline"> |
| 252 | {KINDS[d.kind] ?? d.kind}, {archLabel(d)} |
| 253 | </a> |
| 254 | <span className="text-muted">{size(d.size)}</span> |
| 255 | <code className="ml-auto truncate font-mono text-xs text-faint">{d.name}</code> |
| 256 | </div> |
| 257 | ))} |
| 258 | </Card> |
| 259 | ); |
| 260 | })} |
| 261 | </div> |
| 262 | <p className="mt-6 text-sm text-muted"> |
| 263 | On macOS the app is not yet notarized with Apple: the first time, right-click it and choose Open. How the app works, and how a |
| 264 | release is made, is in{" "} |
| 265 | <a href="https://docs.g1t.sh/guides/desktop/" className="text-accent hover:underline"> |
| 266 | the desktop app guide |
| 267 | </a> |
| 268 | . |
| 269 | </p> |
| 270 | </section> |
| 271 | )} |
| 272 | <PhoneApp site={site} hosted={hosted} mobile={mobile} android={android} /> |
| 273 | </div> |
| 274 | </main> |
| 275 | ); |
| 276 | } |
| 277 | |
| 278 | /** |
| 279 | * The phone app: Chat with people and agents, live, and notifications on |
| 280 | * the phone. Not in Google Play or the App Store yet (both wait on |
| 281 | * Flagon's D-U-N-S number), so the Android app is offered here as an APK, |
| 282 | * with how to install it and why it is not in a store. On a g1t of your |
| 283 | * own, a link and its QR code point the app here, so nobody types the |
| 284 | * address; on g1t.sh the app starts here anyway. |
| 285 | */ |
| 286 | function PhoneApp({ site, hosted, mobile, android }: { site: string; hosted: boolean; mobile: MobileRelease | null; android: boolean }) { |
| 287 | const link = connectLink(site); |
| 288 | const host = new URL(site).host; |
| 289 | const apk = mobile?.downloads.find((download) => download.kind === "apk") ?? null; |
| 290 | // The release's own address on g1t.sh, wherever this page is: the app is g1t's, served from g1t.sh. |
| 291 | const apkUrl = apk && mobile ? `${HOSTED_ADDRESSES.site}/downloads/mobile/${mobile.version}/${apk.name}` : null; |
| 292 | return ( |
| 293 | <section className="mt-14"> |
| 294 | <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight"> |
| 295 | <Smartphone size={18} className="text-accent" /> |
| 296 | On your phone |
| 297 | </h2> |
| 298 | <p className="mt-1 max-w-2xl text-sm text-muted"> |
| 299 | Chat with your team and its agents, live, with notifications for messages to you, mentions, replies and agents waiting on you; |
| 300 | review and approve pull requests on the way. Android first, with iOS to follow. |
| 301 | </p> |
| 302 | |
| 303 | {apk && apkUrl && mobile ? ( |
| 304 | <Card className="mt-4 flex flex-col gap-5 p-5 sm:flex-row sm:items-center"> |
| 305 | {!android && ( |
| 306 | <div className="shrink-0"> |
| 307 | <QrCode value={apkUrl} label="Download the g1t app for Android" size={152} /> |
| 308 | </div> |
| 309 | )} |
| 310 | <div className="min-w-0"> |
| 311 | <p className="font-medium">g1t for Android, beta {mobile.version}</p> |
| 312 | <p className="mt-1 text-sm text-muted"> |
| 313 | {android |
| 314 | ? "Download it, open the file, and allow your browser to install apps when Android asks." |
| 315 | : "Scan this with your Android phone's camera to download it there, then open the file and allow your browser to install apps when Android asks."}{" "} |
| 316 | It installs as <span className="text-fg-soft">g1t (beta)</span>, and takes most updates by itself. |
| 317 | </p> |
| 318 | <ButtonLink to={apkUrl} reloadDocument className="mt-3"> |
| 319 | <Download size={16} /> |
| 320 | Download for Android{apk.size ? ` (${size(apk.size)})` : ""} |
| 321 | </ButtonLink> |
| 322 | <p className="mt-2 font-mono text-xs break-all text-faint">SHA-256 {apk.sha256}</p> |
| 323 | </div> |
| 324 | </Card> |
| 325 | ) : hosted ? ( |
| 326 | <Card className="mt-4 p-5"> |
| 327 | <p className="font-medium">The first Android build is on its way.</p> |
| 328 | <p className="mt-1 text-sm text-muted">It will be here to download as soon as it is published.</p> |
| 329 | </Card> |
| 330 | ) : ( |
| 331 | <Card className="mt-4 p-5"> |
| 332 | <p className="font-medium">Get the app from g1t.sh</p> |
| 333 | <p className="mt-1 text-sm text-muted"> |
| 334 | The app is the same for every g1t: download it from{" "} |
| 335 | <a href={`${HOSTED_ADDRESSES.site}/download`} className="text-accent hover:underline"> |
| 336 | g1t.sh/download |
| 337 | </a> |
| 338 | , then point it here with the code below. |
| 339 | </p> |
| 340 | </Card> |
| 341 | )} |
| 342 | |
| 343 | <div className="mt-4 rounded-xl border border-line bg-surface p-5 text-sm"> |
| 344 | <p className="font-medium text-fg">Why a download, and not Google Play or the App Store?</p> |
| 345 | <p className="mt-1 text-muted"> |
| 346 | Both stores publish a company's apps only once the company is verified with a D-U-N-S number, and Flagon's is on its way. Until it |
| 347 | arrives, the Android app is published {hosted ? "here" : "on g1t.sh"}, signed by us, as the desktop app is. Android asks once to allow installing it from your |
| 348 | browser; that is the only difference. It moves into Google Play as soon as it can, and iOS follows through the App Store, which has no |
| 349 | other way in. |
| 350 | </p> |
| 351 | </div> |
| 352 | |
| 353 | {!hosted && ( |
| 354 | <Card className="mt-4 flex flex-col gap-5 p-5 sm:flex-row sm:items-center"> |
| 355 | <div className="shrink-0"> |
| 356 | <QrCode value={link} label={`Open the g1t app on ${host}`} size={152} /> |
| 357 | </div> |
| 358 | <div className="min-w-0"> |
| 359 | <p className="font-medium">Use the app with {host}</p> |
| 360 | <p className="mt-1 text-sm text-muted"> |
| 361 | With the g1t app on your phone, scan this with the camera, or open this page on the phone and tap the button: the app signs in |
| 362 | here instead of g1t.sh. In the app, <span className="text-fg-soft">Using your own g1t?</span> on the sign-in screen does the same with |
| 363 | the address typed. |
| 364 | </p> |
| 365 | <ButtonLink to={link} reloadDocument variant="secondary" className="mt-3"> |
| 366 | <Smartphone size={16} /> |
| 367 | Open in the g1t app |
| 368 | </ButtonLink> |
| 369 | </div> |
| 370 | </Card> |
| 371 | )} |
| 372 | |
| 373 | <p className="mt-4 text-sm text-muted"> |
| 374 | How it works, and what it does with a g1t of your own, is in{" "} |
| 375 | <a href="https://docs.g1t.sh/guides/mobile/" className="text-accent hover:underline"> |
| 376 | the phone app guide |
| 377 | </a> |
| 378 | . |
| 379 | </p> |
| 380 | </section> |
| 381 | ); |
| 382 | } |