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.

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