The phone app works with a g1t of your own as well as g1t.sh, and nothing about that shows to people on g1t.sh. Every g1t now says where its parts are at /.well-known/g1t.json (the site, the API and the MCP server, and whether it sends phones notifications; @g1t/contracts/discovery), so someone pointing the app at theirs types only the address they open it at; a g1t from before has none, and the app takes the API to be api. in front of the site, as before. A self-hosted g1t's download page shows a QR code and Open in the g1t app, a g1t://connect?server=<site> link the app asks about before switching, so its people type nothing. The sign-in screen has one button on g1t.sh, with a quiet "Using your own g1t?" at its foot, and says "Signing in to <host>" when pointed elsewhere.
Phone notifications reach a self-hosted g1t's people with no setup, through the same app's Expo push service; PHONE_PUSH=off (on the site and notify; the self-host config sets both) sends phones nothing instead, so no notification's words leave the installation that way, and the app says so rather than asking for permission it cannot use. The docs say that g1t.sh's Expo project must keep enhanced push security off, or every self-hosted g1t's phone notifications stop. The download page is the desktop and the phone: an On your phone section, with the QR code on a self-hosted g1t. The landing page and the README say where the phone app is (Preview: in testing, Android first, not in the stores yet). The docs' home lists the desktop and phone apps as Preview; the phone app guide's Your own g1t, a new The phone app section and PHONE_PUSH setting in self-hosting, notifications, deploying to Cloudflare, the architecture table and Chat say how it all works.
- Claude-Session:
- https://claude.ai/code/session_01MQE64a4WNN14F8GELCiuc9
| 43 | 43 | | Scratchpads: tools, reports and agents anyone can build on a copy of the data, promoted after review | Coming | | |
| 44 | 44 | | Desktop app for Windows, macOS and Linux (Tauri): the site in a window of its own, notifications from the system, a badge, g1t:// links, updates that install themselves | Preview | | |
| 45 | 45 | | A runner inside the desktop app | Coming | | |
| 46 | − | | A phone app for Android and iOS (Expo, in `apps/mobile`): Chat, Code and Customize, signed in with OAuth; Android first | Coming | | |
| 46 | + | | A phone app for Android and iOS (Expo, in `apps/mobile`): Chat in full, live, with notifications on the phone; Code and Customize; signed in with OAuth, to g1t.sh or your own g1t. Android first, in testing; not in the stores yet | Preview | | |
| 47 | 47 | ||
| 48 | 48 | ### Agents | |
| 49 | 49 |
| 728 | 728 | </Card> | |
| 729 | 729 | <Card title="Desktop app" icon="monitor"> | |
| 730 | 730 | g1t in a window of its own, with notifications from your system and a | |
| 731 | − | badge on the icon: [the desktop app](/guides/desktop/). A phone app is | |
| 732 | − | coming. | |
| 731 | + | badge on the icon: [the desktop app](/guides/desktop/). On a phone, | |
| 732 | + | [the phone app](/guides/mobile/) has Chat in full, with notifications. | |
| 733 | 733 | </Card> | |
| 734 | 734 | </CardGrid> | |
| 735 | 735 |
| 400 | 400 | cd services/notify && npx wrangler secret put EXPO_ACCESS_TOKEN | |
| 401 | 401 | ``` | |
| 402 | 402 | ||
| 403 | + | For g1t.sh, leave enhanced push security off: every self-hosted g1t | |
| 404 | + | pushes to the same app, through the same Expo project, without a token of | |
| 405 | + | g1t's. Turning it on stops notifications to phones from every | |
| 406 | + | installation but g1t.sh. | |
| 407 | + | ||
| 408 | + | To send phones nothing at all, set `PHONE_PUSH` to `off` in the `vars` of | |
| 409 | + | both `services/notify/wrangler.jsonc` and `apps/web/wrangler.jsonc`: the | |
| 410 | + | notify Worker sends them nothing, and the site's | |
| 411 | + | `/.well-known/g1t.json` tells the app so. | |
| 412 | + | ||
| 403 | 413 | ## Rate limits | |
| 404 | 414 | ||
| 405 | 415 | Every public surface that costs money or sends something is behind a |
| 111 | 111 | ||
| 112 | 112 | ## Your own g1t | |
| 113 | 113 | ||
| 114 | − | If you [run g1t yourself](/guides/self-hosting/), choose **Server** on the | |
| 115 | − | sign-in screen and enter your site's address, such as | |
| 116 | − | `https://g1t.example.com`. The app takes the API to be `api.` in front of | |
| 117 | − | it (`https://api.g1t.example.com`); if yours is elsewhere, enter it under | |
| 118 | − | **API**. **Use g1t.sh** goes back. | |
| 114 | + | The app works with a g1t you [run yourself](/guides/self-hosting/) as well | |
| 115 | + | as with g1t.sh. It talks to one g1t at a time, and opens on g1t.sh unless | |
| 116 | + | you point it elsewhere. Two ways to point it at yours: | |
| 117 | + | ||
| 118 | + | - **Scan its code.** Your g1t's download page, `<your g1t>/download`, has | |
| 119 | + | a QR code under **On your phone**. Scan it with the phone's camera, or | |
| 120 | + | open the page on the phone and choose **Open in the g1t app**. The app | |
| 121 | + | asks **Use g1t.example.com?**, then signs you in there. | |
| 122 | + | - **Type its address.** On the sign-in screen, choose **Using your own | |
| 123 | + | g1t? Sign in there** and enter the address you open it at in a browser, | |
| 124 | + | such as `g1t.example.com`. | |
| 125 | + | ||
| 126 | + | Either way the app reads `<your g1t>/.well-known/g1t.json`, where your g1t | |
| 127 | + | says where its API is, so you never type that. A g1t from before the phone | |
| 128 | + | app has no such file: the app then takes the API to be `api.` in front of | |
| 129 | + | the site, and offers **Its API is at another address** if it is not. | |
| 130 | + | ||
| 131 | + | The sign-in screen then says **Signing in to g1t.example.com**, with | |
| 132 | + | **Change** and **Use g1t.sh**. Switching signs you out of the g1t you were | |
| 133 | + | on. | |
| 134 | + | ||
| 135 | + | | Your g1t | What to know | | |
| 136 | + | | --- | --- | | |
| 137 | + | | Reached by the phone | `PUBLIC_URL` and `API_URL` must be addresses the phone can open. `localhost` on a phone is the phone itself: use your computer's address on the network, or a domain. | | |
| 138 | + | | `https` | Store builds of the app open `https` addresses only, as Android requires. A development build also opens `http`, for a g1t on your own network. | | |
| 139 | + | | Notifications | Work with no setup: your g1t sends them through Expo's push service, as g1t.sh does. To keep notifications' words off Expo's, Google's and Apple's services, set `PHONE_PUSH=off` ([self-hosting](/guides/self-hosting/#the-phone-app)); the app then says your g1t sends no notifications, and Chat still updates while it is open. | | |
| 140 | + | | Older versions | A g1t from before the phone app does not let the app's sign-in into Chat. Upgrade it, or sign in with an access token that has **Use the website as you**. | | |
| 119 | 141 | ||
| 120 | 142 | ## What's not there yet | |
| 121 | 143 |
| 189 | 189 | - never while **Do not disturb** is on; | |
| 190 | 190 | - nothing from a conversation you [muted](/guides/chat/#star-and-mute), | |
| 191 | 191 | but a direct message or a mention of you; | |
| 192 | − | - nothing while a g1t tab is in front of you: it tells you there. | |
| 192 | + | - nothing while a g1t tab, or the app itself, is in front of you: it tells you there. | |
| 193 | 193 | ||
| 194 | 194 | Your phone is registered when you allow notifications in the app. | |
| 195 | − | **Send a test notification** reaches it too. Signing out of the app stops | |
| 195 | + | **Send a test notification**, here or in the app under **More**, reaches it too. Signing out of the app stops | |
| 196 | 196 | them on that phone. g1t pushes to your 10 most recent phones, and stops | |
| 197 | − | on its own for a phone the app was removed from. | |
| 197 | + | on its own for a phone the app was removed from. Phones are reached | |
| 198 | + | through Expo's push service, which hands each notification to Google | |
| 199 | + | (Android) or Apple (iOS); a [self-hosted g1t](/guides/self-hosting/#the-phone-app) | |
| 200 | + | can turn that off. | |
| 198 | 201 | ||
| 199 | 202 | ## Settings | |
| 200 | 203 |
| 64 | 64 | | `services/search` | Runs: site search is SQLite full-text search | | |
| 65 | 65 | | `services/chat` | Runs, with its Durable Objects; custom emoji are kept with avatars | | |
| 66 | 66 | | `services/artifacts` | Runs: [artifacts](/guides/artifacts/) and their spaces, with their live rooms. Files people add are kept in the `g1t-docs-files` bucket. Search matches words, not meaning, because there is no embedding model. | | |
| 67 | − | | `services/notify` | Runs. Notifications are live in open tabs; browser push needs a VAPID key pair, which an installation does not make. Push to the phone app goes through Expo and needs nothing set. | | |
| 67 | + | | `services/notify` | Runs. Notifications are live in open tabs; browser push needs a VAPID key pair, which an installation does not make. Push to the phone app goes through Expo and needs nothing set; `PHONE_PUSH=off` sends phones nothing ([the phone app](/guides/self-hosting/#the-phone-app)). | | |
| 68 | 68 | | `services/agents` | Runs, but replies need the model proxy, which is off, so an agent answers with a short apology | | |
| 69 | 69 | | `services/billing` | Runs with nothing charged and no usage limit | | |
| 70 | 70 | | `services/integrations` | Runs | |
| 32 | 32 | | Deployments on `g1t.page` | Off | | |
| 33 | 33 | | Billing | Off. Nothing is charged, and no usage limit stops work. | | |
| 34 | 34 | | Site analytics | Off. Only g1t.sh sends page analytics (to HeyCatch, as its [privacy policy](https://g1t.sh/policies/privacy#how-the-site-is-used) describes); your installation sends none. | | |
| 35 | + | | [The phone app](/guides/mobile/) | Works: Chat, notifications, Code. Point it here from your download page's QR code ([below](#the-phone-app)). | | |
| 35 | 36 | | Git over SSH and the `g1t` CLI | Not available yet | | |
| 36 | 37 | | Scheduled jobs | Run on their schedules inside the g1t container: webhook retries, purging deleted repositories, the packages sweep, security sweeps, audit log retention, access request summaries and emptying artifacts left in the trash for 30 days. Actions schedules (`on: schedule`) are not run. | | |
| 37 | 38 | ||
| ⋯ | |||
| 173 | 174 | | `STATUS_PORT` | `8788` | The port the status page is published on | | |
| 174 | 175 | | `STATUS_PROBE_REPO` | (none) | A public repository, `workspace/repo`, whose branches the status page lists every minute as a clone would. Empty: git is not checked. | | |
| 175 | 176 | | `INVITE_STAFF_WORKSPACES` | (none) | Workspace slugs, comma separated, whose owners can make invites without a limit. Set it to your own workspace before you switch to `invite`, so someone can invite the first people. | | |
| 177 | + | | `PHONE_PUSH` | `on` | `off`: send [the phone app](#the-phone-app) no notifications, so their words never pass through Expo's push service. Browser and desktop notifications are not affected. | | |
| 178 | + | ||
| 179 | + | ## The phone app | |
| 180 | + | ||
| 181 | + | [The g1t phone app](/guides/mobile/) works with your installation as it | |
| 182 | + | does with g1t.sh: the same sign-in (OAuth, approved on your site), Chat | |
| 183 | + | live, notifications and Code. Nothing is set up for it here. | |
| 184 | + | ||
| 185 | + | 1. Make the installation reachable from a phone: `PUBLIC_URL` and | |
| 186 | + | `API_URL` set to addresses the phone can open, such as | |
| 187 | + | `https://git.example.com` and `https://api.git.example.com` behind your | |
| 188 | + | proxy. Store builds of the app open `https` addresses only. | |
| 189 | + | 2. People open `PUBLIC_URL/download` and scan the code under **On your | |
| 190 | + | phone**, or tap **Open in the g1t app** on the phone. The link is | |
| 191 | + | `g1t://connect?server=<PUBLIC_URL>`; you can hand it out yourself. | |
| 192 | + | 3. The app reads `PUBLIC_URL/.well-known/g1t.json` for where the API is, | |
| 193 | + | and signs in there. | |
| 194 | + | ||
| 195 | + | Notifications reach phones through the push service of g1t's app on Expo, | |
| 196 | + | which hands them to Google's (Android) or Apple's (iOS): your installation | |
| 197 | + | sends each one there, with its title and a line of the message, as g1t.sh | |
| 198 | + | does. No account or key is needed. To send phones nothing, set | |
| 199 | + | `PHONE_PUSH=off`: the app then tells people your g1t sends no | |
| 200 | + | notifications, and everything else works. | |
| 176 | 201 | ||
| 177 | 202 | ## The status page | |
| 178 | 203 | ||
| 68 | 68 | | [Artifacts](/guides/artifacts/) | Documents written together, live, by people and agents. Private until you share them. | <Status is="live" /> | | |
| 69 | 69 | | More kinds of artifact | Slides, designs and dashboards that keep the query behind every number. | <Status is="coming" /> | | |
| 70 | 70 | | Scratchpads | Tools, reports and agents anyone can build on a copy of the data, used in production only after review. | <Status is="coming" /> | | |
| 71 | − | | Desktop and phone apps | g1t on your desktop, with a runner inside, and on iOS and Android, with approvals and live task progress. | <Status is="coming" /> | | |
| 71 | + | | [The desktop app](/guides/desktop/) | g1t in a window of its own for Windows, macOS and Linux, with notifications from your system. | <Status is="preview" /> | | |
| 72 | + | | [The phone app](/guides/mobile/) | Chat with your team and its agents, live, with notifications on the phone; review pull requests. Android first, for g1t.sh or your own g1t; not in the stores yet. | <Status is="preview" /> | | |
| 73 | + | | A runner inside the desktop app | Agents working on your own machine, with what only it reaches. | <Status is="coming" /> | | |
| 72 | 74 | ||
| 73 | 75 | ## Agents | |
| 74 | 76 |
| 205 | 205 | 4. Build again. Pushes need a build: Expo Go on Android has none. | |
| 206 | 206 | ||
| 207 | 207 | `EXPO_ACCESS_TOKEN` on the notify Worker is needed only if the Expo | |
| 208 | − | project turns on **Enhanced push security**. | |
| 208 | + | project turns on **Enhanced push security**. Leave that off: self-hosted | |
| 209 | + | g1ts push to this same app through this same project, without g1t's | |
| 210 | + | token, and turning it on stops their notifications. | |
| 211 | + | ||
| 212 | + | ### Self-hosted g1t | |
| 213 | + | ||
| 214 | + | The app works with any g1t (docs: the phone app guide's "Your own g1t", | |
| 215 | + | and self-hosting's "The phone app"): | |
| 216 | + | ||
| 217 | + | - `GET <site>/.well-known/g1t.json` (`@g1t/contracts/discovery`, | |
| 218 | + | `apps/web/app/routes/well-known-g1t.ts`) says where the API is and | |
| 219 | + | whether the g1t sends phones notifications (`PHONE_PUSH`). The app reads | |
| 220 | + | it in `lib/discover.ts`, and falls back to `api.<host>` for an older g1t. | |
| 221 | + | - `g1t://connect?server=<site>` (`app/connect.tsx`) points the app at a | |
| 222 | + | g1t after asking; a self-hosted g1t's `/download` shows it as a QR code. | |
| 223 | + | - On g1t.sh none of it shows: the sign-in screen has one button, and a | |
| 224 | + | quiet "Using your own g1t?" at its foot. | |
| 225 | + | - Store builds open `https` only (Android blocks cleartext); development | |
| 226 | + | builds also open `http`, for a g1t on your network. | |
| 209 | 227 | ||
| 210 | 228 | ### Before the first store release | |
| 211 | 229 |
| 18 | 18 | const queries = useQueryClient(); | |
| 19 | 19 | const name = me.display_username ?? me.username; | |
| 20 | 20 | const oauth = client.credentials.kind === "oauth"; | |
| 21 | − | const push = useQuery({ queryKey: ["push-state"], queryFn: pushState }); | |
| 21 | + | const push = useQuery({ queryKey: ["push-state", server.site], queryFn: () => pushState(server) }); | |
| 22 | 22 | const test = useMutation({ mutationFn: () => sendTestPush(client) }); | |
| 23 | 23 | ||
| 24 | 24 | const pushRow = { | |
| 25 | 25 | unsupported: { label: "Not here", detail: "Notifications need a build of the app on a phone: not Expo Go on Android, nor a simulator." }, | |
| 26 | + | off: { label: "Off", detail: `${new URL(server.site).host} sends no notifications to phones. Chat still updates while the app is open.` }, | |
| 26 | 27 | denied: { label: "Off", detail: "Turned off for g1t in your phone's settings. Tap to open them." }, | |
| 27 | 28 | undetermined: { label: "Off", detail: "Tap to get notified of messages to you, mentions and agents waiting on you." }, | |
| 28 | 29 | granted: { label: "On", detail: "Messages to you, mentions, replies and agents waiting on you, when you're not at g1t elsewhere." }, | |
| ⋯ | |||
| 53 | 54 | push.data === "denied" | |
| 54 | 55 | ? () => void Linking.openSettings() | |
| 55 | 56 | : push.data === "undetermined" | |
| 56 | − | ? () => void registerForPush(client, true).finally(() => queries.invalidateQueries({ queryKey: ["push-state"] })) | |
| 57 | + | ? () => void registerForPush(client, true).finally(() => queries.invalidateQueries({ queryKey: ["push-state", server.site] })) | |
| 57 | 58 | : undefined | |
| 58 | 59 | } | |
| 59 | 60 | /> | |
| 61 | 61 | <Stack.Screen name="sign-in" /> | |
| 62 | 62 | </Stack.Protected> | |
| 63 | 63 | <Stack.Screen name="oauth/callback" /> | |
| 64 | + | <Stack.Screen name="connect" /> | |
| 64 | 65 | <Stack.Screen name="open" /> | |
| 65 | 66 | </Stack> | |
| 66 | 67 | </ThemeProvider> |
| 1 | + | import { router, useLocalSearchParams } from "expo-router"; | |
| 2 | + | import { Server as ServerIcon } from "lucide-react-native"; | |
| 3 | + | import { useEffect, useState } from "react"; | |
| 4 | + | import { View } from "react-native"; | |
| 5 | + | ||
| 6 | + | import { G1tMark } from "@/components/mark"; | |
| 7 | + | import { Button, Loading, Screen, Text } from "@/components/ui"; | |
| 8 | + | import { type Found, discover } from "@/lib/discover"; | |
| 9 | + | import { isHosted } from "@/lib/server"; | |
| 10 | + | import { useSession } from "@/lib/session"; | |
| 11 | + | import { space, useColors } from "@/theme"; | |
| 12 | + | ||
| 13 | + | /** | |
| 14 | + | * `g1t://connect?server=<site>`: a link (or the QR code on a self-hosted | |
| 15 | + | * g1t's download page) that points the app at a g1t. It asks first: the | |
| 16 | + | * app talks to one g1t at a time, and switching signs out of the one | |
| 17 | + | * before. | |
| 18 | + | */ | |
| 19 | + | export default function Connect() { | |
| 20 | + | const { server: given } = useLocalSearchParams<{ server?: string }>(); | |
| 21 | + | const { state, changeServer } = useSession(); | |
| 22 | + | const colors = useColors(); | |
| 23 | + | const [found, setFound] = useState<Found | null>(null); | |
| 24 | + | const [error, setError] = useState<string | null>(null); | |
| 25 | + | const [busy, setBusy] = useState(false); | |
| 26 | + | ||
| 27 | + | useEffect(() => { | |
| 28 | + | if (!given) return setError("This link does not say which g1t."); | |
| 29 | + | discover(given) | |
| 30 | + | .then(setFound) | |
| 31 | + | .catch((failure: unknown) => setError(failure instanceof Error ? failure.message : "That g1t did not answer.")); | |
| 32 | + | }, [given]); | |
| 33 | + | ||
| 34 | + | const leave = () => (router.canGoBack() ? router.back() : router.replace("/")); | |
| 35 | + | const host = found ? new URL(found.server.site).host : ""; | |
| 36 | + | const already = found && state.server && state.server.site === found.server.site; | |
| 37 | + | const signedInElsewhere = state.status === "signed-in" && !already; | |
| 38 | + | ||
| 39 | + | return ( | |
| 40 | + | <Screen safe> | |
| 41 | + | <View style={{ flex: 1, padding: space.xl, justifyContent: "center", gap: space.xl }}> | |
| 42 | + | <G1tMark size={44} /> | |
| 43 | + | {!found && !error ? <Loading /> : null} | |
| 44 | + | {error ? ( | |
| 45 | + | <View style={{ gap: space.md }}> | |
| 46 | + | <Text size="title">Can't reach that g1t</Text> | |
| 47 | + | <Text tone="muted">{error}</Text> | |
| 48 | + | <Button title="Back" variant="secondary" onPress={leave} /> | |
| 49 | + | </View> | |
| 50 | + | ) : null} | |
| 51 | + | {found ? ( | |
| 52 | + | <View style={{ gap: space.md }}> | |
| 53 | + | <Text size="title">{already ? `Already on ${host}` : `Use ${host}?`}</Text> | |
| 54 | + | <Text tone="muted"> | |
| 55 | + | {isHosted(found.server) | |
| 56 | + | ? "g1t.sh, hosted by g1t." | |
| 57 | + | : `A g1t run by its own people at ${found.server.site}. The app signs in there, and talks to it alone.`} | |
| 58 | + | </Text> | |
| 59 | + | {!found.push ? ( | |
| 60 | + | <Text size="small" tone="faint"> | |
| 61 | + | This g1t sends no notifications to phones; Chat still works while the app is open. | |
| 62 | + | </Text> | |
| 63 | + | ) : null} | |
| 64 | + | {signedInElsewhere ? ( | |
| 65 | + | <Text size="small" style={{ color: colors.warn }}> | |
| 66 | + | You'll be signed out of {state.server ? new URL(state.server.site).host : "the g1t you are on"} first. | |
| 67 | + | </Text> | |
| 68 | + | ) : null} | |
| 69 | + | {already ? ( | |
| 70 | + | <Button title="Continue" onPress={() => router.replace("/")} /> | |
| 71 | + | ) : ( | |
| 72 | + | <Button | |
| 73 | + | title={`Use ${host}`} | |
| 74 | + | icon={ServerIcon} | |
| 75 | + | busy={busy} | |
| 76 | + | onPress={async () => { | |
| 77 | + | setBusy(true); | |
| 78 | + | await changeServer(found.server); | |
| 79 | + | router.replace("/sign-in"); | |
| 80 | + | }} | |
| 81 | + | /> | |
| 82 | + | )} | |
| 83 | + | {!already ? <Button title="Not now" variant="secondary" onPress={leave} /> : null} | |
| 84 | + | </View> | |
| 85 | + | ) : null} | |
| 86 | + | </View> | |
| 87 | + | </Screen> | |
| 88 | + | ); | |
| 89 | + | } |
| 1 | − | import { KeyRound, LogIn, Server as ServerIcon } from "lucide-react-native"; | |
| 1 | + | import { KeyRound, LogIn } from "lucide-react-native"; | |
| 2 | 2 | import { useState } from "react"; | |
| 3 | − | import { KeyboardAvoidingView, Platform, View } from "react-native"; | |
| 3 | + | import { KeyboardAvoidingView, Platform, Pressable, View } from "react-native"; | |
| 4 | 4 | ||
| 5 | + | import { G1tMark } from "@/components/mark"; | |
| 5 | 6 | import { Button, Field, Screen, Text } from "@/components/ui"; | |
| 6 | − | import { G1tMark } from "@/components/mark"; | |
| 7 | + | import { discover } from "@/lib/discover"; | |
| 7 | 8 | import { SignInCancelled } from "@/lib/oauth"; | |
| 8 | − | import { HOSTED, isHosted, parseServer } from "@/lib/server"; | |
| 9 | + | import { HOSTED, isHosted } from "@/lib/server"; | |
| 9 | 10 | import { useSession } from "@/lib/session"; | |
| 10 | 11 | import { space } from "@/theme"; | |
| 11 | 12 | ||
| 12 | 13 | /** | |
| 13 | − | * Signing in. The usual way is **Sign in with g1t**: the site opens in the | |
| 14 | − | * system's browser, the person approves the app, and it comes back signed | |
| 15 | − | * in (lib/oauth.ts). **Use an access token** takes a personal token | |
| 16 | − | * instead. **Server** points the app at a self-hosted g1t. | |
| 14 | + | * Signing in. On g1t.sh there is one thing to do: **Sign in with g1t**, | |
| 15 | + | * which opens the site in the system's browser to approve the app | |
| 16 | + | * (lib/oauth.ts). Everything else stays out of the way: a personal access | |
| 17 | + | * token, and pointing the app at a g1t of your own, which a quiet line at | |
| 18 | + | * the foot offers, or a `g1t://connect` link does for you (app/connect.tsx). | |
| 19 | + | * Pointed at your own g1t, its address is shown, so you know where you sign in. | |
| 17 | 20 | */ | |
| 18 | 21 | export default function SignIn() { | |
| 19 | 22 | const { state, signIn, signInWithToken, changeServer } = useSession(); | |
| 20 | 23 | const server = state.server ?? HOSTED; | |
| 24 | + | const hosted = isHosted(server); | |
| 25 | + | const host = new URL(server.site).host; | |
| 21 | 26 | const [mode, setMode] = useState<"oauth" | "token" | "server">("oauth"); | |
| 22 | 27 | const [busy, setBusy] = useState(false); | |
| 23 | 28 | const [error, setError] = useState<string | null>(null); | |
| 24 | 29 | const [token, setToken] = useState(""); | |
| 25 | − | const [site, setSite] = useState(server.site); | |
| 26 | − | const [api, setApi] = useState(isHosted(server) ? "" : server.api); | |
| 30 | + | const [site, setSite] = useState(hosted ? "" : server.site); | |
| 31 | + | const [api, setApi] = useState(""); | |
| 32 | + | const [apiShown, setApiShown] = useState(false); | |
| 27 | 33 | ||
| 28 | 34 | const run = async (work: () => Promise<void>) => { | |
| 29 | 35 | setBusy(true); | |
| ⋯ | |||
| 43 | 49 | <View style={{ flex: 1, padding: space.xl, justifyContent: "center", gap: space.xl }}> | |
| 44 | 50 | <View style={{ gap: space.md }}> | |
| 45 | 51 | <G1tMark size={44} /> | |
| 46 | − | <Text size="title">Sign in to g1t</Text> | |
| 52 | + | <Text size="title">{hosted ? "Sign in to g1t" : `Sign in to ${host}`}</Text> | |
| 47 | 53 | <Text tone="muted">Chat with your team and its agents, and review and merge what they make.</Text> | |
| 48 | 54 | </View> | |
| 49 | 55 | ||
| ⋯ | |||
| 51 | 57 | <View style={{ gap: space.md }}> | |
| 52 | 58 | <Button title="Sign in with g1t" icon={LogIn} busy={busy} onPress={() => run(signIn)} /> | |
| 53 | 59 | <Text size="small" tone="faint"> | |
| 54 | − | {new URL(server.site).host} opens in your browser to approve the app. Your password never reaches it. | |
| 60 | + | {host} opens in your browser to approve the app. Your password never reaches it. | |
| 55 | 61 | </Text> | |
| 62 | + | <Button title="Use an access token" variant="secondary" icon={KeyRound} onPress={() => setMode("token")} /> | |
| 56 | 63 | </View> | |
| 57 | 64 | ) : null} | |
| 58 | 65 | ||
| ⋯ | |||
| 64 | 71 | value={token} | |
| 65 | 72 | onChangeText={setToken} | |
| 66 | 73 | secureTextEntry | |
| 67 | − | hint="Make one in Settings → Access tokens. For Chat, turn on “Use the website as you”." | |
| 74 | + | hint={`Make one on ${host} in Settings → Access tokens, with Chat and Notifications, or “Use the website as you”.`} | |
| 68 | 75 | /> | |
| 69 | 76 | <Button title="Sign in" busy={busy} disabled={!token.trim()} onPress={() => run(() => signInWithToken(token))} /> | |
| 77 | + | <Button title="Back" variant="secondary" onPress={() => setMode("oauth")} /> | |
| 70 | 78 | </View> | |
| 71 | 79 | ) : null} | |
| 72 | 80 | ||
| 73 | 81 | {mode === "server" ? ( | |
| 74 | 82 | <View style={{ gap: space.md }}> | |
| 75 | − | <Field label="Site" placeholder="g1t.example.com" value={site} onChangeText={setSite} keyboardType="url" /> | |
| 76 | 83 | <Field | |
| 77 | − | label="API (optional)" | |
| 78 | − | placeholder={`api.${site ? site.replace(/^https?:\/\//, "") : "g1t.example.com"}`} | |
| 79 | − | value={api} | |
| 80 | − | onChangeText={setApi} | |
| 84 | + | label="Your g1t's address" | |
| 85 | + | placeholder="g1t.example.com" | |
| 86 | + | value={site} | |
| 87 | + | onChangeText={setSite} | |
| 81 | 88 | keyboardType="url" | |
| 82 | − | hint="Left empty, the API is api. in front of the site's address." | |
| 89 | + | autoFocus | |
| 90 | + | hint="The address you open g1t at in a browser. The app finds the rest." | |
| 83 | 91 | /> | |
| 92 | + | {apiShown ? ( | |
| 93 | + | <Field | |
| 94 | + | label="Its API's address" | |
| 95 | + | placeholder="api.g1t.example.com" | |
| 96 | + | value={api} | |
| 97 | + | onChangeText={setApi} | |
| 98 | + | keyboardType="url" | |
| 99 | + | hint="Only for a g1t too old to say where its API is." | |
| 100 | + | /> | |
| 101 | + | ) : null} | |
| 84 | 102 | <Button | |
| 85 | − | title="Use this server" | |
| 103 | + | title="Continue" | |
| 86 | 104 | busy={busy} | |
| 105 | + | disabled={!site.trim()} | |
| 87 | 106 | onPress={() => | |
| 88 | 107 | run(async () => { | |
| 89 | − | const next = parseServer(site, api); | |
| 90 | − | if (!next) throw new Error("That is not a web address. It looks like https://g1t.example.com."); | |
| 91 | − | await changeServer(next); | |
| 108 | + | const found = await discover(site, apiShown ? api : undefined); | |
| 109 | + | await changeServer(found.server); | |
| 92 | 110 | setMode("oauth"); | |
| 93 | 111 | }) | |
| 94 | 112 | } | |
| 95 | 113 | /> | |
| 96 | − | <Button | |
| 97 | − | title="Use g1t.sh" | |
| 98 | − | variant="secondary" | |
| 99 | − | onPress={() => { | |
| 100 | − | setSite(HOSTED.site); | |
| 101 | − | setApi(""); | |
| 102 | − | void run(async () => { | |
| 103 | − | await changeServer(HOSTED); | |
| 104 | − | setMode("oauth"); | |
| 105 | − | }); | |
| 106 | − | }} | |
| 107 | − | /> | |
| 114 | + | <Button title="Back" variant="secondary" onPress={() => setMode("oauth")} /> | |
| 115 | + | {!apiShown ? ( | |
| 116 | + | <Pressable onPress={() => setApiShown(true)} hitSlop={8}> | |
| 117 | + | <Text size="small" tone="faint" style={{ textAlign: "center" }}> | |
| 118 | + | Its API is at another address | |
| 119 | + | </Text> | |
| 120 | + | </Pressable> | |
| 121 | + | ) : null} | |
| 108 | 122 | </View> | |
| 109 | 123 | ) : null} | |
| 110 | 124 | ||
| 111 | 125 | {error ? <Text tone="danger">{error}</Text> : null} | |
| 126 | + | </View> | |
| 112 | 127 | ||
| 113 | − | <View style={{ gap: space.sm }}> | |
| 114 | − | {mode !== "oauth" ? <Button title="Sign in with g1t instead" variant="secondary" icon={LogIn} onPress={() => setMode("oauth")} /> : null} | |
| 115 | − | {mode !== "token" ? <Button title="Use an access token" variant="secondary" icon={KeyRound} onPress={() => setMode("token")} /> : null} | |
| 116 | − | {mode !== "server" ? ( | |
| 117 | − | <Button title={isHosted(server) ? "Server: g1t.sh" : `Server: ${new URL(server.site).host}`} variant="secondary" icon={ServerIcon} onPress={() => setMode("server")} /> | |
| 118 | − | ) : null} | |
| 128 | + | {mode === "oauth" ? ( | |
| 129 | + | <View style={{ padding: space.xl, paddingTop: 0, alignItems: "center", gap: space.xs }}> | |
| 130 | + | {hosted ? ( | |
| 131 | + | <Pressable onPress={() => setMode("server")} hitSlop={10}> | |
| 132 | + | <Text size="small" tone="faint"> | |
| 133 | + | Using your own g1t? <Text size="small" tone="accent">Sign in there</Text> | |
| 134 | + | </Text> | |
| 135 | + | </Pressable> | |
| 136 | + | ) : ( | |
| 137 | + | <Text size="small" tone="faint"> | |
| 138 | + | Signing in to {host} ·{" "} | |
| 139 | + | <Text size="small" tone="accent" onPress={() => setMode("server")}> | |
| 140 | + | Change | |
| 141 | + | </Text>{" "} | |
| 142 | + | ·{" "} | |
| 143 | + | <Text size="small" tone="accent" onPress={() => void run(() => changeServer(HOSTED))}> | |
| 144 | + | Use g1t.sh | |
| 145 | + | </Text> | |
| 146 | + | </Text> | |
| 147 | + | )} | |
| 119 | 148 | </View> | |
| 120 | − | </View> | |
| 149 | + | ) : null} | |
| 121 | 150 | </KeyboardAvoidingView> | |
| 122 | 151 | </Screen> | |
| 123 | 152 | ); | |
| 1 | + | import { DISCOVERY_PATH, parseDiscovery } from "@g1t/contracts/discovery"; | |
| 2 | + | ||
| 3 | + | import { type Server, parseOrigin, parseServer } from "./server"; | |
| 4 | + | ||
| 5 | + | /** | |
| 6 | + | * A g1t from the address someone typed, or a `g1t://connect` link gave: | |
| 7 | + | * its site, and its API as the site says (`/.well-known/g1t.json`, | |
| 8 | + | * packages/contracts src/discovery.ts), so a self-hosted g1t's API can be | |
| 9 | + | * anywhere. A g1t from before the phone app has no such file: its API is | |
| 10 | + | * then taken to be `api.` in front of the site, and Chat may need a | |
| 11 | + | * website-enabled access token there. | |
| 12 | + | */ | |
| 13 | + | export type Found = { server: Server; known: boolean; push: boolean; chat: boolean }; | |
| 14 | + | ||
| 15 | + | export async function discover(siteText: string, apiText?: string): Promise<Found> { | |
| 16 | + | const site = parseOrigin(siteText); | |
| 17 | + | if (!site) throw new Error("That is not a web address. It looks like https://g1t.example.com."); | |
| 18 | + | if (apiText?.trim()) { | |
| 19 | + | const given = parseServer(site, apiText); | |
| 20 | + | if (!given) throw new Error("The API's address is not a web address."); | |
| 21 | + | return { server: given, known: false, push: true, chat: true }; | |
| 22 | + | } | |
| 23 | + | const controller = new AbortController(); | |
| 24 | + | const timer = setTimeout(() => controller.abort(), 6000); | |
| 25 | + | try { | |
| 26 | + | const response = await fetch(site + DISCOVERY_PATH, { headers: { accept: "application/json" }, signal: controller.signal }); | |
| 27 | + | const found = response.ok ? parseDiscovery(await response.json()) : null; | |
| 28 | + | if (found) { | |
| 29 | + | return { server: { site: found.site, api: found.api, push: found.phone.push }, known: true, push: found.phone.push, chat: found.phone.chat }; | |
| 30 | + | } | |
| 31 | + | } catch { | |
| 32 | + | // Unreachable, or not g1t: said below. | |
| 33 | + | } finally { | |
| 34 | + | clearTimeout(timer); | |
| 35 | + | } | |
| 36 | + | const guessed = parseServer(site); | |
| 37 | + | if (!guessed) throw new Error("That is not a web address."); | |
| 38 | + | // Reachable at all? A wrong address should say so now, not at sign-in. | |
| 39 | + | try { | |
| 40 | + | const metadata = await fetch(`${guessed.api}/.well-known/oauth-authorization-server`, { headers: { accept: "application/json" } }); | |
| 41 | + | if (!metadata.ok) throw new Error(String(metadata.status)); | |
| 42 | + | } catch { | |
| 43 | + | throw new Error(`${new URL(site).host} did not answer as a g1t. Check the address, or give its API's address too.`); | |
| 44 | + | } | |
| 45 | + | return { server: guessed, known: false, push: true, chat: false }; | |
| 46 | + | } |
| 35 | 35 | assert.equal(routeFor("/settings/tokens"), null); | |
| 36 | 36 | assert.equal(routeFor("/"), "/"); | |
| 37 | 37 | }); | |
| 38 | + | ||
| 39 | + | test("a connect link points the app at a g1t", () => { | |
| 40 | + | assert.equal(routeFor("/connect?server=https%3A%2F%2Fg1t.example.com"), "/connect?server=https%3A%2F%2Fg1t.example.com"); | |
| 41 | + | assert.equal(sitePathOf("g1t://connect?server=https%3A%2F%2Fg1t.example.com"), "/connect?server=https%3A%2F%2Fg1t.example.com"); | |
| 42 | + | // A workspace named connect is still a workspace. | |
| 43 | + | assert.equal(routeFor("/connect/-/chat"), "/chat?workspace=connect"); | |
| 44 | + | assert.equal(routeFor("/connect/web"), "/code/connect/web"); | |
| 45 | + | }); |
| 25 | 25 | if (parts.length === 0) return "/"; | |
| 26 | 26 | if (`/${parts.join("/")}` === OAUTH_CALLBACK_PATH) return OAUTH_CALLBACK_PATH + keep; | |
| 27 | 27 | const [owner, second, third, fourth, fifth] = parts; | |
| 28 | + | // `g1t://connect?server=<site>`: point the app at a g1t, as a self-hosted one's download page offers. | |
| 29 | + | if (owner === "connect" && !second && /(^|&)server=/.test(query)) return `/connect?${query}`; | |
| 28 | 30 | if (NOT_OWNERS.has(owner.toLowerCase())) return null; | |
| 29 | 31 | const workspace = `workspace=${encodeURIComponent(owner)}`; | |
| 30 | 32 |
| 44 | 44 | }, | |
| 45 | 45 | }); | |
| 46 | 46 | ||
| 47 | − | export type PushState = "unsupported" | "denied" | "undetermined" | "granted"; | |
| 47 | + | export type PushState = "unsupported" | "off" | "denied" | "undetermined" | "granted"; | |
| 48 | 48 | ||
| 49 | 49 | /** Whether pushes can work here: a real phone, in a build of the app (Expo Go on Android has no push). */ | |
| 50 | 50 | export function pushSupported(): boolean { | |
| 51 | 51 | return Device.isDevice && !(IN_EXPO_GO && Platform.OS === "android"); | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | − | export async function pushState(): Promise<PushState> { | |
| 54 | + | export async function pushState(server?: { push?: boolean }): Promise<PushState> { | |
| 55 | 55 | if (!pushSupported()) return "unsupported"; | |
| 56 | + | if (server?.push === false) return "off"; | |
| 56 | 57 | const { status } = await Notifications.getPermissionsAsync(); | |
| 57 | 58 | return status === "granted" ? "granted" : status === "denied" ? "denied" : "undetermined"; | |
| 58 | 59 | } | |
| ⋯ | |||
| 64 | 65 | */ | |
| 65 | 66 | export async function registerForPush(client: Client, ask: boolean): Promise<PushState> { | |
| 66 | 67 | if (!pushSupported()) return "unsupported"; | |
| 68 | + | // A g1t that sends phones nothing (PHONE_PUSH=off): no asking, nothing to register. | |
| 69 | + | if (client.server.push === false) return "off"; | |
| 67 | 70 | if (Platform.OS === "android") { | |
| 68 | 71 | await Notifications.setNotificationChannelAsync(MESSAGES_CHANNEL, { | |
| 69 | 72 | name: "Messages", | |
| ⋯ | |||
| 80 | 83 | ||
| 81 | 84 | const projectId = (Constants.expoConfig?.extra?.eas as { projectId?: string } | undefined)?.projectId ?? Constants.easConfig?.projectId; | |
| 82 | 85 | const { data: token } = await Notifications.getExpoPushTokenAsync(projectId ? { projectId } : undefined); | |
| 83 | − | await client.siteRaw("/-/notify", { | |
| 86 | + | const answer = await client.siteRaw<{ ok: boolean; off?: boolean }>("/-/notify", { | |
| 84 | 87 | method: "POST", | |
| 85 | 88 | body: JSON.stringify({ | |
| 86 | 89 | intent: "register_device", | |
| 87 | 90 | device: { token, platform: Platform.OS === "ios" ? "ios" : "android", name: Device.deviceName ?? Device.modelName ?? null }, | |
| 88 | 91 | }), | |
| 89 | 92 | }); | |
| 93 | + | if (answer.off) return "off"; | |
| 90 | 94 | await SecureStore.setItemAsync(TOKEN_KEY, token); | |
| 91 | 95 | return "granted"; | |
| 92 | 96 | } | |
| 14 | 14 | site: string; | |
| 15 | 15 | /** An origin, no trailing slash: `https://api.g1t.sh`. */ | |
| 16 | 16 | api: string; | |
| 17 | + | /** False when this g1t sends phones no notifications (its PHONE_PUSH=off, lib/discover.ts). */ | |
| 18 | + | push?: boolean; | |
| 17 | 19 | }; | |
| 18 | 20 | ||
| 19 | 21 | export const HOSTED: Server = { site: "https://g1t.sh", api: "https://api.g1t.sh" }; |
| 563 | 563 | { text: "Pull requests, checks and deploys as cards in the channel", soon: true }, | |
| 564 | 564 | { text: "Mentions and approvals in Notifications", soon: true }, | |
| 565 | 565 | "A desktop app for Windows, macOS and Linux", | |
| 566 | − | { text: "A phone app", soon: true }, | |
| 566 | + | { text: "A phone app with notifications, Android first, for g1t.sh or your own g1t", soon: true }, | |
| 567 | 567 | ]} | |
| 568 | 568 | /> | |
| 569 | 569 | <p className="mt-5 flex max-w-xl flex-wrap items-center gap-x-2.5 gap-y-1.5 text-sm leading-6 text-muted"> |
| 84 | 84 | route("status", "routes/status.tsx"), | |
| 85 | 85 | route("status.json", "routes/status-json.ts"), | |
| 86 | 86 | route(".well-known/security.txt", "routes/security-txt.ts"), | |
| 87 | + | // Where this g1t's parts are, for the phone app pointed at it (packages/contracts src/discovery.ts). | |
| 88 | + | route(".well-known/g1t.json", "routes/well-known-g1t.ts"), | |
| 87 | 89 | // For search engines: what may be crawled, and a map of the public pages. | |
| 88 | 90 | route("robots.txt", "routes/robots-txt.ts"), | |
| 89 | 91 | route("sitemap.xml", "routes/sitemap-xml.ts"), |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | − | import { Apple, Bell, Download, Link2, Monitor, RefreshCw, Server, Terminal } from "lucide-react"; | |
| 2 | + | import { connectLink } from "@g1t/contracts/discovery"; | |
| 3 | + | import { Apple, Bell, Download, Link2, Monitor, RefreshCw, Server, Smartphone, Terminal } from "lucide-react"; | |
| 3 | 4 | import type { ReactNode } from "react"; | |
| 4 | 5 | import { useLoaderData } from "react-router"; | |
| 5 | 6 | ||
| 6 | 7 | import type { Route } from "./+types/download"; | |
| 8 | + | import { QrCode } from "../components/qr-code"; | |
| 7 | 9 | import { TrustPage } from "../components/trust-page"; | |
| 8 | 10 | import { ButtonLink } from "../components/ui"; | |
| 9 | 11 | import { Card } from "../components/ui/card"; | |
| 12 | + | import { HOSTED_ADDRESSES } from "../lib/addresses"; | |
| 13 | + | import { addresses } from "../lib/addresses.server"; | |
| 10 | 14 | import { page } from "../lib/meta"; | |
| 11 | 15 | ||
| 12 | 16 | /** | |
| 13 | − | * g1t.sh/download: the desktop app, from its newest release. The release | |
| 17 | + | * g1t.sh/download: the desktop app, from its newest release, and the phone | |
| 18 | + | * app. On a self-hosted g1t the phone section offers a `g1t://connect` | |
| 19 | + | * link and its QR code, which point the app at this g1t. The release | |
| 14 | 20 | * is `desktop/latest.json` in the g1t-downloads bucket, as | |
| 15 | 21 | * scripts/desktop-release.mjs writes it: what the app's updater reads | |
| 16 | 22 | * (`platforms`), and the list this page shows (`downloads`). The visitor's | |
| ⋯ | |||
| 46 | 52 | ||
| 47 | 53 | export function meta(args: Route.MetaArgs) { | |
| 48 | 54 | return page(args, { | |
| 49 | − | title: "Download g1t for your desktop", | |
| 50 | − | description: "g1t in a window of its own, for Windows, macOS and Linux: notifications from your system, a badge on the icon, g1t:// links, and updates that install themselves.", | |
| 55 | + | title: "Download g1t for your desktop and phone", | |
| 56 | + | 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.", | |
| 51 | 57 | }); | |
| 52 | 58 | } | |
| 53 | 59 | ||
| ⋯ | |||
| 65 | 71 | } catch (error) { | |
| 66 | 72 | console.error("download: the release could not be read", error); | |
| 67 | 73 | } | |
| 68 | − | return { release, platform: platformOf(request.headers.get("user-agent")) }; | |
| 74 | + | const { site } = addresses(); | |
| 75 | + | return { release, platform: platformOf(request.headers.get("user-agent")), site, hosted: site === HOSTED_ADDRESSES.site }; | |
| 69 | 76 | } | |
| 70 | 77 | ||
| 71 | 78 | const PLATFORMS: { id: Platform; name: string; icon: ReactNode; note: string }[] = [ | |
| ⋯ | |||
| 96 | 103 | { 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." }, | |
| 97 | 104 | { 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." }, | |
| 98 | 105 | { 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." }, | |
| 99 | − | { 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." }, | |
| 106 | + | { 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." }, | |
| 100 | 107 | ]; | |
| 101 | 108 | ||
| 102 | 109 | export default function DownloadPage() { | |
| 103 | − | const { release, platform } = useLoaderData<typeof loader>(); | |
| 110 | + | const { release, platform, site, hosted } = useLoaderData<typeof loader>(); | |
| 104 | 111 | const ordered = platform ? [...PLATFORMS.filter((p) => p.id === platform), ...PLATFORMS.filter((p) => p.id !== platform)] : PLATFORMS; | |
| 105 | 112 | const yours = platform && release ? release.downloads.filter((d) => d.platform === platform) : []; | |
| 106 | 113 | const featured = yours.find((d) => d.kind === "installer" || d.kind === "dmg" || d.kind === "appimage") ?? yours[0] ?? null; | |
| ⋯ | |||
| 213 | 220 | </p> | |
| 214 | 221 | </section> | |
| 215 | 222 | )} | |
| 223 | + | <PhoneApp site={site} hosted={hosted} /> | |
| 216 | 224 | </div> | |
| 217 | 225 | </TrustPage> | |
| 218 | 226 | ); | |
| 219 | 227 | } | |
| 228 | + | ||
| 229 | + | /** | |
| 230 | + | * The phone app: Chat with people and agents, live, and notifications on | |
| 231 | + | * the phone. On a g1t of your own, a link and its QR code point the app | |
| 232 | + | * here, so nobody types the address; on g1t.sh the app starts here anyway. | |
| 233 | + | */ | |
| 234 | + | function PhoneApp({ site, hosted }: { site: string; hosted: boolean }) { | |
| 235 | + | const link = connectLink(site); | |
| 236 | + | const host = new URL(site).host; | |
| 237 | + | return ( | |
| 238 | + | <section className="mt-14"> | |
| 239 | + | <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight"> | |
| 240 | + | <Smartphone size={18} className="text-accent" /> | |
| 241 | + | On your phone | |
| 242 | + | </h2> | |
| 243 | + | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 244 | + | Chat with your team and its agents, live, with notifications for messages to you, mentions, replies and agents waiting on you; | |
| 245 | + | review and approve pull requests on the way. Android comes first; it is in testing now, and the stores are next. | |
| 246 | + | </p> | |
| 247 | + | {hosted ? ( | |
| 248 | + | <p className="mt-4 text-sm text-muted"> | |
| 249 | + | How it works, and how to build it yourself meanwhile, is in{" "} | |
| 250 | + | <a href="https://docs.g1t.sh/guides/mobile/" className="text-accent hover:underline"> | |
| 251 | + | the phone app guide | |
| 252 | + | </a> | |
| 253 | + | . | |
| 254 | + | </p> | |
| 255 | + | ) : ( | |
| 256 | + | <Card className="mt-4 flex flex-col gap-5 p-5 sm:flex-row sm:items-center"> | |
| 257 | + | <QrCode value={link} label={`Open the g1t app on ${host}`} size={152} /> | |
| 258 | + | <div className="min-w-0"> | |
| 259 | + | <p className="font-medium">Use the app with {host}</p> | |
| 260 | + | <p className="mt-1 text-sm text-muted"> | |
| 261 | + | 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 | |
| 262 | + | 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 | |
| 263 | + | the address typed. | |
| 264 | + | </p> | |
| 265 | + | <ButtonLink to={link} reloadDocument variant="secondary" className="mt-3"> | |
| 266 | + | <Smartphone size={16} /> | |
| 267 | + | Open in the g1t app | |
| 268 | + | </ButtonLink> | |
| 269 | + | </div> | |
| 270 | + | </Card> | |
| 271 | + | )} | |
| 272 | + | </section> | |
| 273 | + | ); | |
| 274 | + | } | |
| 61 | 61 | if (typeof d?.token !== "string" || (d.platform !== "android" && d.platform !== "ios")) return Response.json({ ok: false }, { status: 400 }); | |
| 62 | 62 | const device: PushDevice = { token: d.token, platform: d.platform, name: typeof d.name === "string" ? d.name : null }; | |
| 63 | 63 | const registered = await notify.registerDevice(user, device); | |
| 64 | − | // The service checks the token is Expo's. | |
| 65 | − | return Response.json(registered, { status: registered.ok ? 200 : 400 }); | |
| 64 | + | // The service checks the token is Expo's; `off` is this g1t sending phones nothing (PHONE_PUSH=off), not a mistake. | |
| 65 | + | return Response.json(registered, { status: registered.ok || registered.off ? 200 : 400 }); | |
| 66 | 66 | } | |
| 67 | 67 | case "unregister_device": | |
| 68 | 68 | return Response.json(await notify.unregisterDevice(user, String(sent.token ?? ""))); |
| 1 | + | /** | |
| 2 | + | * /.well-known/g1t.json: where this g1t's parts are (packages/contracts | |
| 3 | + | * src/discovery.ts). The phone app reads it when someone points it at their | |
| 4 | + | * own g1t, so they type only the site's address. The same for everyone and | |
| 5 | + | * public, so any origin may read it. | |
| 6 | + | */ | |
| 7 | + | import { env } from "cloudflare:workers"; | |
| 8 | + | ||
| 9 | + | import type { G1tDiscovery } from "@g1t/contracts/discovery"; | |
| 10 | + | ||
| 11 | + | import type { Route } from "./+types/well-known-g1t"; | |
| 12 | + | import { HOSTED_ADDRESSES } from "../lib/addresses"; | |
| 13 | + | import { addresses } from "../lib/addresses.server"; | |
| 14 | + | ||
| 15 | + | export function loader(_: Route.LoaderArgs) { | |
| 16 | + | const { site, api, mcp } = addresses(); | |
| 17 | + | const settings = env as { PHONE_PUSH?: string }; | |
| 18 | + | const body: G1tDiscovery = { | |
| 19 | + | name: "g1t", | |
| 20 | + | site, | |
| 21 | + | api, | |
| 22 | + | mcp, | |
| 23 | + | hosted: site === HOSTED_ADDRESSES.site, | |
| 24 | + | // Phone pushes go through Expo's push service unless this g1t turned them off (PHONE_PUSH=off). | |
| 25 | + | phone: { chat: true, push: settings.PHONE_PUSH !== "off" }, | |
| 26 | + | }; | |
| 27 | + | return Response.json(body, { | |
| 28 | + | headers: { "cache-control": "public, max-age=300", "access-control-allow-origin": "*" }, | |
| 29 | + | }); | |
| 30 | + | } |
| 251 | 251 | Object.assign(config.vars, { SITE_URL: PUBLIC_URL, API_URL, MCP_URL, OG_URL: "", USERCONTENT_URL: (process.env.USERCONTENT_URL ?? "").trim() }); | |
| 252 | 252 | } | |
| 253 | 253 | if (hosted.name === "g1t-api") Object.assign(config.vars, { SITE_URL: PUBLIC_URL, API_URL, MCP_URL }); | |
| 254 | + | // Notifications on the g1t phone app go through Expo's push service (and | |
| 255 | + | // from there Google's and Apple's), with no setup here, unless | |
| 256 | + | // PHONE_PUSH=off: notify then sends phones nothing, and the site's | |
| 257 | + | // /.well-known/g1t.json tells the app so. | |
| 258 | + | if (service.web || hosted.name === "g1t-notify") config.vars.PHONE_PUSH = process.env.PHONE_PUSH === "off" ? "off" : "on"; | |
| 254 | 259 | if (hosted.name === "g1t-identity") config.vars.SITE_URL = PUBLIC_URL; | |
| 255 | 260 | // Nightly backups' bundles go to a bucket of their own on the same | |
| 256 | 261 | // S3-compatible store, instead of the BACKUPS R2 bucket, and so do the |
| 4 | 4 | "private": true, | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "license": "MIT", | |
| 7 | − | "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./agent-look": "./src/agent-look.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts", "./oauth": "./src/oauth.ts", "./customize": "./src/customize.ts", "./skills": "./src/skills.ts" }, | |
| 7 | + | "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./agent-look": "./src/agent-look.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts", "./oauth": "./src/oauth.ts", "./customize": "./src/customize.ts", "./skills": "./src/skills.ts", "./discovery": "./src/discovery.ts" }, | |
| 8 | 8 | "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" } | |
| 9 | 9 | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { connectLink, parseDiscovery } from "./discovery.ts"; | |
| 5 | + | ||
| 6 | + | test("a discovery document is read for its origins", () => { | |
| 7 | + | assert.deepEqual( | |
| 8 | + | parseDiscovery({ name: "g1t", site: "https://g1t.example.com/", api: "http://10.0.0.5:8789/x", mcp: "https://g1t.example.com/mcp/", hosted: false, phone: { chat: true, push: false } }), | |
| 9 | + | { name: "g1t", site: "https://g1t.example.com", api: "http://10.0.0.5:8789", mcp: "https://g1t.example.com/mcp", hosted: false, phone: { chat: true, push: false } }, | |
| 10 | + | ); | |
| 11 | + | }); | |
| 12 | + | ||
| 13 | + | test("anything else is not one", () => { | |
| 14 | + | assert.equal(parseDiscovery(null), null); | |
| 15 | + | assert.equal(parseDiscovery({ site: "https://a.example", api: "https://b.example" }), null, "no name"); | |
| 16 | + | assert.equal(parseDiscovery({ name: "g1t", site: "javascript:alert(1)", api: "https://b.example" }), null); | |
| 17 | + | assert.equal(parseDiscovery({ name: "g1t", site: "https://a.example" }), null, "no API"); | |
| 18 | + | }); | |
| 19 | + | ||
| 20 | + | test("a connect link carries the site", () => { | |
| 21 | + | assert.equal(connectLink("https://g1t.example.com"), "g1t://connect?server=https%3A%2F%2Fg1t.example.com"); | |
| 22 | + | }); |
| 1 | + | /** | |
| 2 | + | * How a g1t says where its parts are: `GET <site>/.well-known/g1t.json`. | |
| 3 | + | * The phone app reads it when someone points it at their own g1t, so they | |
| 4 | + | * type only the site's address and the app finds the API (the OAuth | |
| 5 | + | * issuer), wherever that g1t put it. g1t.sh answers it too. | |
| 6 | + | * | |
| 7 | + | * Public and the same for everyone: no account, nothing about a person. | |
| 8 | + | * No imports, so the site and the phone share it. | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | /** Where the document is served, under a site's origin. */ | |
| 12 | + | export const DISCOVERY_PATH = "/.well-known/g1t.json"; | |
| 13 | + | ||
| 14 | + | export type G1tDiscovery = { | |
| 15 | + | /** Always "g1t", so a stray JSON file at the address is not mistaken for one. */ | |
| 16 | + | name: "g1t"; | |
| 17 | + | /** The site's origin: where people sign in and approve applications. */ | |
| 18 | + | site: string; | |
| 19 | + | /** The REST API's origin, which is also the OAuth issuer. */ | |
| 20 | + | api: string; | |
| 21 | + | /** The MCP server's URL. */ | |
| 22 | + | mcp: string; | |
| 23 | + | /** Whether this is g1t.sh, rather than a g1t someone runs themselves. */ | |
| 24 | + | hosted: boolean; | |
| 25 | + | /** | |
| 26 | + | * What the phone app can use here: `chat`, Chat with the app's own | |
| 27 | + | * sign-in (the `chat:*` scopes); `push`, notifications on the phone. A g1t | |
| 28 | + | * from before the phone app has no document at all. | |
| 29 | + | */ | |
| 30 | + | phone: { chat: boolean; push: boolean }; | |
| 31 | + | }; | |
| 32 | + | ||
| 33 | + | function origin(value: unknown): string | null { | |
| 34 | + | if (typeof value !== "string") return null; | |
| 35 | + | try { | |
| 36 | + | const url = new URL(value); | |
| 37 | + | if (url.protocol !== "https:" && url.protocol !== "http:") return null; | |
| 38 | + | return url.origin; | |
| 39 | + | } catch { | |
| 40 | + | return null; | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /** A discovery document as read, checked; null when it is not one. */ | |
| 45 | + | export function parseDiscovery(value: unknown): G1tDiscovery | null { | |
| 46 | + | if (!value || typeof value !== "object") return null; | |
| 47 | + | const v = value as Record<string, unknown>; | |
| 48 | + | if (v.name !== "g1t") return null; | |
| 49 | + | const site = origin(v.site); | |
| 50 | + | const api = origin(v.api); | |
| 51 | + | if (!site || !api) return null; | |
| 52 | + | const mcp = typeof v.mcp === "string" ? v.mcp.trim().replace(/\/+$/, "") : ""; | |
| 53 | + | const phone = (v.phone && typeof v.phone === "object" ? v.phone : {}) as Record<string, unknown>; | |
| 54 | + | return { | |
| 55 | + | name: "g1t", | |
| 56 | + | site, | |
| 57 | + | api, | |
| 58 | + | mcp, | |
| 59 | + | hosted: v.hosted === true, | |
| 60 | + | phone: { chat: phone.chat === true, push: phone.push === true }, | |
| 61 | + | }; | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /** | |
| 65 | + | * A link that opens the phone app pointed at a g1t: `g1t://connect?server=<site>`. | |
| 66 | + | * A self-hosted g1t shows it, and its QR code, so its people need not type | |
| 67 | + | * the address. | |
| 68 | + | */ | |
| 69 | + | export function connectLink(site: string): string { | |
| 70 | + | return `g1t://connect?server=${encodeURIComponent(site)}`; | |
| 71 | + | } |
| 18 | 18 | export * from "./customize"; | |
| 19 | 19 | export * from "./d1"; | |
| 20 | 20 | export * from "./datasets"; | |
| 21 | + | export * from "./discovery"; | |
| 21 | 22 | export * from "./docs"; | |
| 22 | 23 | export * from "./deploy-keys"; | |
| 23 | 24 | export * from "./deployments"; |
| 256 | 256 | subscribe(user: User, subscription: PushSubscriptionJson, userAgent?: string | null): Promise<{ ok: boolean }>; | |
| 257 | 257 | unsubscribe(user: User, endpoint: string): Promise<{ ok: boolean }>; | |
| 258 | 258 | /** A phone the app runs on gets pushes too; the newest 10 are kept. `ok` is false for a token that is not Expo's. */ | |
| 259 | − | registerDevice(user: User, device: PushDevice): Promise<{ ok: boolean }>; | |
| 259 | + | /** `off`: this g1t sends phones nothing (PHONE_PUSH=off). */ | |
| 260 | + | registerDevice(user: User, device: PushDevice): Promise<{ ok: boolean; off?: boolean }>; | |
| 260 | 261 | /** That phone stops getting them: the app signed out. */ | |
| 261 | 262 | unregisterDevice(user: User, token: string): Promise<{ ok: boolean }>; | |
| 262 | 263 | status(user: User, endpoint?: string | null): Promise<NotifyStatus>; |
| 72 | 72 | * phones go unauthenticated, which Expo takes otherwise. | |
| 73 | 73 | */ | |
| 74 | 74 | EXPO_ACCESS_TOKEN?: string; | |
| 75 | + | /** | |
| 76 | + | * `off` sends phones nothing: a self-hosted g1t that would rather not pass | |
| 77 | + | * notifications through Expo's push service (and Google's and Apple's) | |
| 78 | + | * sets PHONE_PUSH=off (deploy/self-host/configs.mjs). Phones then do not | |
| 79 | + | * register, and the site's /.well-known/g1t.json says so to the app. | |
| 80 | + | */ | |
| 81 | + | PHONE_PUSH?: string; | |
| 75 | 82 | /** One presence room per workspace (src/room.ts); without it, presence stays in the person's own tabs. */ | |
| 76 | 83 | ROOMS?: DurableObjectNamespace<Room>; | |
| 77 | 84 | }; | |
| ⋯ | |||
| 164 | 171 | } | |
| 165 | 172 | ||
| 166 | 173 | private deviceCount(): number { | |
| 174 | + | if (!this.phonePush()) return 0; | |
| 167 | 175 | return this.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM devices").one().n; | |
| 168 | 176 | } | |
| 169 | 177 | ||
| 178 | + | /** Whether this g1t sends phones anything (PHONE_PUSH). */ | |
| 179 | + | private phonePush(): boolean { | |
| 180 | + | return this.env.PHONE_PUSH !== "off"; | |
| 181 | + | } | |
| 182 | + | ||
| 170 | 183 | // ── Presence ──────────────────────────────────────────────────────────── | |
| 171 | 184 | ||
| 172 | 185 | private kept(): Kept { | |
| ⋯ | |||
| 465 | 478 | ||
| 466 | 479 | /** Pushes to every phone through Expo; drops the ones Expo says the app is gone from. */ | |
| 467 | 480 | private async pushDevices(notification: FeedNotification, payload: PushPayload): Promise<number> { | |
| 481 | + | if (!this.phonePush()) return 0; | |
| 468 | 482 | const tokens = this.sql.exec<{ token: string }>("SELECT token FROM devices").toArray().map((r) => r.token); | |
| 469 | 483 | if (!tokens.length) return 0; | |
| 470 | 484 | const result = await sendExpo(tokens, payload, notification, { accessToken: this.env.EXPO_ACCESS_TOKEN }); | |
| ⋯ | |||
| 550 | 564 | } | |
| 551 | 565 | ||
| 552 | 566 | /** A phone the app runs on, by its Expo push token; registering it again moves it to the newest. */ | |
| 553 | − | async registerDevice(device: PushDevice): Promise<{ ok: boolean }> { | |
| 567 | + | async registerDevice(device: PushDevice): Promise<{ ok: boolean; off?: boolean }> { | |
| 568 | + | if (!this.phonePush()) return { ok: false, off: true }; | |
| 554 | 569 | const clean = cleanDevice(device); | |
| 555 | 570 | if (!clean) return { ok: false }; | |
| 556 | 571 | this.sql.exec( | |