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

Merge the new download page: it opens on the visitor's own platform with every other one a tab away, a QR code beside the Android download on a computer, the runner and CLI under Command line, and what is coming said plainly.

syntaqxcommitted Parentsd7ea477f68983aBrowse 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.