Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

723 linesCodeBlameRaw
1import { env } from "cloudflare:workers";
2import { connectLink } from "@g1t/contracts/discovery";
3import { ArrowRight, Bell, Download, Link2, RefreshCw, Server, ShieldAlert, Smartphone } from "lucide-react";
4import { type ReactNode, useEffect, useRef, useState } from "react";
5import { useLoaderData } from "react-router";
6
7import type { Route } from "./+types/download";
8import { PlatformMark } from "../components/platform-mark";
9import { QrCode } from "../components/qr-code";
10import { ButtonLink } from "../components/ui";
11import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "../components/ui/accordion";
12import { Alert, AlertDescription, AlertTitle } from "../components/ui/alert";
13import { Badge } from "../components/ui/badge";
14import { Button } from "../components/ui/button";
15import { Card, CardContent } from "../components/ui/card";
16import { Hint } from "../components/ui/hint";
17import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
18import { HOSTED_ADDRESSES } from "../lib/addresses";
19import { addresses } from "../lib/addresses.server";
20import {
21 type Arch,
22 type DesktopPlatform,
23 type Device,
24 PLATFORM_IDS,
25 type PlatformId,
26 alternateFiles,
27 archOfHint,
28 deviceOf,
29 featuredFile,
30 isDesktop,
31 isPlatform,
32} from "../lib/download-platform";
33import { page } from "../lib/meta";
34
35/**
36 * g1t.sh/download: the desktop app, the phone app, and the runner and CLI
37 * for each computer, behind one switcher of platforms that opens on the
38 * visitor's own (lib/download-platform.ts reads it from the User-Agent, so
39 * the first byte is already theirs; `?platform=` chooses another). On a
40 * self-hosted g1t a `g1t://connect` link and its QR code point the phone
41 * app at this g1t.
42 *
43 * Everything offered comes from the newest release's manifest in the
44 * g1t-downloads bucket: `desktop/latest.json` (scripts/desktop-release.mjs:
45 * `downloads` is the list this page shows), `mobile/latest.json`
46 * (scripts/mobile-release.mjs), and `runner/latest.json` and
47 * `cli/latest.json` (scripts/runner-release.mjs, cli-release.mjs). A
48 * platform a release lacks is shown as coming.
49 */
50
51export type DesktopDownload = {
52 platform: DesktopPlatform;
53 arch: Arch;
54 kind: string;
55 name: string;
56 size: number;
57 sha256: string;
58};
59
60export type DesktopRelease = {
61 version: string;
62 pub_date: string;
63 notes?: string;
64 downloads: DesktopDownload[];
65};
66
67/** The phone app's newest Android release (scripts/mobile-release.mjs): `mobile/latest.json`. */
68export type MobileRelease = {
69 version: string;
70 build: number | null;
71 pub_date: string;
72 notes?: string;
73 downloads: { platform: "android"; kind: "apk"; name: string; size: number; sha256: string }[];
74};
75
76/** The runner's or the CLI's newest release: one file for each `<os>-<arch>`. */
77export type ToolRelease = {
78 version: string;
79 files: Record<string, { name: string; sha256: string }>;
80};
81
82/** A manifest as read from the bucket, or null when it is not one. */
83async function readJson<T>(bucket: R2Bucket | undefined, key: string, check: (value: Partial<T>) => boolean): Promise<T | null> {
84 try {
85 const found = bucket ? await bucket.get(key) : null;
86 if (!found) return null;
87 const read = (await found.json()) as Partial<T>;
88 return read && check(read) ? (read as T) : null;
89 } catch (error) {
90 console.error(`download: ${key} could not be read`, error);
91 return null;
92 }
93}
94
95const hasDownloads = (read: { version?: unknown; downloads?: unknown }) => typeof read.version === "string" && Array.isArray(read.downloads);
96const hasFiles = (read: { version?: unknown; files?: unknown }) => typeof read.version === "string" && !!read.files && typeof read.files === "object";
97
98export function meta(args: Route.MetaArgs) {
99 return page(args, {
100 title: "Download g1t for your desktop and phone",
101 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.",
102 });
103}
104
105export async function loader({ request }: Route.LoaderArgs) {
106 const bucket = env.DOWNLOADS;
107 const [desktop, mobile, runner, cli] = await Promise.all([
108 readJson<DesktopRelease>(bucket, "desktop/latest.json", hasDownloads),
109 readJson<MobileRelease>(bucket, "mobile/latest.json", hasDownloads),
110 readJson<ToolRelease>(bucket, "runner/latest.json", hasFiles),
111 readJson<ToolRelease>(bucket, "cli/latest.json", hasFiles),
112 ]);
113 const { site } = addresses();
114 const asked = new URL(request.url).searchParams.get("platform");
115 return {
116 release: desktop && { version: desktop.version, pub_date: desktop.pub_date ?? "", notes: desktop.notes, downloads: desktop.downloads },
117 mobile,
118 // Only what the page shows: the runner's manifest also carries its sandbox's images.
119 runner: runner && { version: runner.version, files: runner.files },
120 cli: cli && { version: cli.version, files: cli.files },
121 device: deviceOf(request.headers.get("user-agent")),
122 asked: isPlatform(asked) ? asked : null,
123 site,
124 hosted: site === HOSTED_ADDRESSES.site,
125 };
126}
127
128type Data = Awaited<ReturnType<typeof loader>>;
129
130const PLATFORMS: Record<PlatformId, { name: string; device: string; note: string }> = {
131 macos: { name: "macOS", device: "Mac", note: "macOS 11 or later. Drag g1t to Applications." },
132 windows: { name: "Windows", device: "PC", note: "Windows 10 or 11. Installs for you alone, with no administrator needed." },
133 linux: { name: "Linux", device: "computer", note: "The AppImage runs anywhere once made executable; the packages install what it needs." },
134 android: { name: "Android", device: "phone", note: "" },
135 ios: { name: "iOS", device: "iPhone", note: "" },
136};
137
138/**
139 * What each system says about an app that isn't code-signed, and how to
140 * open it anyway. Every build is unsigned while g1t is in alpha.
141 */
142const UNSIGNED: Record<PlatformId, string | null> = {
143 windows: "Windows may say it protected your PC: choose More info, then Run anyway.",
144 macos: "macOS may say it can't check g1t: open System Settings, Privacy & Security, and choose Open Anyway.",
145 linux: "Your package manager may say the package isn't signed. The AppImage needs only to be made executable.",
146 android: "Play Protect may warn about an unknown app: choose More details, then Install anyway.",
147 ios: null,
148};
149
150const KINDS: Record<string, string> = {
151 installer: "Installer",
152 dmg: "Disk image",
153 appimage: "AppImage",
154 deb: "Debian package",
155 rpm: "RPM package",
156};
157
158function archLabel(platform: string, arch: string): string {
159 if (platform === "macos") return arch === "arm64" ? "Apple silicon" : "Intel";
160 return arch === "arm64" ? "Arm" : "x64";
161}
162
163function size(bytes: number): string {
164 if (!bytes) return "";
165 return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(0)} MB` : `${Math.ceil(bytes / 1024)} KB`;
166}
167
168function day(iso: string): string {
169 const date = new Date(iso);
170 return Number.isNaN(date.getTime()) ? "" : date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", timeZone: "UTC" });
171}
172
173/** Who the visitor is, as the page first drew it, made more exact once the browser says. */
174function useDevice(initial: Device): Device {
175 const [device, setDevice] = useState(initial);
176 useEffect(() => {
177 // An iPad asking for the desktop site says it is a Mac; its touch screen says otherwise.
178 if (initial.platform === "macos" && navigator.maxTouchPoints > 1) {
179 setDevice({ platform: "ios", arch: "arm64" });
180 return;
181 }
182 if (initial.arch || !initial.platform || !isDesktop(initial.platform)) return;
183 const hints = (navigator as Navigator & { userAgentData?: { getHighEntropyValues(keys: string[]): Promise<{ architecture?: string }> } }).userAgentData;
184 hints
185 ?.getHighEntropyValues(["architecture"])
186 .then((values) => {
187 const arch = archOfHint(values.architecture);
188 if (arch) setDevice((current) => ({ ...current, arch }));
189 })
190 .catch(() => {});
191 }, [initial.platform, initial.arch]);
192 return device;
193}
194
195export default function DownloadPage() {
196 const data = useLoaderData<typeof loader>();
197 const device = useDevice(data.device);
198 const first: PlatformId =
199 data.asked ?? data.device.platform ?? (["macos", "windows", "linux"] as const).find((p) => data.release?.downloads.some((d) => d.platform === p)) ?? "macos";
200 const [tab, setTab] = useState<PlatformId>(first);
201 const chosen = useRef(data.asked !== null);
202 // An iPad found out only in the browser opens on iOS, unless the visitor has chosen.
203 useEffect(() => {
204 if (!chosen.current && device.platform && device.platform !== data.device.platform) setTab(device.platform);
205 }, [device.platform, data.device.platform]);
206
207 const choose = (value: string) => {
208 if (!isPlatform(value)) return;
209 chosen.current = true;
210 setTab(value);
211 // In the address, so the link shared is the platform shown; without a navigation, which would read the manifests again.
212 const url = new URL(window.location.href);
213 url.searchParams.set("platform", value);
214 window.history.replaceState(window.history.state, "", url);
215 };
216
217 // A marketing page, drawn like Pricing: the site's header and footer,
218 // never the app's rail (lib/chrome.ts, MARKETING).
219 return (
220 <main className="overflow-x-clip">
221 <section className="dusk relative">
222 <div className="relative mx-auto max-w-5xl px-4 pt-14 text-center sm:pt-20">
223 <p className="font-mono text-xs tracking-[0.2em] text-accent uppercase">Download</p>
224 <h1 className="mx-auto mt-4 max-w-3xl text-4xl leading-[1.05] font-semibold tracking-tight text-balance sm:text-6xl">
225 g1t on your computer and your phone
226 </h1>
227 <p className="mx-auto mt-5 max-w-2xl text-lg leading-8 text-balance text-fg-soft/80">
228 Chat, artifacts and code in an app of their own, with notifications from your system and updates that install themselves.
229 </p>
230 </div>
231
232 <Tabs value={tab} onValueChange={choose} className="relative mx-auto max-w-5xl px-4 pt-10 sm:pt-12">
233 <div className="flex justify-center">
234 <TabsList aria-label="Platform" className="grid w-full max-w-xl grid-cols-5 rounded-full bg-bg/70 backdrop-blur">
235 {PLATFORM_IDS.map((id) => (
236 <TabsTrigger key={id} value={id} className="inline-flex items-center justify-center gap-2 rounded-full px-1 py-2 text-[0.8125rem] sm:px-3">
237 <PlatformMark platform={id} size={14} className="max-sm:hidden" />
238 {PLATFORMS[id].name}
239 </TabsTrigger>
240 ))}
241 </TabsList>
242 </div>
243 <Alert tone="warn" className="mx-auto mt-6 flex max-w-3xl gap-3 px-4 py-3">
244 <ShieldAlert size={16} className="mt-0.5 shrink-0 text-warn" />
245 <div>
246 <AlertTitle>Unsigned while g1t is in alpha</AlertTitle>
247 <AlertDescription>
248 The desktop app, the Android app, the runner and the CLI aren't code-signed yet, so your system warns you before it opens them.{" "}
249 {UNSIGNED[tab] ?? "Every download here is unsigned until g1t leaves alpha."} Each file's SHA-256 is listed to check it against.
250 </AlertDescription>
251 </div>
252 </Alert>
253 {PLATFORM_IDS.map((id) => (
254 <TabsContent key={id} value={id} className="mt-6">
255 {isDesktop(id) ? (
256 <DesktopPanel platform={id} data={data} device={device} />
257 ) : id === "android" ? (
258 <AndroidPanel data={data} device={device} />
259 ) : (
260 <IosPanel yours={device.platform === "ios"} onAndroid={() => choose("android")} />
261 )}
262 </TabsContent>
263 ))}
264 </Tabs>
265 <div aria-hidden="true" className="h-16 bg-linear-to-b from-transparent to-bg" />
266 </section>
267
268 <div className="mx-auto max-w-5xl space-y-20 px-4 pt-8 pb-24">
269 {!data.hosted && <ConnectCard site={data.site} />}
270 <Features />
271 <Questions hosted={data.hosted} release={data.release} />
272 </div>
273 </main>
274 );
275}
276
277/**
278 * One platform's panel: its mark and name, what there is for it, and an
279 * aside (the files, or the phone's QR code) beside it on a wide screen and
280 * under it on a narrow one.
281 */
282function Panel({
283 platform,
284 title,
285 meta,
286 yours,
287 badge,
288 children,
289 aside,
290 asideClassName,
291 footer,
292}: {
293 platform: PlatformId;
294 title: string;
295 meta?: ReactNode;
296 yours: boolean;
297 badge?: ReactNode;
298 children: ReactNode;
299 aside?: ReactNode;
300 asideClassName?: string;
301 footer?: ReactNode;
302}) {
303 return (
304 <Card className="overflow-hidden shadow-2xl shadow-black/20">
305 <div className={aside ? "grid md:grid-cols-[minmax(0,1fr)_minmax(0,22rem)]" : undefined}>
306 <CardContent className="p-6 sm:p-8">
307 <div className="flex items-start gap-4">
308 <span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-raised text-fg ring-1 ring-line">
309 <PlatformMark platform={platform} size={24} />
310 </span>
311 <div className="min-w-0">
312 <div className="flex flex-wrap items-center gap-2">
313 <h2 className="text-xl font-semibold tracking-tight">{title}</h2>
314 {yours && (
315 <Badge tone="accent" size="sm">
316 This device
317 </Badge>
318 )}
319 {badge}
320 </div>
321 {meta && <p className="mt-1 text-sm text-muted">{meta}</p>}
322 </div>
323 </div>
324 <div className="mt-6">{children}</div>
325 </CardContent>
326 {aside && <CardContent className={`border-t border-line bg-bg/40 p-6 sm:p-8 md:border-t-0 md:border-l ${asideClassName ?? ""}`}>{aside}</CardContent>}
327 </div>
328 {footer && <CardContent className="border-t border-line p-6 sm:px-8">{footer}</CardContent>}
329 </Card>
330 );
331}
332
333/** The panel's main download, and the other ways to the same app beside it. */
334function DownloadButtons({ main, alternates }: { main: { href: string; label: string; detail: string }; alternates: { href: string; label: string }[] }) {
335 return (
336 <div>
337 <div className="flex flex-wrap items-center gap-3">
338 <ButtonLink to={main.href} reloadDocument variant="accent" size="lg" className="rounded-full">
339 <Download size={16} />
340 {main.label}
341 </ButtonLink>
342 {alternates.map((alternate) => (
343 <ButtonLink key={alternate.href} to={alternate.href} reloadDocument variant="outline" className="rounded-full">
344 {alternate.label}
345 </ButtonLink>
346 ))}
347 </div>
348 <p className="mt-3 text-sm text-muted">{main.detail}</p>
349 </div>
350 );
351}
352
353/** What to do while a platform has no release: the site installs as an app from the browser. */
354function Meanwhile({ children }: { children: ReactNode }) {
355 return (
356 <div className="space-y-2 text-sm text-muted">
357 <p className="text-base text-fg-soft">{children}</p>
358 <p>Until then, g1t installs from your browser: in its menu, choose Install or Add to Dock.</p>
359 </div>
360 );
361}
362
363function DesktopPanel({ platform, data, device }: { platform: DesktopPlatform; data: Data; device: Device }) {
364 const { release } = data;
365 const info = PLATFORMS[platform];
366 const yours = device.platform === platform;
367 const files = release ? release.downloads.filter((d) => d.platform === platform) : [];
368 const featured = release ? featuredFile(release.downloads, platform, yours ? device.arch : null) : null;
369 const alternates = release ? alternateFiles(release.downloads, featured) : [];
370 const href = (download: DesktopDownload) => `/downloads/desktop/${release!.version}/${download.name}`;
371 const released = release?.pub_date ? day(release.pub_date) : "";
372 const label = (d: DesktopDownload) => (d.platform === "linux" ? `.${d.name.split(".").pop()}` : d.platform === "macos" ? `${archLabel(d.platform, d.arch)} Mac` : archLabel(d.platform, d.arch));
373 const tools = commandLineTools(platform, data.runner, data.cli);
374 const out = release ? Array.from(new Set(release.downloads.map((d) => PLATFORMS[d.platform]?.name).filter(Boolean))) : [];
375
376 return (
377 <Panel
378 platform={platform}
379 title={`g1t for ${info.name}`}
380 yours={yours}
381 meta={release ? `Version ${release.version}${released ? `, released ${released}` : ""}` : undefined}
382 badge={
383 !featured && (
384 <Badge tone="neutral" size="sm">
385 Coming
386 </Badge>
387 )
388 }
389 aside={featured && <FileList platform={platform} files={files} version={release!.version} href={href} />}
390 footer={tools.length > 0 && <CommandLine platform={platform} tools={tools} />}
391 >
392 {featured ? (
393 <>
394 <p className="mb-6 max-w-md text-sm text-muted">{info.note}</p>
395 <DownloadButtons
396 main={{
397 href: href(featured),
398 label: `Download for ${info.name}`,
399 detail: [KINDS[featured.kind] ?? featured.kind, archLabel(featured.platform, featured.arch), size(featured.size)].filter(Boolean).join(" · "),
400 }}
401 alternates={alternates.map((d) => ({ href: href(d), label: label(d) }))}
402 />
403 </>
404 ) : release ? (
405 <Meanwhile>
406 {yours ? `Your ${info.device} isn't in this release yet.` : `${info.name} isn't in this release yet.`} Version {release.version} is out for {out.join(" and ")}, and{" "}
407 {info.name} comes in a later one.
408 </Meanwhile>
409 ) : (
410 <Meanwhile>The first release of the desktop app is on its way.</Meanwhile>
411 )}
412 </Panel>
413 );
414}
415
416/** Every file of a platform in the release, with its SHA-256 on hover. */
417function FileList({ platform, files, version, href }: { platform: DesktopPlatform; files: DesktopDownload[]; version: string; href: (d: DesktopDownload) => string }) {
418 return (
419 <div>
420 <h3 className="text-sm font-medium">All {PLATFORMS[platform].name} downloads</h3>
421 <ul className="mt-3 divide-y divide-line">
422 {files.map((d) => (
423 <li key={d.name} className="flex items-center gap-3 py-2.5">
424 <div className="min-w-0 grow">
425 <p className="text-sm">
426 {KINDS[d.kind] ?? d.kind} <span className="text-muted">· {archLabel(d.platform, d.arch)}</span>
427 </p>
428 <Hint label={`SHA-256 ${d.sha256}`} side="bottom" align="start">
429 <p className="truncate font-mono text-xs text-faint">{d.name}</p>
430 </Hint>
431 </div>
432 <span className="shrink-0 text-xs text-muted tabular-nums">{size(d.size)}</span>
433 <Button asChild variant="ghost" size="icon-sm">
434 <a href={href(d)} aria-label={`Download ${d.name}`}>
435 <Download size={15} />
436 </a>
437 </Button>
438 </li>
439 ))}
440 </ul>
441 <p className="mt-3 text-xs text-faint">
442 Checksums in{" "}
443 <a href={`/downloads/desktop/${version}/SHA256SUMS`} className="text-accent hover:underline">
444 SHA256SUMS
445 </a>
446 </p>
447 </div>
448 );
449}
450
451const OS_FILE: Record<DesktopPlatform, string> = { macos: "macos", windows: "windows", linux: "linux" };
452
453/** The runner and the CLI for a computer's system, those whose releases have one. */
454function commandLineTools(platform: DesktopPlatform, runner: ToolRelease | null, cli: ToolRelease | null) {
455 return [
456 { id: "runner", title: "g1t-runner", text: "Runs your workspace's jobs on a server. The desktop app runs one for you.", release: runner, guide: "https://docs.g1t.sh/guides/self-hosted-runners/" },
457 { id: "cli", title: "g1t CLI", text: "g1t from your terminal and scripts.", release: cli, guide: "https://docs.g1t.sh/guides/containers/" },
458 ]
459 .map((tool) => ({
460 ...tool,
461 files: tool.release
462 ? Object.entries(tool.release.files)
463 .filter(([key]) => key.startsWith(`${OS_FILE[platform]}-`))
464 .map(([key, file]) => ({ arch: key.split("-")[1] ?? "", ...file }))
465 // Apple silicon first on a Mac, x64 first elsewhere.
466 .sort((a, b) => (platform === "macos" ? a.arch.localeCompare(b.arch) : b.arch.localeCompare(a.arch)))
467 : [],
468 }))
469 .filter((tool) => tool.files.length > 0);
470}
471
472function CommandLine({ platform, tools }: { platform: DesktopPlatform; tools: ReturnType<typeof commandLineTools> }) {
473 return (
474 <div>
475 <h3 className="text-sm font-medium">Command line</h3>
476 <ul className="mt-3 grid gap-4 sm:grid-cols-2">
477 {tools.map((tool) => (
478 <li key={tool.id} className="flex flex-col gap-2">
479 <p className="text-sm">
480 <a href={tool.guide} className="font-medium hover:text-accent">
481 {tool.title}
482 </a>{" "}
483 <span className="text-faint">{tool.release!.version}</span>
484 </p>
485 <p className="text-sm text-muted">{tool.text}</p>
486 <div className="flex flex-wrap gap-2">
487 {tool.files.map((file) => (
488 <Hint key={file.name} label={`SHA-256 ${file.sha256}`}>
489 <Button asChild variant="outline" size="xs">
490 <a href={`/downloads/${tool.id}/${tool.release!.version}/${file.name}`}>
491 <Download size={13} />
492 {archLabel(platform, file.arch)}
493 </a>
494 </Button>
495 </Hint>
496 ))}
497 </div>
498 </li>
499 ))}
500 </ul>
501 </div>
502 );
503}
504
505/**
506 * The Android app, an APK until it is in Google Play (both stores wait on
507 * Flagon's D-U-N-S number). On a computer, a QR code takes it to the phone.
508 * The release is g1t.sh's wherever this page is: the app is g1t's.
509 */
510function AndroidPanel({ data, device }: { data: Data; device: Device }) {
511 const { mobile, hosted } = data;
512 const onPhone = device.platform === "android";
513 const apk = mobile?.downloads.find((download) => download.kind === "apk") ?? null;
514 const apkUrl = apk && mobile ? `${HOSTED_ADDRESSES.site}/downloads/mobile/${mobile.version}/${apk.name}` : null;
515 const released = mobile?.pub_date ? day(mobile.pub_date) : "";
516
517 if (!apk || !apkUrl || !mobile) {
518 return (
519 <Panel platform="android" title="g1t for Android" yours={onPhone} badge={hosted ? <Badge size="sm">Coming</Badge> : undefined}>
520 {hosted ? (
521 <div className="space-y-2 text-sm text-muted">
522 <p className="text-base text-fg-soft">The first Android build is on its way.</p>
523 <p>It will be here to download as soon as it is published.</p>
524 </div>
525 ) : (
526 <div className="space-y-2 text-sm text-muted">
527 <p className="text-base text-fg-soft">Get the app from g1t.sh.</p>
528 <p>
529 The app is the same for every g1t: download it from{" "}
530 <a href={`${HOSTED_ADDRESSES.site}/download?platform=android`} className="text-accent hover:underline">
531 g1t.sh/download
532 </a>
533 , then point it here with the code below.
534 </p>
535 </div>
536 )}
537 </Panel>
538 );
539 }
540
541 const steps = [
542 onPhone ? "Download the app." : "Scan the code with your phone's camera, or download it on the phone.",
543 "Open the file, and allow your browser to install apps when Android asks.",
544 "Open g1t (beta) and sign in.",
545 ];
546 return (
547 <Panel
548 platform="android"
549 title="g1t for Android"
550 yours={onPhone}
551 badge={<Badge size="sm">Beta</Badge>}
552 meta={`Version ${mobile.version}${released ? `, released ${released}` : ""}`}
553 aside={
554 !onPhone && (
555 <div className="flex h-full flex-col items-center justify-center gap-4 text-center">
556 <div className="rounded-2xl bg-white p-3 shadow-lg shadow-black/10 ring-1 ring-line">
557 <QrCode value={apkUrl} label="Download the g1t app for Android" size={168} />
558 </div>
559 <p className="max-w-56 text-sm text-muted">Scan with your phone's camera to download it there.</p>
560 </div>
561 )
562 }
563 asideClassName="max-sm:hidden"
564 >
565 <p className="mb-6 max-w-md text-sm text-muted">Chat with your team, with notifications on your phone, and review pull requests on the way.</p>
566 <DownloadButtons
567 main={{ href: apkUrl, label: "Download for Android", detail: ["APK", size(apk.size)].filter(Boolean).join(" · ") }}
568 alternates={[]}
569 />
570 <ol className="mt-6 space-y-2 text-sm text-muted">
571 {steps.map((step, index) => (
572 <li key={step} className="flex gap-3">
573 <Badge tone="accent" size="sm" className="size-5 justify-center p-0 tabular-nums">
574 {index + 1}
575 </Badge>
576 <span>{step}</span>
577 </li>
578 ))}
579 </ol>
580 <Hint label={`SHA-256 ${apk.sha256}`} align="start">
581 <p className="mt-6 truncate font-mono text-xs text-faint">{apk.name}</p>
582 </Hint>
583 </Panel>
584 );
585}
586
587function IosPanel({ yours, onAndroid }: { yours: boolean; onAndroid: () => void }) {
588 return (
589 <Panel platform="ios" title="g1t for iPhone" yours={yours} badge={<Badge size="sm">Coming</Badge>}>
590 <div className="space-y-2 text-sm text-muted">
591 <p className="text-base text-fg-soft">The iPhone app comes through the App Store, once it can be published there.</p>
592 <p>Until then, g1t installs from Safari: tap Share, then Add to Home Screen.</p>
593 </div>
594 <Button variant="outline" className="mt-6 rounded-full" onClick={onAndroid}>
595 <PlatformMark platform="android" size={14} />
596 Get the Android app
597 </Button>
598 </Panel>
599 );
600}
601
602/** On a self-hosted g1t: a link and its QR code that point the phone app here. */
603function ConnectCard({ site }: { site: string }) {
604 const link = connectLink(site);
605 const host = new URL(site).host;
606 return (
607 <Card className="flex flex-col gap-6 p-6 sm:flex-row sm:items-center sm:p-8">
608 <div className="shrink-0 self-center rounded-2xl bg-white p-3 ring-1 ring-line">
609 <QrCode value={link} label={`Open the g1t app on ${host}`} size={152} />
610 </div>
611 <div className="min-w-0">
612 <h2 className="text-xl font-semibold tracking-tight">Use the phone app with {host}</h2>
613 <p className="mt-2 text-sm text-muted">
614 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 here instead
615 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 the address typed.
616 </p>
617 <ButtonLink to={link} reloadDocument variant="secondary" className="mt-4 rounded-full">
618 <Smartphone size={16} />
619 Open in the g1t app
620 </ButtonLink>
621 </div>
622 </Card>
623 );
624}
625
626const FEATURES: { icon: ReactNode; title: string; text: string }[] = [
627 { icon: <Bell size={18} />, title: "Notifications", text: "A message to you, a mention, or a review requested, from your system, with a badge on the icon." },
628 { icon: <Link2 size={18} />, title: "g1t:// links", text: "Open a page in the app from anywhere on your computer. Other links open in your browser." },
629 { icon: <RefreshCw size={18} />, title: "Updates install themselves", text: "Signed by g1t and downloaded in the background. The app asks only to restart." },
630 { icon: <Server size={18} />, title: "A runner built in", text: "Your workspace's self-hosted jobs run on your computer while g1t is open, in a sandbox, at no cost." },
631];
632
633function Features() {
634 return (
635 <section>
636 <p className="font-mono text-xs tracking-[0.2em] text-accent uppercase">The desktop app</p>
637 <h2 className="mt-3 max-w-2xl text-2xl font-semibold tracking-tight text-balance sm:text-3xl">Everything in g1t, and what a browser can't do</h2>
638 <ul className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
639 {FEATURES.map((item) => (
640 <Card asChild key={item.title} className="p-5">
641 <li>
642 <span className="flex size-9 items-center justify-center rounded-lg bg-accent/10 text-accent">{item.icon}</span>
643 <p className="mt-4 font-medium">{item.title}</p>
644 <p className="mt-1 text-sm text-muted">{item.text}</p>
645 </li>
646 </Card>
647 ))}
648 </ul>
649 </section>
650 );
651}
652
653function Questions({ hosted, release }: { hosted: boolean; release: Data["release"] }) {
654 return (
655 <section className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]">
656 <div>
657 <p className="font-mono text-xs tracking-[0.2em] text-accent uppercase">Questions</p>
658 <h2 className="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl">Before you install</h2>
659 <div className="mt-6 flex flex-col items-start gap-1">
660 <ButtonLink to="https://docs.g1t.sh/guides/desktop/" variant="link" size="inline">
661 The desktop app guide <ArrowRight size={14} />
662 </ButtonLink>
663 <ButtonLink to="https://docs.g1t.sh/guides/mobile/" variant="link" size="inline">
664 The phone app guide <ArrowRight size={14} />
665 </ButtonLink>
666 </div>
667 </div>
668 <Card className="px-5">
669 <Accordion type="single" collapsible defaultValue="stores">
670 <AccordionItem value="stores">
671 <AccordionTrigger>Why a download, and not Google Play or the App Store?</AccordionTrigger>
672 <AccordionContent>
673 Both stores publish a company's apps only once it is verified with a D-U-N-S number, and Flagon's is on its way. Until then the Android app is
674 published {hosted ? "here" : "on g1t.sh"}, as the desktop app is. It moves into Google Play as soon as it can, and iOS follows through the App
675 Store.
676 </AccordionContent>
677 </AccordionItem>
678 <AccordionItem value="unsigned">
679 <AccordionTrigger>Why does my system warn me before opening it?</AccordionTrigger>
680 <AccordionContent>
681 <p>
682 While g1t is in alpha, none of its builds are code-signed: not the desktop app, the Android app, the runner or the CLI. Your system can't tell
683 who made them, so it warns you the first time. To open one anyway:
684 </p>
685 <ul className="mt-3 space-y-1.5">
686 {(["windows", "macos", "linux", "android"] as const).map((id) => (
687 <li key={id}>
688 <span className="text-fg-soft">{PLATFORMS[id].name}:</span> {UNSIGNED[id]}
689 </li>
690 ))}
691 </ul>
692 <p className="mt-3">
693 Check a download against its SHA-256 first. The desktop app's own updates are checked against g1t's signature before they install.
694 </p>
695 </AccordionContent>
696 </AccordionItem>
697 <AccordionItem value="check">
698 <AccordionTrigger>How do I check a download?</AccordionTrigger>
699 <AccordionContent>
700 Each file's SHA-256 shows when you point at its name, and every release lists them in SHA256SUMS beside its files
701 {release ? (
702 <>
703 , as in{" "}
704 <a href={`/downloads/desktop/${release.version}/SHA256SUMS`} className="text-accent hover:underline">
705 version {release.version}'s
706 </a>
707 </>
708 ) : null}
709 . The apps check their own updates against g1t's signature.
710 </AccordionContent>
711 </AccordionItem>
712 <AccordionItem value="own">
713 <AccordionTrigger>Do the apps work with my own g1t?</AccordionTrigger>
714 <AccordionContent>
715 Yes. In the desktop app's tray, Use another g1t points it at your server. The phone app signs in to any g1t from its sign-in screen, or from the
716 code on that g1t's download page.
717 </AccordionContent>
718 </AccordionItem>
719 </Accordion>
720 </Card>
721 </section>
722 );
723}