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

Commit

g1t.sh/download opens on the visitor's own platform, with every other one a tab away. The server reads the User-Agent, so the first byte is already drawn for macOS, Windows, Linux, Android or iOS, and a phone visitor sees the phone app first; in the browser, client hints say whether a Mac or PC is Arm or x64, and an iPad asking for the desktop site is told apart by its touch screen. A switcher of the five platforms sits under a short opening, and `?platform=android` (or any other) opens that tab, which the address keeps as people switch.

Each computer's tab leads with one download button for the right file (the disk image for Apple silicon or Intel, the Windows installer, the AppImage), with the other packages beside it, the platform's every file with its size and its SHA-256 on hover, and a Command line part with g1t-runner and the g1t CLI for that system, read from their release manifests. A platform the release lacks still says it is coming, with a word for the visitor whose platform it is, and the page reads well with no release at all. Android's tab offers the APK with three steps to install it and, on a computer, a QR code to take it to the phone; with no Android release yet it says the first build is on its way. iOS says the App Store app is coming and how to add g1t to the Home Screen meanwhile. A self-hosted g1t keeps its code that points the phone app at it. Below, four cards say what the desktop app adds, and the questions about stores, macOS's first-open prompt, checksums and your own g1t are an accordion. The platform marks come from Simple Icons, with Lucide's window for Windows, which Simple Icons does not carry. The desktop and phone app guides describe the page as it now is.

syntaqxcommitted Parent9dc5a09Browse files
6 files+742−2730/6 viewed
+4−1
2525 and the runner.
2626
2727 Get it at [g1t.sh/download](https://g1t.sh/download), or from your account
28−menu, **Get the desktop app**.
28+menu, **Get the desktop app**. The page opens on the system you visit it
29+from, with every other platform a tab away, and lists the
30+[runner](/guides/self-hosted-runners/) and the g1t CLI for each system
31+under **Command line**.
2932
3033 ## Install
3134
+8−6
1111
1212 ## Install it
1313
14−Android comes first. Get it from [g1t.sh/download](https://g1t.sh/download),
15−under **On your phone**:
14+Android comes first. Get it from [g1t.sh/download](https://g1t.sh/download):
15+the page opens on **Android** when you visit it on an Android phone, and
16+[g1t.sh/download?platform=android](https://g1t.sh/download?platform=android)
17+opens it from anywhere.
1618
1719 1. On your Android phone, choose **Download for Android**. On a computer,
18− scan the code there with the phone's camera instead.
20+ scan the code beside it with the phone's camera instead.
1921 2. Open the downloaded file. Android asks once to allow your browser to
2022 install apps: allow it.
2123 3. Open **g1t (beta)** and choose **Sign in with g1t**.
2224
23−The page shows each release's SHA-256, to check the file you have is the
24−one g1t published.
25+Point at the file's name on the page to see its SHA-256, to check the file
26+you have is the one g1t published.
2527
2628 **Why a download, and not Google Play?** Google Play and the App Store
2729 publish a company's apps only once it is verified with a D-U-N-S number,
137139 you point it elsewhere. Two ways to point it at yours:
138140
139141 - **Scan its code.** Your g1t's download page, `<your g1t>/download`, has
140− a QR code under **On your phone**. Scan it with the phone's camera, or
142+ a QR code under its platforms, **Use the phone app with** your g1t. Scan it with the phone's camera, or
141143 open the page on the phone and choose **Open in the g1t app**. The app
142144 asks **Use g1t.example.com?**, then signs you in there.
143145 - **Type its address.** On the sign-in screen, choose **Using your own
+19−0
1+import { AppWindow } from "lucide-react";
2+import { siAndroid, siApple, siLinux } from "simple-icons";
3+
4+import type { PlatformId } from "../lib/download-platform";
5+
6+/**
7+ * A platform's mark, in the text's colour: Apple's for macOS and iOS,
8+ * Linux's and Android's from Simple Icons. Simple Icons has no Windows
9+ * mark, so Windows has Lucide's window.
10+ */
11+export function PlatformMark({ platform, size = 16, className }: { platform: PlatformId; size?: number; className?: string }) {
12+ if (platform === "windows") return <AppWindow size={size} className={className} aria-hidden="true" />;
13+ const path = platform === "linux" ? siLinux.path : platform === "android" ? siAndroid.path : siApple.path;
14+ return (
15+ <svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" aria-hidden="true" className={className}>
16+ <path d={path} />
17+ </svg>
18+ );
19+}
+56−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { alternateFiles, archOfHint, deviceOf, featuredFile } from "./download-platform.ts";
5+
6+const UA = {
7+ mac: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
8+ windows: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
9+ windowsArm: "Mozilla/5.0 (Windows NT 10.0; ARM64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
10+ linux: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
11+ android: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36",
12+ iphone: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
13+ chromebook: "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
14+};
15+
16+test("a User-Agent names the visitor's platform, phones first", () => {
17+ assert.deepEqual(deviceOf(UA.mac), { platform: "macos", arch: null });
18+ assert.deepEqual(deviceOf(UA.windows), { platform: "windows", arch: "x64" });
19+ assert.deepEqual(deviceOf(UA.windowsArm), { platform: "windows", arch: "arm64" });
20+ assert.deepEqual(deviceOf(UA.linux), { platform: "linux", arch: "x64" });
21+ assert.deepEqual(deviceOf(UA.android), { platform: "android", arch: null });
22+ assert.deepEqual(deviceOf(UA.iphone), { platform: "ios", arch: "arm64" });
23+ assert.deepEqual(deviceOf(UA.chromebook), { platform: "linux", arch: "x64" });
24+ assert.deepEqual(deviceOf("curl/8.0"), { platform: null, arch: null });
25+ assert.deepEqual(deviceOf(null), { platform: null, arch: null });
26+});
27+
28+test("client hints name the processor", () => {
29+ assert.equal(archOfHint("arm"), "arm64");
30+ assert.equal(archOfHint("x86"), "x64");
31+ assert.equal(archOfHint(""), null);
32+});
33+
34+const files = [
35+ { platform: "linux", arch: "x64", kind: "appimage" },
36+ { platform: "linux", arch: "x64", kind: "deb" },
37+ { platform: "linux", arch: "x64", kind: "rpm" },
38+ { platform: "linux", arch: "arm64", kind: "appimage" },
39+ { platform: "macos", arch: "arm64", kind: "dmg" },
40+ { platform: "macos", arch: "x64", kind: "dmg" },
41+ { platform: "windows", arch: "x64", kind: "installer" },
42+];
43+
44+test("the file offered first is the platform's preferred kind, on the visitor's processor", () => {
45+ assert.equal(featuredFile(files, "linux", null), files[0]);
46+ assert.equal(featuredFile(files, "linux", "arm64"), files[3]);
47+ // A Mac whose processor is not known gets Apple silicon's, and Intel's beside it.
48+ assert.equal(featuredFile(files, "macos", null), files[4]);
49+ assert.equal(featuredFile(files, "macos", "x64"), files[5]);
50+ assert.deepEqual(alternateFiles(files, files[4]), [files[5]]);
51+ // Windows on Arm has no installer of its own yet: the x64 one runs there.
52+ assert.equal(featuredFile(files, "windows", "arm64"), files[6]);
53+ assert.deepEqual(alternateFiles(files, files[0]), [files[1], files[2]]);
54+ // A platform the release lacks.
55+ assert.equal(featuredFile(files.filter((f) => f.platform !== "windows"), "windows", "x64"), null);
56+});
+86−0
1+/**
2+ * Which platform a visitor to g1t.sh/download is on, and which of a
3+ * release's files to offer them first (routes/download.tsx). The server
4+ * reads the User-Agent, so the page is drawn for the visitor's platform
5+ * from the first byte; the browser can then say which processor it has
6+ * (`navigator.userAgentData`), which a User-Agent no longer does on a Mac.
7+ */
8+
9+export const PLATFORM_IDS = ["macos", "windows", "linux", "android", "ios"] as const;
10+export type PlatformId = (typeof PLATFORM_IDS)[number];
11+export type Arch = "x64" | "arm64";
12+
13+/** The desktop app's platforms, as its releases name them. */
14+export type DesktopPlatform = Extract<PlatformId, "macos" | "windows" | "linux">;
15+
16+export type Device = { platform: PlatformId | null; arch: Arch | null };
17+
18+export function isPlatform(value: unknown): value is PlatformId {
19+ return typeof value === "string" && (PLATFORM_IDS as readonly string[]).includes(value);
20+}
21+
22+export function isDesktop(platform: PlatformId): platform is DesktopPlatform {
23+ return platform === "macos" || platform === "windows" || platform === "linux";
24+}
25+
26+/**
27+ * The platform and processor a User-Agent names, each null when it does not
28+ * say. A phone is checked first: Android's says Linux, an iPhone's says
29+ * "like Mac OS X". An iPad asking for the desktop site says Macintosh, which
30+ * only the browser can tell apart (by its touch screen).
31+ */
32+export function deviceOf(userAgent: string | null | undefined): Device {
33+ const ua = userAgent ?? "";
34+ if (/iPhone|iPad|iPod/i.test(ua)) return { platform: "ios", arch: "arm64" };
35+ if (/Android/i.test(ua)) return { platform: "android", arch: null };
36+ if (/Windows/i.test(ua)) return { platform: "windows", arch: /ARM64|aarch64/i.test(ua) ? "arm64" : /Win64|x64|WOW64|amd64/i.test(ua) ? "x64" : null };
37+ if (/Macintosh|Mac OS X/i.test(ua)) return { platform: "macos", arch: null };
38+ if (/CrOS|Linux|X11/i.test(ua)) return { platform: "linux", arch: /aarch64|arm64/i.test(ua) ? "arm64" : /x86_64|amd64/i.test(ua) ? "x64" : null };
39+ return { platform: null, arch: null };
40+}
41+
42+/** The processor a browser's client hints name (`getHighEntropyValues(["architecture"])`). */
43+export function archOfHint(architecture: string | undefined): Arch | null {
44+ if (architecture === "arm") return "arm64";
45+ if (architecture === "x86") return "x64";
46+ return null;
47+}
48+
49+/** The kinds of file a platform's download is, most wanted first. */
50+const PREFERRED: Record<DesktopPlatform, string[]> = {
51+ macos: ["dmg"],
52+ windows: ["installer"],
53+ linux: ["appimage", "deb", "rpm"],
54+};
55+
56+type File = { platform: string; arch: string; kind: string };
57+
58+/**
59+ * The file to offer first for a platform: its preferred kind, on the
60+ * visitor's processor when known; on a Mac whose processor is not known,
61+ * Apple silicon's, which every Mac sold since 2023 has. Null when the
62+ * release has nothing for the platform.
63+ */
64+export function featuredFile<T extends File>(downloads: T[], platform: DesktopPlatform, arch: Arch | null): T | null {
65+ const files = downloads.filter((d) => d.platform === platform);
66+ if (files.length === 0) return null;
67+ const want = arch ?? (platform === "macos" ? "arm64" : "x64");
68+ const rank = (d: T) => {
69+ const kind = PREFERRED[platform].indexOf(d.kind);
70+ return (kind === -1 ? 10 : kind) + (d.arch === want ? 0 : 100);
71+ };
72+ return [...files].sort((a, b) => rank(a) - rank(b))[0];
73+}
74+
75+/**
76+ * The other ways to have the same app, shown beside the main button: on a
77+ * Mac, the other processor's disk image (the Intel one, for one whose
78+ * processor the page could not tell); on Linux, the other packages for the
79+ * same processor; on Windows, the other processor's installer.
80+ */
81+export function alternateFiles<T extends File>(downloads: T[], featured: T | null): T[] {
82+ if (!featured) return [];
83+ const others = downloads.filter((d) => d.platform === featured.platform && d !== featured);
84+ if (featured.platform === "linux") return others.filter((d) => d.arch === featured.arch).slice(0, 2);
85+ return others.filter((d) => d.kind === featured.kind).slice(0, 1);
86+}
+569−266
11 import { env } from "cloudflare:workers";
22 import { connectLink } from "@g1t/contracts/discovery";
3−import { Apple, Bell, Download, Link2, Monitor, RefreshCw, Server, Smartphone, Terminal } from "lucide-react";
4−import type { ReactNode } from "react";
3+import { ArrowRight, Bell, Download, Link2, RefreshCw, Server, Smartphone } from "lucide-react";
4+import { type ReactNode, useEffect, useRef, useState } from "react";
55 import { useLoaderData } from "react-router";
66
77 import type { Route } from "./+types/download";
8+import { PlatformMark } from "../components/platform-mark";
89 import { QrCode } from "../components/qr-code";
910 import { ButtonLink } from "../components/ui";
10−import { Card } from "../components/ui/card";
11+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "../components/ui/accordion";
12+import { Badge } from "../components/ui/badge";
13+import { Button } from "../components/ui/button";
14+import { Card, CardContent } from "../components/ui/card";
15+import { Hint } from "../components/ui/hint";
16+import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
1117 import { HOSTED_ADDRESSES } from "../lib/addresses";
1218 import { addresses } from "../lib/addresses.server";
19+import {
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";
1332 import { page } from "../lib/meta";
1433
1534 /**
16− * g1t.sh/download: the desktop app, from its newest release, and the phone
17− * app. On a self-hosted g1t the phone section offers a `g1t://connect`
18− * link and its QR code, which point the app at this g1t. The release
19− * is `desktop/latest.json` in the g1t-downloads bucket, as
20− * scripts/desktop-release.mjs writes it: what the app's updater reads
21− * (`platforms`), and the list this page shows (`downloads`). The visitor's
22− * own platform, read from the browser, comes first.
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.
2348 */
2449
2550 export type DesktopDownload = {
26− platform: "windows" | "macos" | "linux";
27− arch: "x64" | "arm64";
51+ platform: DesktopPlatform;
52+ arch: Arch;
2853 kind: string;
2954 name: string;
3055 size: number;
3762 notes?: string;
3863 downloads: DesktopDownload[];
3964 };
40−
41−type Platform = DesktopDownload["platform"];
4265
4366 /** The phone app's newest Android release (scripts/mobile-release.mjs): `mobile/latest.json`. */
4467 export type MobileRelease = {
4972 downloads: { platform: "android"; kind: "apk"; name: string; size: number; sha256: string }[];
5073 };
5174
75+/** The runner's or the CLI's newest release: one file for each `<os>-<arch>`. */
76+export type ToolRelease = {
77+ version: string;
78+ files: Record<string, { name: string; sha256: string }>;
79+};
80+
5281 /** A manifest as read from the bucket, or null when it is not one. */
5382 async function readJson<T>(bucket: R2Bucket | undefined, key: string, check: (value: Partial<T>) => boolean): Promise<T | null> {
5483 try {
6291 }
6392 }
6493
65−/** Which platform a browser says it is on; null when it does not say. */
66−export function platformOf(userAgent: string | null): Platform | null {
67− if (!userAgent) return null;
68− if (/Windows/i.test(userAgent)) return "windows";
69− if (/Mac OS X|Macintosh/i.test(userAgent)) return "macos";
70− if (/Linux|X11/i.test(userAgent) && !/Android/i.test(userAgent)) return "linux";
71− return null;
72−}
94+const hasDownloads = (read: { version?: unknown; downloads?: unknown }) => typeof read.version === "string" && Array.isArray(read.downloads);
95+const hasFiles = (read: { version?: unknown; files?: unknown }) => typeof read.version === "string" && !!read.files && typeof read.files === "object";
7396
7497 export function meta(args: Route.MetaArgs) {
7598 return page(args, {
80103
81104 export async function loader({ request }: Route.LoaderArgs) {
82105 const bucket = env.DOWNLOADS;
83− let release: DesktopRelease | null = null;
84− try {
85− const found = bucket ? await bucket.get("desktop/latest.json") : null;
86− if (found) {
87− const read = (await found.json()) as Partial<DesktopRelease>;
88− if (read && typeof read.version === "string" && Array.isArray(read.downloads)) {
89− release = { version: read.version, pub_date: read.pub_date ?? "", notes: read.notes, downloads: read.downloads };
90− }
91− }
92− } catch (error) {
93− console.error("download: the release could not be read", error);
94− }
95− const mobile = await readJson<MobileRelease>(bucket, "mobile/latest.json", (read) => typeof read.version === "string" && Array.isArray(read.downloads));
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+ ]);
96112 const { site } = addresses();
97− const userAgent = request.headers.get("user-agent") ?? "";
113+ const asked = new URL(request.url).searchParams.get("platform");
98114 return {
99− release,
100− platform: platformOf(userAgent),
101− android: /Android/i.test(userAgent),
115+ release: desktop && { version: desktop.version, pub_date: desktop.pub_date ?? "", notes: desktop.notes, downloads: desktop.downloads },
102116 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,
103122 site,
104123 hosted: site === HOSTED_ADDRESSES.site,
105124 };
106125 }
107126
108−const PLATFORMS: { id: Platform; name: string; icon: ReactNode; note: string }[] = [
109− { id: "windows", name: "Windows", icon: <Monitor size={16} />, note: "Windows 10 and 11. Installs for you alone, no administrator needed." },
110− { id: "macos", name: "macOS", icon: <Apple size={16} />, note: "macOS 11 and later. Apple silicon and Intel are separate downloads." },
111− { id: "linux", name: "Linux", icon: <Terminal size={16} />, note: "An AppImage that runs anywhere, or a package for Debian and Ubuntu." },
112−];
127+type Data = Awaited<ReturnType<typeof loader>>;
113128
129+const 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+
114137 const KINDS: Record<string, string> = {
115138 installer: "Installer",
116139 dmg: "Disk image",
119142 rpm: "RPM package",
120143 };
121144
122−function archLabel(download: DesktopDownload): string {
123− if (download.platform === "macos") return download.arch === "arm64" ? "Apple silicon" : "Intel";
124− return download.arch === "arm64" ? "arm64" : "x64";
145+function archLabel(platform: string, arch: string): string {
146+ if (platform === "macos") return arch === "arm64" ? "Apple silicon" : "Intel";
147+ return arch === "arm64" ? "Arm" : "x64";
125148 }
126149
127150 function size(bytes: number): string {
129152 return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(0)} MB` : `${Math.ceil(bytes / 1024)} KB`;
130153 }
131154
132−const WHAT: { icon: ReactNode; title: string; text: string }[] = [
133− { icon: <Bell size={16} />, title: "Notifications from your system", text: "A message to you, a mention, or an agent finished, while the window is behind another; the icon carries a badge until you have looked." },
134− { icon: <Link2 size={16} />, title: "g1t:// links", text: "A g1t:// link opens the page in the app, from anywhere on your computer. Links to other sites open in your browser." },
135− { icon: <RefreshCw size={16} />, title: "Updates install themselves", text: "The app downloads a new version in the background, signed by g1t, and asks only to restart. Everything you see is the site itself, so most changes need no update at all." },
136− { icon: <Server size={16} />, title: "Your own g1t", text: "Running g1t yourself? The tray's \u201cUse another g1t\u201d points the app at your server, and the phone app below works with it too." },
137−];
155+function 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. */
161+function 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+}
138181
139182 export default function DownloadPage() {
140− const { release, platform, site, hosted, mobile, android } = useLoaderData<typeof loader>();
141− const ordered = platform ? [...PLATFORMS.filter((p) => p.id === platform), ...PLATFORMS.filter((p) => p.id !== platform)] : PLATFORMS;
142− const yours = platform && release ? release.downloads.filter((d) => d.platform === platform) : [];
143− const featured = yours.find((d) => d.kind === "installer" || d.kind === "dmg" || d.kind === "appimage") ?? yours[0] ?? null;
144− const href = (download: DesktopDownload) => `/downloads/desktop/${release!.version}/${download.name}`;
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+ };
145203
146204 // A marketing page, drawn like Pricing: the site's header and footer,
147205 // never the app's rail (lib/chrome.ts, MARKETING).
148206 return (
149− <main className="mx-auto max-w-4xl px-4 py-12">
150− <p className="text-sm font-medium text-accent">Desktop and phone apps</p>
151− <h1 className="mt-2 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">g1t on your desktop, and in your pocket</h1>
152− <p className="mt-3 max-w-2xl text-muted">
153− The whole of g1t in a window of its own: chat, agents, artifacts and code, for Windows, macOS and Linux, with notifications
154− from your system and updates that install themselves.
155− {release && (
156− <>
157− {" "}
158− Version {release.version}
159− {release.pub_date && (
160− <>
161− , released{" "}
162− {new Date(release.pub_date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}
163− </>
164− )}
165− .
166− </>
167− )}
168− </p>
169− <div className="mt-8">
170− {!release && (
171− <Card className="p-5">
172− <p className="font-medium">The first release is on its way.</p>
173− <p className="mt-1 text-sm text-muted">
174− The desktop app is built and being released now. Until it is here, g1t works as an installed web app too: in your browser's menu, choose
175− Install or Add to Dock.
176− </p>
177− </Card>
178− )}
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, agents 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+}
179253
180− {release && featured && (
181− <Card className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
182− <div>
183− <p className="font-medium">For your {PLATFORMS.find((p) => p.id === platform)?.name}</p>
184− <p className="mt-1 text-sm text-muted">
185− {KINDS[featured.kind] ?? featured.kind}, {archLabel(featured)}
186− {featured.size ? `, ${size(featured.size)}` : ""}.
187− {yours.length > 1 ? " The other downloads for it are below." : ""}
188− </p>
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+ */
259+function 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>}
189299 </div>
190− <ButtonLink to={href(featured)} reloadDocument className="shrink-0">
191− <Download size={16} />
192− Download g1t {release.version}
193− </ButtonLink>
194− </Card>
195− )}
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+}
196309
197− {release && !featured && platform && (
198− <Card className="p-5">
199− <p className="font-medium">Not yet for {PLATFORMS.find((p) => p.id === platform)?.name}.</p>
200− <p className="mt-1 text-sm text-muted">
201− Version {release.version} is out for {Array.from(new Set(release.downloads.map((d) => PLATFORMS.find((p) => p.id === d.platform)?.name))).join(" and ")};
202− yours is built in a later release. Until then, g1t works as an installed web app: in your browser's menu, choose Install or Add to Dock.
203− </p>
204− </Card>
205− )}
310+/** The panel's main download, and the other ways to the same app beside it. */
311+function 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+}
206329
207− <div className="mt-10 grid gap-3 sm:grid-cols-2">
208− {WHAT.map((item) => (
209− <div key={item.title} className="flex gap-3 rounded-xl border border-line bg-surface p-5">
210− <span className="mt-0.5 shrink-0 text-accent">{item.icon}</span>
211− <span>
212− <span className="block font-medium">{item.title}</span>
213− <span className="mt-1 block text-sm text-muted">{item.text}</span>
214− </span>
215− </div>
216− ))}
217− </div>
330+/** What to do while a platform has no release: the site installs as an app from the browser. */
331+function 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+}
218339
219− {release && (
220− <section className="mt-14">
221− <h2 className="text-xl font-semibold tracking-tight">All downloads</h2>
222− <p className="mt-1 text-sm text-muted">
223− Every file of version {release.version}, with its SHA-256 in{" "}
224− <a href={`/downloads/desktop/${release.version}/SHA256SUMS`} className="text-accent hover:underline">
225− SHA256SUMS
226− </a>
227− .
228− </p>
229− <div className="mt-4 space-y-3">
230− {ordered.map((p) => {
231− const files = release.downloads.filter((d) => d.platform === p.id);
232− // A platform this release lacks: it is built on a machine not yet registered.
233− if (files.length === 0) {
234− return (
235− <Card key={p.id} tone="plain" className="flex flex-wrap items-center gap-2 rounded-xl px-4 py-3">
236− <span className="text-faint">{p.icon}</span>
237− <span className="font-medium text-muted">{p.name}</span>
238− <span className="text-sm text-muted">· Coming in a later release. {p.note}</span>
239− </Card>
240− );
241− }
242− return (
243− <Card key={p.id} tone="plain" divided className="rounded-xl">
244− <div className="flex items-center gap-2 px-4 py-3">
245− <span className="text-accent">{p.icon}</span>
246− <span className="font-medium">{p.name}</span>
247− <span className="text-sm text-muted">· {p.note}</span>
248− </div>
249− {files.map((d) => (
250− <div key={d.name} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-2.5 text-sm">
251− <a href={href(d)} className="font-medium text-fg hover:underline">
252− {KINDS[d.kind] ?? d.kind}, {archLabel(d)}
253− </a>
254− <span className="text-muted">{size(d.size)}</span>
255− <code className="ml-auto truncate font-mono text-xs text-faint">{d.name}</code>
256− </div>
257− ))}
258− </Card>
259− );
260− })}
340+function 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. */
394+function 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>
261408 </div>
262− <p className="mt-6 text-sm text-muted">
263− On macOS the app is not yet notarized with Apple: the first time, right-click it and choose Open. How the app works, and how a
264− release is made, is in{" "}
265− <a href="https://docs.g1t.sh/guides/desktop/" className="text-accent hover:underline">
266− the desktop app guide
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} />
267413 </a>
268− .
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+
428+const 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. */
431+function 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+
449+function 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>
269461 </p>
270− </section>
271− )}
272− <PhoneApp site={site} hosted={hosted} mobile={mobile} android={android} />
273− </div>
274− </main>
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>
275479 );
276480 }
277481
278482 /**
279− * The phone app: Chat with people and agents, live, and notifications on
280− * the phone. Not in Google Play or the App Store yet (both wait on
281− * Flagon's D-U-N-S number), so the Android app is offered here as an APK,
282− * with how to install it and why it is not in a store. On a g1t of your
283− * own, a link and its QR code point the app here, so nobody types the
284− * address; on g1t.sh the app starts here anyway.
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.
285486 */
286−function PhoneApp({ site, hosted, mobile, android }: { site: string; hosted: boolean; mobile: MobileRelease | null; android: boolean }) {
287− const link = connectLink(site);
288− const host = new URL(site).host;
487+function AndroidPanel({ data, device }: { data: Data; device: Device }) {
488+ const { mobile, hosted } = data;
489+ const onPhone = device.platform === "android";
289490 const apk = mobile?.downloads.find((download) => download.kind === "apk") ?? null;
290− // The release's own address on g1t.sh, wherever this page is: the app is g1t's, served from g1t.sh.
291491 const apkUrl = apk && mobile ? `${HOSTED_ADDRESSES.site}/downloads/mobile/${mobile.version}/${apk.name}` : null;
292− return (
293− <section className="mt-14">
294− <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
295− <Smartphone size={18} className="text-accent" />
296− On your phone
297− </h2>
298− <p className="mt-1 max-w-2xl text-sm text-muted">
299− Chat with your team and its agents, live, with notifications for messages to you, mentions, replies and agents waiting on you;
300− review and approve pull requests on the way. Android first, with iOS to follow.
301− </p>
492+ const released = mobile?.pub_date ? day(mobile.pub_date) : "";
302493
303− {apk && apkUrl && mobile ? (
304− <Card className="mt-4 flex flex-col gap-5 p-5 sm:flex-row sm:items-center">
305− {!android && (
306− <div className="shrink-0">
307− <QrCode value={apkUrl} label="Download the g1t app for Android" size={152} />
308− </div>
309− )}
310− <div className="min-w-0">
311− <p className="font-medium">g1t for Android, beta {mobile.version}</p>
312− <p className="mt-1 text-sm text-muted">
313− {android
314− ? "Download it, open the file, and allow your browser to install apps when Android asks."
315− : "Scan this with your Android phone's camera to download it there, then open the file and allow your browser to install apps when Android asks."}{" "}
316− It installs as <span className="text-fg-soft">g1t (beta)</span>, and takes most updates by itself.
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.
317511 </p>
318− <ButtonLink to={apkUrl} reloadDocument className="mt-3">
319− <Download size={16} />
320− Download for Android{apk.size ? ` (${size(apk.size)})` : ""}
321− </ButtonLink>
322− <p className="mt-2 font-mono text-xs break-all text-faint">SHA-256 {apk.sha256}</p>
323512 </div>
324− </Card>
325− ) : hosted ? (
326− <Card className="mt-4 p-5">
327− <p className="font-medium">The first Android build is on its way.</p>
328− <p className="mt-1 text-sm text-muted">It will be here to download as soon as it is published.</p>
329− </Card>
330− ) : (
331− <Card className="mt-4 p-5">
332− <p className="font-medium">Get the app from g1t.sh</p>
333− <p className="mt-1 text-sm text-muted">
334− The app is the same for every g1t: download it from{" "}
335− <a href={`${HOSTED_ADDRESSES.site}/download`} className="text-accent hover:underline">
336− g1t.sh/download
337− </a>
338− , then point it here with the code below.
339− </p>
340− </Card>
341− )}
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 and its agents, with notifications on your phone, and review what agents made in Code.</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+
564+function 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+}
342578
343− <div className="mt-4 rounded-xl border border-line bg-surface p-5 text-sm">
344− <p className="font-medium text-fg">Why a download, and not Google Play or the App Store?</p>
345− <p className="mt-1 text-muted">
346− Both stores publish a company's apps only once the company is verified with a D-U-N-S number, and Flagon's is on its way. Until it
347− arrives, the Android app is published {hosted ? "here" : "on g1t.sh"}, signed by us, as the desktop app is. Android asks once to allow installing it from your
348− browser; that is the only difference. It moves into Google Play as soon as it can, and iOS follows through the App Store, which has no
349− other way in.
579+/** On a self-hosted g1t: a link and its QR code that point the phone app here. */
580+function 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.
350593 </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>
351598 </div>
599+ </Card>
600+ );
601+}
602+
603+const FEATURES: { icon: ReactNode; title: string; text: string }[] = [
604+ { icon: <Bell size={18} />, title: "Notifications", text: "A message to you, a mention, or an agent finished, 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+];
352609
353− {!hosted && (
354− <Card className="mt-4 flex flex-col gap-5 p-5 sm:flex-row sm:items-center">
355− <div className="shrink-0">
356− <QrCode value={link} label={`Open the g1t app on ${host}`} size={152} />
357− </div>
358− <div className="min-w-0">
359− <p className="font-medium">Use the app with {host}</p>
360− <p className="mt-1 text-sm text-muted">
361− With the g1t app on your phone, scan this with the camera, or open this page on the phone and tap the button: the app signs in
362− here instead of g1t.sh. In the app, <span className="text-fg-soft">Using your own g1t?</span> on the sign-in screen does the same with
363− the address typed.
364− </p>
365− <ButtonLink to={link} reloadDocument variant="secondary" className="mt-3">
366− <Smartphone size={16} />
367− Open in the g1t app
368− </ButtonLink>
369− </div>
370− </Card>
371− )}
610+function 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+}
372629
373− <p className="mt-4 text-sm text-muted">
374− How it works, and what it does with a g1t of your own, is in{" "}
375− <a href="https://docs.g1t.sh/guides/mobile/" className="text-accent hover:underline">
376− the phone app guide
377− </a>
378− .
379− </p>
630+function 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>
380683 </section>
381684 );
382685 }

Comments

Sign in to comment.