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.

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