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.

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