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
382 linesCodeBlameRaw
1import { env } from "cloudflare:workers";
2import { connectLink } from "@g1t/contracts/discovery";
3import { Apple, Bell, Download, Link2, Monitor, RefreshCw, Server, Smartphone, Terminal } from "lucide-react";
4import type { ReactNode } from "react";
5import { useLoaderData } from "react-router";
6
7import type { Route } from "./+types/download";
8import { QrCode } from "../components/qr-code";
9import { ButtonLink } from "../components/ui";
10import { Card } from "../components/ui/card";
11import { HOSTED_ADDRESSES } from "../lib/addresses";
12import { addresses } from "../lib/addresses.server";
13import { page } from "../lib/meta";
14
15/**
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.
23 */
24
25export type DesktopDownload = {
26 platform: "windows" | "macos" | "linux";
27 arch: "x64" | "arm64";
28 kind: string;
29 name: string;
30 size: number;
31 sha256: string;
32};
33
34export type DesktopRelease = {
35 version: string;
36 pub_date: string;
37 notes?: string;
38 downloads: DesktopDownload[];
39};
40
41type Platform = DesktopDownload["platform"];
42
43/** The phone app's newest Android release (scripts/mobile-release.mjs): `mobile/latest.json`. */
44export type MobileRelease = {
45 version: string;
46 build: number | null;
47 pub_date: string;
48 notes?: string;
49 downloads: { platform: "android"; kind: "apk"; name: string; size: number; sha256: string }[];
50};
51
52/** A manifest as read from the bucket, or null when it is not one. */
53async function readJson<T>(bucket: R2Bucket | undefined, key: string, check: (value: Partial<T>) => boolean): Promise<T | null> {
54 try {
55 const found = bucket ? await bucket.get(key) : null;
56 if (!found) return null;
57 const read = (await found.json()) as Partial<T>;
58 return read && check(read) ? (read as T) : null;
59 } catch (error) {
60 console.error(`download: ${key} could not be read`, error);
61 return null;
62 }
63}
64
65/** Which platform a browser says it is on; null when it does not say. */
66export 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}
73
74export function meta(args: Route.MetaArgs) {
75 return page(args, {
76 title: "Download g1t for your desktop and phone",
77 description: "g1t in a window of its own, for Windows, macOS and Linux, and on your phone: notifications, a badge, g1t:// links, and updates that install themselves.",
78 });
79}
80
81export async function loader({ request }: Route.LoaderArgs) {
82 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));
96 const { site } = addresses();
97 const userAgent = request.headers.get("user-agent") ?? "";
98 return {
99 release,
100 platform: platformOf(userAgent),
101 android: /Android/i.test(userAgent),
102 mobile,
103 site,
104 hosted: site === HOSTED_ADDRESSES.site,
105 };
106}
107
108const 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];
113
114const KINDS: Record<string, string> = {
115 installer: "Installer",
116 dmg: "Disk image",
117 appimage: "AppImage",
118 deb: "Debian package",
119 rpm: "RPM package",
120};
121
122function archLabel(download: DesktopDownload): string {
123 if (download.platform === "macos") return download.arch === "arm64" ? "Apple silicon" : "Intel";
124 return download.arch === "arm64" ? "arm64" : "x64";
125}
126
127function size(bytes: number): string {
128 if (!bytes) return "";
129 return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(0)} MB` : `${Math.ceil(bytes / 1024)} KB`;
130}
131
132const 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];
138
139export 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}`;
145
146 // A marketing page, drawn like Pricing: the site's header and footer,
147 // never the app's rail (lib/chrome.ts, MARKETING).
148 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 )}
179
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>
189 </div>
190 <ButtonLink to={href(featured)} reloadDocument className="shrink-0">
191 <Download size={16} />
192 Download g1t {release.version}
193 </ButtonLink>
194 </Card>
195 )}
196
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 )}
206
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>
218
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 })}
261 </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
267 </a>
268 .
269 </p>
270 </section>
271 )}
272 <PhoneApp site={site} hosted={hosted} mobile={mobile} android={android} />
273 </div>
274 </main>
275 );
276}
277
278/**
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.
285 */
286function 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;
289 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.
291 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>
302
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.
317 </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>
323 </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 )}
342
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.
350 </p>
351 </div>
352
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 )}
372
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>
380 </section>
381 );
382}