Skip to content

Commit

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
Claudeagent and Claudeagentcommitted Parentfa14c6cBrowse files
30 files+538−740/30 viewed
+1−1
4343 | Scratchpads: tools, reports and agents anyone can build on a copy of the data, promoted after review | Coming |
4444 | 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 |
4545 | 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 |
4747
4848 ### Agents
4949
+2−2
728728 </Card>
729729 <Card title="Desktop app" icon="monitor">
730730 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.
733733 </Card>
734734 </CardGrid>
735735
+10−0
400400 cd services/notify && npx wrangler secret put EXPO_ACCESS_TOKEN
401401 ```
402402
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+
403413 ## Rate limits
404414
405415 Every public surface that costs money or sends something is behind a
+27−5
111111
112112 ## Your own g1t
113113
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**. |
119141
120142 ## What's not there yet
121143
+6−3
189189 - never while **Do not disturb** is on;
190190 - nothing from a conversation you [muted](/guides/chat/#star-and-mute),
191191 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.
193193
194194 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
196196 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.
198201
199202 ## Settings
200203
+1−1
6464 | `services/search` | Runs: site search is SQLite full-text search |
6565 | `services/chat` | Runs, with its Durable Objects; custom emoji are kept with avatars |
6666 | `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)). |
6868 | `services/agents` | Runs, but replies need the model proxy, which is off, so an agent answers with a short apology |
6969 | `services/billing` | Runs with nothing charged and no usage limit |
7070 | `services/integrations` | Runs |
+25−0
3232 | Deployments on `g1t.page` | Off |
3333 | Billing | Off. Nothing is charged, and no usage limit stops work. |
3434 | 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)). |
3536 | Git over SSH and the `g1t` CLI | Not available yet |
3637 | 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. |
3738
173174 | `STATUS_PORT` | `8788` | The port the status page is published on |
174175 | `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. |
175176 | `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.
176201
177202 ## The status page
178203
+3−1
6868 | [Artifacts](/guides/artifacts/) | Documents written together, live, by people and agents. Private until you share them. | <Status is="live" /> |
6969 | More kinds of artifact | Slides, designs and dashboards that keep the query behind every number. | <Status is="coming" /> |
7070 | 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" /> |
7274
7375 ## Agents
7476
+19−1
205205 4. Build again. Pushes need a build: Expo Go on Android has none.
206206
207207 `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.
209227
210228 ### Before the first store release
211229
+3−2
1818 const queries = useQueryClient();
1919 const name = me.display_username ?? me.username;
2020 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) });
2222 const test = useMutation({ mutationFn: () => sendTestPush(client) });
2323
2424 const pushRow = {
2525 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.` },
2627 denied: { label: "Off", detail: "Turned off for g1t in your phone's settings. Tap to open them." },
2728 undetermined: { label: "Off", detail: "Tap to get notified of messages to you, mentions and agents waiting on you." },
2829 granted: { label: "On", detail: "Messages to you, mentions, replies and agents waiting on you, when you're not at g1t elsewhere." },
5354 push.data === "denied"
5455 ? () => void Linking.openSettings()
5556 : 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] }))
5758 : undefined
5859 }
5960 />
+1−0
6161 <Stack.Screen name="sign-in" />
6262 </Stack.Protected>
6363 <Stack.Screen name="oauth/callback" />
64+ <Stack.Screen name="connect" />
6465 <Stack.Screen name="open" />
6566 </Stack>
6667 </ThemeProvider>
+89−0
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+}
+71−42
1−import { KeyRound, LogIn, Server as ServerIcon } from "lucide-react-native";
1+import { KeyRound, LogIn } from "lucide-react-native";
22 import { useState } from "react";
3−import { KeyboardAvoidingView, Platform, View } from "react-native";
3+import { KeyboardAvoidingView, Platform, Pressable, View } from "react-native";
44
5+import { G1tMark } from "@/components/mark";
56 import { Button, Field, Screen, Text } from "@/components/ui";
6−import { G1tMark } from "@/components/mark";
7+import { discover } from "@/lib/discover";
78 import { SignInCancelled } from "@/lib/oauth";
8−import { HOSTED, isHosted, parseServer } from "@/lib/server";
9+import { HOSTED, isHosted } from "@/lib/server";
910 import { useSession } from "@/lib/session";
1011 import { space } from "@/theme";
1112
1213 /**
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.
1720 */
1821 export default function SignIn() {
1922 const { state, signIn, signInWithToken, changeServer } = useSession();
2023 const server = state.server ?? HOSTED;
24+ const hosted = isHosted(server);
25+ const host = new URL(server.site).host;
2126 const [mode, setMode] = useState<"oauth" | "token" | "server">("oauth");
2227 const [busy, setBusy] = useState(false);
2328 const [error, setError] = useState<string | null>(null);
2429 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);
2733
2834 const run = async (work: () => Promise<void>) => {
2935 setBusy(true);
4349 <View style={{ flex: 1, padding: space.xl, justifyContent: "center", gap: space.xl }}>
4450 <View style={{ gap: space.md }}>
4551 <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>
4753 <Text tone="muted">Chat with your team and its agents, and review and merge what they make.</Text>
4854 </View>
4955
5157 <View style={{ gap: space.md }}>
5258 <Button title="Sign in with g1t" icon={LogIn} busy={busy} onPress={() => run(signIn)} />
5359 <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.
5561 </Text>
62+ <Button title="Use an access token" variant="secondary" icon={KeyRound} onPress={() => setMode("token")} />
5663 </View>
5764 ) : null}
5865
6471 value={token}
6572 onChangeText={setToken}
6673 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”.`}
6875 />
6976 <Button title="Sign in" busy={busy} disabled={!token.trim()} onPress={() => run(() => signInWithToken(token))} />
77+ <Button title="Back" variant="secondary" onPress={() => setMode("oauth")} />
7078 </View>
7179 ) : null}
7280
7381 {mode === "server" ? (
7482 <View style={{ gap: space.md }}>
75− <Field label="Site" placeholder="g1t.example.com" value={site} onChangeText={setSite} keyboardType="url" />
7683 <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}
8188 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."
8391 />
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}
84102 <Button
85− title="Use this server"
103+ title="Continue"
86104 busy={busy}
105+ disabled={!site.trim()}
87106 onPress={() =>
88107 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);
92110 setMode("oauth");
93111 })
94112 }
95113 />
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}
108122 </View>
109123 ) : null}
110124
111125 {error ? <Text tone="danger">{error}</Text> : null}
126+ </View>
112127
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+ )}
119148 </View>
120− </View>
149+ ) : null}
121150 </KeyboardAvoidingView>
122151 </Screen>
123152 );
+46−0
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+}
+8−0
3535 assert.equal(routeFor("/settings/tokens"), null);
3636 assert.equal(routeFor("/"), "/");
3737 });
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+});
+2−0
2525 if (parts.length === 0) return "/";
2626 if (`/${parts.join("/")}` === OAUTH_CALLBACK_PATH) return OAUTH_CALLBACK_PATH + keep;
2727 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}`;
2830 if (NOT_OWNERS.has(owner.toLowerCase())) return null;
2931 const workspace = `workspace=${encodeURIComponent(owner)}`;
3032
+7−3
4444 },
4545 });
4646
47−export type PushState = "unsupported" | "denied" | "undetermined" | "granted";
47+export type PushState = "unsupported" | "off" | "denied" | "undetermined" | "granted";
4848
4949 /** Whether pushes can work here: a real phone, in a build of the app (Expo Go on Android has no push). */
5050 export function pushSupported(): boolean {
5151 return Device.isDevice && !(IN_EXPO_GO && Platform.OS === "android");
5252 }
5353
54−export async function pushState(): Promise<PushState> {
54+export async function pushState(server?: { push?: boolean }): Promise<PushState> {
5555 if (!pushSupported()) return "unsupported";
56+ if (server?.push === false) return "off";
5657 const { status } = await Notifications.getPermissionsAsync();
5758 return status === "granted" ? "granted" : status === "denied" ? "denied" : "undetermined";
5859 }
6465 */
6566 export async function registerForPush(client: Client, ask: boolean): Promise<PushState> {
6667 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";
6770 if (Platform.OS === "android") {
6871 await Notifications.setNotificationChannelAsync(MESSAGES_CHANNEL, {
6972 name: "Messages",
8083
8184 const projectId = (Constants.expoConfig?.extra?.eas as { projectId?: string } | undefined)?.projectId ?? Constants.easConfig?.projectId;
8285 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", {
8487 method: "POST",
8588 body: JSON.stringify({
8689 intent: "register_device",
8790 device: { token, platform: Platform.OS === "ios" ? "ios" : "android", name: Device.deviceName ?? Device.modelName ?? null },
8891 }),
8992 });
93+ if (answer.off) return "off";
9094 await SecureStore.setItemAsync(TOKEN_KEY, token);
9195 return "granted";
9296 }
+2−0
1414 site: string;
1515 /** An origin, no trailing slash: `https://api.g1t.sh`. */
1616 api: string;
17+ /** False when this g1t sends phones no notifications (its PHONE_PUSH=off, lib/discover.ts). */
18+ push?: boolean;
1719 };
1820
1921 export const HOSTED: Server = { site: "https://g1t.sh", api: "https://api.g1t.sh" };
+1−1
563563 { text: "Pull requests, checks and deploys as cards in the channel", soon: true },
564564 { text: "Mentions and approvals in Notifications", soon: true },
565565 "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 },
567567 ]}
568568 />
569569 <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">
+2−0
8484 route("status", "routes/status.tsx"),
8585 route("status.json", "routes/status-json.ts"),
8686 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"),
8789 // For search engines: what may be crawled, and a map of the public pages.
8890 route("robots.txt", "routes/robots-txt.ts"),
8991 route("sitemap.xml", "routes/sitemap-xml.ts"),
+62−7
11 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";
34 import type { ReactNode } from "react";
45 import { useLoaderData } from "react-router";
56
67 import type { Route } from "./+types/download";
8+import { QrCode } from "../components/qr-code";
79 import { TrustPage } from "../components/trust-page";
810 import { ButtonLink } from "../components/ui";
911 import { Card } from "../components/ui/card";
12+import { HOSTED_ADDRESSES } from "../lib/addresses";
13+import { addresses } from "../lib/addresses.server";
1014 import { page } from "../lib/meta";
1115
1216 /**
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
1420 * is `desktop/latest.json` in the g1t-downloads bucket, as
1521 * scripts/desktop-release.mjs writes it: what the app's updater reads
1622 * (`platforms`), and the list this page shows (`downloads`). The visitor's
4652
4753 export function meta(args: Route.MetaArgs) {
4854 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.",
5157 });
5258 }
5359
6571 } catch (error) {
6672 console.error("download: the release could not be read", error);
6773 }
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 };
6976 }
7077
7178 const PLATFORMS: { id: Platform; name: string; icon: ReactNode; note: string }[] = [
96103 { 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." },
97104 { 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." },
98105 { 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." },
100107 ];
101108
102109 export default function DownloadPage() {
103− const { release, platform } = useLoaderData<typeof loader>();
110+ const { release, platform, site, hosted } = useLoaderData<typeof loader>();
104111 const ordered = platform ? [...PLATFORMS.filter((p) => p.id === platform), ...PLATFORMS.filter((p) => p.id !== platform)] : PLATFORMS;
105112 const yours = platform && release ? release.downloads.filter((d) => d.platform === platform) : [];
106113 const featured = yours.find((d) => d.kind === "installer" || d.kind === "dmg" || d.kind === "appimage") ?? yours[0] ?? null;
213220 </p>
214221 </section>
215222 )}
223+ <PhoneApp site={site} hosted={hosted} />
216224 </div>
217225 </TrustPage>
218226 );
219227 }
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+}
+2−2
6161 if (typeof d?.token !== "string" || (d.platform !== "android" && d.platform !== "ios")) return Response.json({ ok: false }, { status: 400 });
6262 const device: PushDevice = { token: d.token, platform: d.platform, name: typeof d.name === "string" ? d.name : null };
6363 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 });
6666 }
6767 case "unregister_device":
6868 return Response.json(await notify.unregisterDevice(user, String(sent.token ?? "")));
+30−0
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+}
+5−0
251251 Object.assign(config.vars, { SITE_URL: PUBLIC_URL, API_URL, MCP_URL, OG_URL: "", USERCONTENT_URL: (process.env.USERCONTENT_URL ?? "").trim() });
252252 }
253253 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";
254259 if (hosted.name === "g1t-identity") config.vars.SITE_URL = PUBLIC_URL;
255260 // Nightly backups' bundles go to a bucket of their own on the same
256261 // S3-compatible store, instead of the BACKUPS R2 bucket, and so do the
+1−1
44 "private": true,
55 "type": "module",
66 "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" },
88 "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" }
99 }
+22−0
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+});
+71−0
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+}
+1−0
1818 export * from "./customize";
1919 export * from "./d1";
2020 export * from "./datasets";
21+export * from "./discovery";
2122 export * from "./docs";
2223 export * from "./deploy-keys";
2324 export * from "./deployments";
+2−1
256256 subscribe(user: User, subscription: PushSubscriptionJson, userAgent?: string | null): Promise<{ ok: boolean }>;
257257 unsubscribe(user: User, endpoint: string): Promise<{ ok: boolean }>;
258258 /** 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 }>;
260261 /** That phone stops getting them: the app signed out. */
261262 unregisterDevice(user: User, token: string): Promise<{ ok: boolean }>;
262263 status(user: User, endpoint?: string | null): Promise<NotifyStatus>;
+16−1
7272 * phones go unauthenticated, which Expo takes otherwise.
7373 */
7474 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;
7582 /** One presence room per workspace (src/room.ts); without it, presence stays in the person's own tabs. */
7683 ROOMS?: DurableObjectNamespace<Room>;
7784 };
164171 }
165172
166173 private deviceCount(): number {
174+ if (!this.phonePush()) return 0;
167175 return this.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM devices").one().n;
168176 }
169177
178+ /** Whether this g1t sends phones anything (PHONE_PUSH). */
179+ private phonePush(): boolean {
180+ return this.env.PHONE_PUSH !== "off";
181+ }
182+
170183 // ── Presence ────────────────────────────────────────────────────────────
171184
172185 private kept(): Kept {
465478
466479 /** Pushes to every phone through Expo; drops the ones Expo says the app is gone from. */
467480 private async pushDevices(notification: FeedNotification, payload: PushPayload): Promise<number> {
481+ if (!this.phonePush()) return 0;
468482 const tokens = this.sql.exec<{ token: string }>("SELECT token FROM devices").toArray().map((r) => r.token);
469483 if (!tokens.length) return 0;
470484 const result = await sendExpo(tokens, payload, notification, { accessToken: this.env.EXPO_ACCESS_TOKEN });
550564 }
551565
552566 /** 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 };
554569 const clean = cleanDevice(device);
555570 if (!clean) return { ok: false };
556571 this.sql.exec(