Skip to content

Commit

The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5)

The phone app with your own g1t, the docs and download page for it, and the runner's flaky cancellation test

syntaqxcommitted Parents87b4b006e2393bBrowse files
43 files+1063−1050/43 viewed
+2−2
117117 # tested here, built into installers by desktop-release.yml.
118118 - name: Check and test
119119 run: cargo test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml --quiet
120− - name: The release script's tests
121− run: node --test scripts/desktop-release.test.mjs
120+ - name: The release scripts' tests
121+ run: node --test scripts/desktop-release.test.mjs scripts/mobile-release.test.mjs
+74−0
1+# Releases the phone app for Android (apps/mobile) on g1t.sh/download,
2+# while it is not in Google Play: EAS builds the `preview` profile (g1t
3+# (beta), a signed release APK), and the APK is published to
4+# g1t.sh/downloads/mobile/ (the g1t-downloads R2 bucket), with latest.json
5+# pointed at it. Google Play and the App Store wait on Flagon's D-U-N-S
6+# number; until then this is how the app is given out.
7+#
8+# A release is a tag `mobile-v<version>`, where the version is the one in
9+# apps/mobile/app.config.ts; or run it by hand, optionally naming a build
10+# EAS already made. scripts/mobile-release.mjs does the work.
11+#
12+# Most changes reach installed apps without a release: `eas update
13+# --channel preview` (expo-updates). A release is for a change to the
14+# app's native side (a new module, app.config.ts) or its version.
15+#
16+# Needs the repository secret EXPO_TOKEN (an Expo access token with access
17+# to the g1t project), CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID as the
18+# deploy has them.
19+name: Mobile release
20+
21+on:
22+ push:
23+ tags: ["mobile-v*"]
24+ workflow_dispatch:
25+ inputs:
26+ build:
27+ description: "An EAS build id to publish instead of building again"
28+ type: string
29+ notes:
30+ description: "A line about the release, shown in the manifest"
31+ type: string
32+
33+permissions:
34+ contents: read
35+
36+concurrency:
37+ group: mobile-release
38+ cancel-in-progress: false
39+
40+env:
41+ CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
42+ WRANGLER_SEND_METRICS: "false"
43+
44+jobs:
45+ release:
46+ name: Build and publish to g1t.sh/downloads/mobile
47+ runs-on: ubuntu-latest
48+ environment: production
49+ timeout-minutes: 60
50+ steps:
51+ - uses: actions/checkout@v5
52+ - name: The tag names this version
53+ run: |
54+ version="$(sed -n 's/^\s*version: "\(.*\)",/\1/p' apps/mobile/app.config.ts | head -1)"
55+ if [ "${GITHUB_REF_TYPE}" = tag ] && [ "mobile-v$version" != "${GITHUB_REF_NAME}" ]; then
56+ echo "::error::The tag is ${GITHUB_REF_NAME}, but apps/mobile is $version"; exit 1
57+ fi
58+ - name: Install Wrangler
59+ run: npm ci --workspaces=false --no-audit --no-fund
60+ - name: Build on EAS
61+ if: ${{ !inputs.build }}
62+ env:
63+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
64+ run: node scripts/mobile-release.mjs build
65+ - name: Fetch the APK
66+ env:
67+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
68+ run: node scripts/mobile-release.mjs fetch ${{ inputs.build && format('--build {0}', inputs.build) || '' }}
69+ - name: The manifest
70+ run: node scripts/mobile-release.mjs manifest --notes "${{ inputs.notes }}"
71+ - name: Publish
72+ env:
73+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
74+ run: node scripts/mobile-release.mjs publish
+1−0
1+24
+10−0
109109 same by hand. The app loads the live site, so a change to the site needs
110110 no release; only a change under `apps/desktop` does. The guide is
111111 [The desktop app](https://docs.g1t.sh/guides/desktop/).
112+- **The phone app** for Android is released on g1t.sh/download while it
113+ is not in Google Play (that waits on Flagon's D-U-N-S number): bump
114+ `version` in `apps/mobile/app.config.ts` when its native side changes,
115+ merge, and push a `mobile-v<version>` tag.
116+ `.g1t/workflows/mobile-release.yml` has EAS build the APK and publishes
117+ it; `scripts/mobile-release.mjs` does the same by hand. A change to the
118+ app's code alone needs no release: `eas update --channel preview` in
119+ `apps/mobile` reaches installed copies. Its server half (chat scopes,
120+ `/-/notify`, notify's phone push) deploys with the site and services.
121+ The guide is [The phone app](https://docs.g1t.sh/guides/mobile/).
112122
113123 ## Speed
114124
+3−3
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
183183 ### On Cloudflare
184184
185185 You need a Cloudflare account on the Workers Paid plan (Cloudflare
186−Artifacts, where repositories are stored, requires it), Node 22.22 or newer (`engines` in `package.json`; g1t is built on
187−Node 24), Rust with the `wasm32-unknown-unknown` target, and
186+Artifacts, where repositories are stored, requires it), Node 24 (`.nvmrc`, and `engines` in `package.json`), Rust with the
187+`wasm32-unknown-unknown` target, and
188188 Docker to build the sandbox image.
189189
190190 ```sh
+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
+11−1
99 [Run g1t yourself](/guides/self-hosting/) instead.
1010
1111 You need a Cloudflare account on the Workers Paid plan (Cloudflare
12−Artifacts, where repositories are kept, requires it), Node 22.22 or newer,
12+Artifacts, where repositories are kept, requires it), Node 24,
1313 Rust with the `wasm32-unknown-unknown` target, and Docker to build the
1414 runner's image.
1515
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
+79−12
11 ---
22 title: The phone app
3−description: g1t on Android and iOS, built with Expo. Chat with people and agents, live, with notifications on your phone, and Code and Customize, signed in with g1t's own OAuth. Not in the stores yet; this is how it works and how to build it.
3+description: g1t on Android and iOS, built with Expo. Chat with people and agents, live, with notifications on your phone, and Code and Customize, signed in with g1t's own OAuth. Android first, downloaded from g1t.sh while it waits for Google Play.
44 ---
55
66 The phone app is g1t on Android and iOS, with its own screens for what
99 and seeing the skills, connectors and plugins your workspace can use in
1010 Customize. Everything else opens on the site, in your phone's browser.
1111
12−It is not in Google Play or the App Store yet. Android comes first. Until
13−then, it is built from `apps/mobile` in
14−[g1t's repository](https://g1t.sh/flagon-io/g1t), as
15−[For operators](#for-operators-building-and-releasing) says.
12+## Install it
13+
14+Android comes first. Get it from [g1t.sh/download](https://g1t.sh/download),
15+under **On your phone**:
16+
17+1. On your Android phone, choose **Download for Android**. On a computer,
18+ scan the code there with the phone's camera instead.
19+2. Open the downloaded file. Android asks once to allow your browser to
20+ install apps: allow it.
21+3. Open **g1t (beta)** and choose **Sign in with g1t**.
22+
23+The page shows each release's SHA-256, to check the file you have is the
24+one g1t published.
25+
26+**Why a download, and not Google Play?** Google Play and the App Store
27+publish a company's apps only once it is verified with a D-U-N-S number,
28+and Flagon's is on its way. Until then the Android app is published on
29+g1t.sh, signed by g1t, as [the desktop app](/guides/desktop/) is. It moves
30+into Google Play as soon as it can. iOS has no way in but the App Store,
31+so it follows then.
32+
33+**Updates** reach the app by themselves: most arrive the next time you
34+open it, with nothing to install. A change that needs a new install is a
35+new release on the download page; installing it over the one you have
36+keeps you signed in.
1637
1738 The bar along the bottom is the site's phone bar:
1839
111132
112133 ## Your own g1t
113134
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.
135+The app works with a g1t you [run yourself](/guides/self-hosting/) as well
136+as with g1t.sh. It talks to one g1t at a time, and opens on g1t.sh unless
137+you point it elsewhere. Two ways to point it at yours:
119138
139+- **Scan its code.** Your g1t's download page, `<your g1t>/download`, has
140+ a QR code under **On your phone**. Scan it with the phone's camera, or
141+ open the page on the phone and choose **Open in the g1t app**. The app
142+ asks **Use g1t.example.com?**, then signs you in there.
143+- **Type its address.** On the sign-in screen, choose **Using your own
144+ g1t? Sign in there** and enter the address you open it at in a browser,
145+ such as `g1t.example.com`.
146+
147+Either way the app reads `<your g1t>/.well-known/g1t.json`, where your g1t
148+says where its API is, so you never type that. A g1t from before the phone
149+app has no such file: the app then takes the API to be `api.` in front of
150+the site, and offers **Its API is at another address** if it is not.
151+
152+The sign-in screen then says **Signing in to g1t.example.com**, with
153+**Change** and **Use g1t.sh**. Switching signs you out of the g1t you were
154+on.
155+
156+| Your g1t | What to know |
157+| --- | --- |
158+| 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. |
159+| `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. |
160+| 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. |
161+| 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**. |
162+
120163 ## What's not there yet
121164
122−- **Store releases.** Google Play first, then the App Store.
165+- **Google Play and the App Store**, once Flagon's D-U-N-S number
166+ arrives ([above](#install-it)). Until then, Android from the download
167+ page; iOS waits for the App Store.
123168 - **Home, Notifications, Agents and Artifacts** as tabs of their own.
124169 Their pages open on the site meanwhile.
125170 - **Formatting in Code.** Descriptions and comments show their Markdown
145190
146191 The three install side by side. `eas submit --platform android` sends
147192 the latest production build to Google Play's internal testing track as a
148−draft.
193+draft, once there is a Play account to send it to.
194+
195+### Releasing on g1t.sh/download
196+
197+Until the app is in Google Play, the `preview` build is the one people
198+install, from g1t.sh/download. A release is a tag `mobile-v<version>`,
199+where the version is `version` in `apps/mobile/app.config.ts`: push it,
200+and the `Mobile release` workflow (`.g1t/workflows/mobile-release.yml`)
201+has EAS build the APK, then publishes it to the `g1t-downloads` bucket
202+at `g1t.sh/downloads/mobile/<version>/` and points `mobile/latest.json`
203+at it, which the download page reads. `scripts/mobile-release.mjs` does
204+the same by hand (`build`, `fetch`, `manifest`, `publish`).
205+
206+| Secret or variable | What it is |
207+| --- | --- |
208+| `EXPO_TOKEN` | An Expo access token with access to the g1t project, from expo.dev |
209+| `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` | As the deploy has them |
210+
211+Most changes need no release: `eas update --channel preview` in
212+`apps/mobile` sends the app's new code to every installed copy of that
213+version, which takes it the next time it opens. A release is for a change
214+to the app's native side (a new native module, `app.config.ts`) or its
215+`version`, which is also what decides which updates a copy takes.
+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
+43−2
9797
9898 ## Run it
9999
100−You need Node 22 (the repository's).
100+You need Node 24 (the repository's `.nvmrc`; EAS builds on 24.21.0, `eas.json`).
101101
102102 ### In Expo Go (quickest)
103103
185185 iOS is the same with `--platform ios`, an Apple Developer account and the
186186 bundle id `sh.g1t.app`; EAS makes the certificates.
187187
188+### Releasing on g1t.sh/download (until Google Play)
189+
190+Google Play and the App Store wait on Flagon's D-U-N-S number. Until then
191+the `preview` build, `g1t (beta)`, is what people install, from
192+g1t.sh/download:
193+
194+```sh
195+# a new native side or version: bump `version` in app.config.ts, then
196+git tag mobile-v0.1.0 && git push origin mobile-v0.1.0 # .g1t/workflows/mobile-release.yml
197+# or by hand, from the repository's root:
198+node scripts/mobile-release.mjs build # EAS builds the APK
199+node scripts/mobile-release.mjs fetch # the newest finished preview build
200+node scripts/mobile-release.mjs manifest
201+node scripts/mobile-release.mjs publish # to g1t.sh/downloads/mobile/, latest.json last
202+
203+# code alone: over the air to every installed copy of this version
204+cd apps/mobile && npx eas-cli update --channel preview --message "What changed"
205+```
206+
207+The workflow needs `EXPO_TOKEN` (expo.dev → Access tokens) beside the
208+deploy's Cloudflare secrets. Preview builds count their own versionCode
209+(`autoIncrement`), so each installs over the last.
210+
188211 ### Push notifications on Android
189212
190213 Expo's push service sends to Android through Firebase Cloud Messaging,
205228 4. Build again. Pushes need a build: Expo Go on Android has none.
206229
207230 `EXPO_ACCESS_TOKEN` on the notify Worker is needed only if the Expo
208−project turns on **Enhanced push security**.
231+project turns on **Enhanced push security**. Leave that off: self-hosted
232+g1ts push to this same app through this same project, without g1t's
233+token, and turning it on stops their notifications.
234+
235+### Self-hosted g1t
236+
237+The app works with any g1t (docs: the phone app guide's "Your own g1t",
238+and self-hosting's "The phone app"):
239+
240+- `GET <site>/.well-known/g1t.json` (`@g1t/contracts/discovery`,
241+ `apps/web/app/routes/well-known-g1t.ts`) says where the API is and
242+ whether the g1t sends phones notifications (`PHONE_PUSH`). The app reads
243+ it in `lib/discover.ts`, and falls back to `api.<host>` for an older g1t.
244+- `g1t://connect?server=<site>` (`app/connect.tsx`) points the app at a
245+ g1t after asking; a self-hosted g1t's `/download` shows it as a QR code.
246+- On g1t.sh none of it shows: the sign-in screen has one button, and a
247+ quiet "Using your own g1t?" at its foot.
248+- Store builds open `https` only (Android blocks cleartext); development
249+ builds also open `http`, for a g1t on your network.
209250
210251 ### Before the first store release
211252
+21−7
55 },
66 "build": {
77 "base": {
8− "node": "22.22.0"
8+ "node": "24.21.0"
99 },
1010 "development": {
1111 "extends": "base",
1212 "developmentClient": true,
1313 "distribution": "internal",
14− "env": { "APP_VARIANT": "development" },
15− "android": { "buildType": "apk" }
14+ "env": {
15+ "APP_VARIANT": "development"
16+ },
17+ "android": {
18+ "buildType": "apk"
19+ }
1620 },
1721 "preview": {
1822 "extends": "base",
1923 "distribution": "internal",
2024 "channel": "preview",
21− "env": { "APP_VARIANT": "preview" },
22− "android": { "buildType": "apk" }
25+ "env": {
26+ "APP_VARIANT": "preview"
27+ },
28+ "android": {
29+ "buildType": "apk"
30+ },
31+ "autoIncrement": true
2332 },
2433 "production": {
2534 "extends": "base",
2635 "channel": "production",
2736 "autoIncrement": true,
28− "android": { "buildType": "app-bundle" }
37+ "android": {
38+ "buildType": "app-bundle"
39+ }
2940 }
3041 },
3142 "submit": {
3243 "production": {
33− "android": { "track": "internal", "releaseStatus": "draft" }
44+ "android": {
45+ "track": "internal",
46+ "releaseStatus": "draft"
47+ }
3448 }
3549 }
3650 }
+1−0
3939 "expo-splash-screen": "~57.0.9",
4040 "expo-status-bar": "~57.0.1",
4141 "expo-system-ui": "~57.0.4",
42+ "expo-updates": "~57.0.25",
4243 "expo-web-browser": "~57.0.3",
4344 "lucide-react-native": "^1.55.0",
4445 "react": "19.2.3",
+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"),
+152−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
3541
3642 type Platform = DesktopDownload["platform"];
3743
44+/** The phone app's newest Android release (scripts/mobile-release.mjs): `mobile/latest.json`. */
45+export type MobileRelease = {
46+ version: string;
47+ build: number | null;
48+ pub_date: string;
49+ notes?: string;
50+ downloads: { platform: "android"; kind: "apk"; name: string; size: number; sha256: string }[];
51+};
52+
53+/** A manifest as read from the bucket, or null when it is not one. */
54+async function readJson<T>(bucket: R2Bucket | undefined, key: string, check: (value: Partial<T>) => boolean): Promise<T | null> {
55+ try {
56+ const found = bucket ? await bucket.get(key) : null;
57+ if (!found) return null;
58+ const read = (await found.json()) as Partial<T>;
59+ return read && check(read) ? (read as T) : null;
60+ } catch (error) {
61+ console.error(`download: ${key} could not be read`, error);
62+ return null;
63+ }
64+}
65+
3866 /** Which platform a browser says it is on; null when it does not say. */
3967 export function platformOf(userAgent: string | null): Platform | null {
4068 if (!userAgent) return null;
4674
4775 export function meta(args: Route.MetaArgs) {
4876 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.",
77+ title: "Download g1t for your desktop and phone",
78+ 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.",
5179 });
5280 }
5381
6593 } catch (error) {
6694 console.error("download: the release could not be read", error);
6795 }
68− return { release, platform: platformOf(request.headers.get("user-agent")) };
96+ const mobile = await readJson<MobileRelease>(bucket, "mobile/latest.json", (read) => typeof read.version === "string" && Array.isArray(read.downloads));
97+ const { site } = addresses();
98+ const userAgent = request.headers.get("user-agent") ?? "";
99+ return {
100+ release,
101+ platform: platformOf(userAgent),
102+ android: /Android/i.test(userAgent),
103+ mobile,
104+ site,
105+ hosted: site === HOSTED_ADDRESSES.site,
106+ };
69107 }
70108
71109 const PLATFORMS: { id: Platform; name: string; icon: ReactNode; note: string }[] = [
96134 { 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." },
97135 { 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." },
98136 { 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." },
137+ { 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." },
100138 ];
101139
102140 export default function DownloadPage() {
103− const { release, platform } = useLoaderData<typeof loader>();
141+ const { release, platform, site, hosted, mobile, android } = useLoaderData<typeof loader>();
104142 const ordered = platform ? [...PLATFORMS.filter((p) => p.id === platform), ...PLATFORMS.filter((p) => p.id !== platform)] : PLATFORMS;
105143 const yours = platform && release ? release.downloads.filter((d) => d.platform === platform) : [];
106144 const featured = yours.find((d) => d.kind === "installer" || d.kind === "dmg" || d.kind === "appimage") ?? yours[0] ?? null;
213251 </p>
214252 </section>
215253 )}
254+ <PhoneApp site={site} hosted={hosted} mobile={mobile} android={android} />
216255 </div>
217256 </TrustPage>
218257 );
219258 }
259+
260+/**
261+ * The phone app: Chat with people and agents, live, and notifications on
262+ * the phone. Not in Google Play or the App Store yet (both wait on
263+ * Flagon's D-U-N-S number), so the Android app is offered here as an APK,
264+ * with how to install it and why it is not in a store. On a g1t of your
265+ * own, a link and its QR code point the app here, so nobody types the
266+ * address; on g1t.sh the app starts here anyway.
267+ */
268+function PhoneApp({ site, hosted, mobile, android }: { site: string; hosted: boolean; mobile: MobileRelease | null; android: boolean }) {
269+ const link = connectLink(site);
270+ const host = new URL(site).host;
271+ const apk = mobile?.downloads.find((download) => download.kind === "apk") ?? null;
272+ // The release's own address on g1t.sh, wherever this page is: the app is g1t's, served from g1t.sh.
273+ const apkUrl = apk && mobile ? `${HOSTED_ADDRESSES.site}/downloads/mobile/${mobile.version}/${apk.name}` : null;
274+ return (
275+ <section className="mt-14">
276+ <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
277+ <Smartphone size={18} className="text-accent" />
278+ On your phone
279+ </h2>
280+ <p className="mt-1 max-w-2xl text-sm text-muted">
281+ Chat with your team and its agents, live, with notifications for messages to you, mentions, replies and agents waiting on you;
282+ review and approve pull requests on the way. Android first, with iOS to follow.
283+ </p>
284+
285+ {apk && apkUrl && mobile ? (
286+ <Card className="mt-4 flex flex-col gap-5 p-5 sm:flex-row sm:items-center">
287+ {!android && (
288+ <div className="shrink-0">
289+ <QrCode value={apkUrl} label="Download the g1t app for Android" size={152} />
290+ </div>
291+ )}
292+ <div className="min-w-0">
293+ <p className="font-medium">g1t for Android, beta {mobile.version}</p>
294+ <p className="mt-1 text-sm text-muted">
295+ {android
296+ ? "Download it, open the file, and allow your browser to install apps when Android asks."
297+ : "Scan this with your Android phone's camera to download it there, then open the file and allow your browser to install apps when Android asks."}{" "}
298+ It installs as <span className="text-fg-soft">g1t (beta)</span>, and takes most updates by itself.
299+ </p>
300+ <ButtonLink to={apkUrl} reloadDocument className="mt-3">
301+ <Download size={16} />
302+ Download for Android{apk.size ? ` (${size(apk.size)})` : ""}
303+ </ButtonLink>
304+ <p className="mt-2 font-mono text-xs break-all text-faint">SHA-256 {apk.sha256}</p>
305+ </div>
306+ </Card>
307+ ) : hosted ? (
308+ <Card className="mt-4 p-5">
309+ <p className="font-medium">The first Android build is on its way.</p>
310+ <p className="mt-1 text-sm text-muted">It will be here to download as soon as it is published.</p>
311+ </Card>
312+ ) : (
313+ <Card className="mt-4 p-5">
314+ <p className="font-medium">Get the app from g1t.sh</p>
315+ <p className="mt-1 text-sm text-muted">
316+ The app is the same for every g1t: download it from{" "}
317+ <a href={`${HOSTED_ADDRESSES.site}/download`} className="text-accent hover:underline">
318+ g1t.sh/download
319+ </a>
320+ , then point it here with the code below.
321+ </p>
322+ </Card>
323+ )}
324+
325+ <div className="mt-4 rounded-xl border border-line bg-surface p-5 text-sm">
326+ <p className="font-medium text-fg">Why a download, and not Google Play or the App Store?</p>
327+ <p className="mt-1 text-muted">
328+ Both stores publish a company's apps only once the company is verified with a D-U-N-S number, and Flagon's is on its way. Until it
329+ arrives, the Android app is published {hosted ? "here" : "on g1t.sh"}, signed by us, as the desktop app is. Android asks once to allow installing it from your
330+ browser; that is the only difference. It moves into Google Play as soon as it can, and iOS follows through the App Store, which has no
331+ other way in.
332+ </p>
333+ </div>
334+
335+ {!hosted && (
336+ <Card className="mt-4 flex flex-col gap-5 p-5 sm:flex-row sm:items-center">
337+ <div className="shrink-0">
338+ <QrCode value={link} label={`Open the g1t app on ${host}`} size={152} />
339+ </div>
340+ <div className="min-w-0">
341+ <p className="font-medium">Use the app with {host}</p>
342+ <p className="mt-1 text-sm text-muted">
343+ 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
344+ 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
345+ the address typed.
346+ </p>
347+ <ButtonLink to={link} reloadDocument variant="secondary" className="mt-3">
348+ <Smartphone size={16} />
349+ Open in the g1t app
350+ </ButtonLink>
351+ </div>
352+ </Card>
353+ )}
354+
355+ <p className="mt-4 text-sm text-muted">
356+ How it works, and what it does with a g1t of your own, is in{" "}
357+ <a href="https://docs.g1t.sh/guides/mobile/" className="text-accent hover:underline">
358+ the phone app guide
359+ </a>
360+ .
361+ </p>
362+ </section>
363+ );
364+}
+7−3
66 * Releases: `g1t.sh/downloads/<tool>/<version>/<file>`, served from the
77 * g1t-downloads R2 bucket. `runner` is the self-hosted runner
88 * (scripts/runner-release.mjs), `cli` the g1t CLI (scripts/cli-release.mjs),
9− * `desktop` the desktop app (scripts/desktop-release.mjs). `latest/<file>`
9+ * `desktop` the desktop app (scripts/desktop-release.mjs), `mobile` the
10+ * phone app's Android APK (scripts/mobile-release.mjs). `latest/<file>`
1011 * is the newest release's, as its `latest.json` names it; the runner's
1112 * `latest.json` and `latest.json.sig` are what runners check before
1213 * updating, and the desktop app's `latest.json` names each platform's
1314 * installer with its signature, as the app's updater reads it.
1415 */
15−const TOOLS = new Set(["runner", "cli", "desktop"]);
16+const TOOLS = new Set(["runner", "cli", "desktop", "mobile"]);
1617 const TYPES: Record<string, string> = {
1718 json: "application/json",
1819 sig: "text/plain; charset=utf-8",
1920 txt: "text/plain; charset=utf-8",
21+ apk: "application/vnd.android.package-archive",
2022 };
23+/** Typed files that are still downloaded, not shown: an APK is installed from the phone's downloads. */
24+const SAVED = new Set(["apk"]);
2125
2226 async function object(key: string): Promise<R2ObjectBody | null> {
2327 const bucket = env.DOWNLOADS;
4852 // A version's files never change; what `latest` points to does.
4953 "cache-control": path.startsWith("latest") ? "public, max-age=300" : "public, max-age=31536000, immutable",
5054 });
51− if (!TYPES[extension]) headers.set("content-disposition", `attachment; filename="${name}"`);
55+ if (!TYPES[extension] || SAVED.has(extension)) headers.set("content-disposition", `attachment; filename="${name}"`);
5256 return new Response(found.body, { headers });
5357 }
+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+}
+4−2
300300 use std::process::Command;
301301 use std::time::{Duration, Instant};
302302
303− let api = Api { base: "http://127.0.0.1:9".into(), job: "job_1".into(), token: "t".into() };
304− let mut log = Log::new(api, Vec::new());
303+ // Offline: on a busy machine the step can outlast a batch of the log,
304+ // and sending it to an address that is not there held the loop for
305+ // seconds, past the grace this test measures.
306+ let mut log = Log::new(Api::offline(), Vec::new());
305307 let mut command = Command::new("sh");
306308 command.args(["-c", "trap 'echo cleaned up; exit 3' INT; echo started; while true; do sleep 0.1; done"]);
307309 let began = Instant::now();
+23−4
103103 }
104104 }
105105
106+ /// An API with no address, which sends nothing: what the tests use, so
107+ /// a step's run is not held up by reports that go nowhere (each one
108+ /// tried three times, with waits between, over a second and a half).
109+ #[cfg(test)]
110+ pub(crate) fn offline() -> Api {
111+ Api { base: String::new(), job: "job_1".into(), token: "t".into() }
112+ }
113+
106114 pub(crate) fn report(&self, report: Value) {
115+ // No address: nowhere to send it (Api::offline).
116+ if self.base.is_empty() {
117+ return;
118+ }
107119 // A report that cannot be sent is tried a few times, then dropped:
108120 // the job goes on, and g1t notices a silent job by itself.
109121 for attempt in 0..3 {
135147 last: Instant,
136148 /// When anything was last sent, for the ping.
137149 sent: Instant,
150+ /// Everything sent so far, so a test reads the whole log however its
151+ /// batches fell (`buffered`).
152+ #[cfg(test)]
153+ flushed: String,
138154 }
139155
140156 impl Log {
148164 buffer: String::new(),
149165 last: Instant::now(),
150166 sent: Instant::now(),
167+ #[cfg(test)]
168+ flushed: String::new(),
151169 }
152170 }
153171
154− /// What waits to be sent.
172+ /// The log so far: what was sent and what waits to be.
155173 #[cfg(all(test, unix))]
156174 pub(crate) fn buffered(&self) -> String {
157− self.buffer.clone()
175+ format!("{}{}", self.flushed, self.buffer)
158176 }
159177
160178 /// Starts writing to step `number` (0 for the job's setup).
210228 }
211229 self.sent = Instant::now();
212230 let text = std::mem::take(&mut self.buffer);
231+ #[cfg(test)]
232+ self.flushed.push_str(&text);
213233 self.api.report(json!({ "kind": "log", "step": self.step, "text": text }));
214234 }
215235
286306 use super::*;
287307
288308 fn log(secrets: &[&str]) -> Log {
289− let api = Api { base: "http://127.0.0.1:9".into(), job: "job_1".into(), token: "t".into() };
290− Log::new(api, mask::all_variants(secrets.iter().copied()))
309+ Log::new(Api::offline(), mask::all_variants(secrets.iter().copied()))
291310 }
292311
293312 #[test]
+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
+0−0

Binary or large file; its contents are not shown.

+2−2
33 "private": true,
44 "type": "module",
55 "engines": {
6− "node": ">=22.22.0"
6+ "node": ">=24"
77 },
88 "workspaces": [
99 "apps/*",
1515 "deploy": "npm run deploy -w @g1t/web",
1616 "test:deploy": "node --test \"scripts/deploy/*.test.mjs\"",
1717 "test:ops": "node --test \"scripts/ops/*.test.mjs\"",
18− "test:release": "node --test \"scripts/desktop-release.test.mjs\""
18+ "test:release": "node --test \"scripts/desktop-release.test.mjs\" \"scripts/mobile-release.test.mjs\""
1919 },
2020 "devDependencies": {
2121 "typescript": "^5.9.3",
+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>;
+173−0
1+#!/usr/bin/env node
2+// Releases of the phone app (apps/mobile) for Android, published on
3+// g1t.sh/download while the app is not in Google Play: built by EAS from
4+// the `preview` profile (g1t (beta), sh.g1t.app.preview, a signed release
5+// APK that needs no computer), downloaded, described in a manifest, and
6+// published to the g1t-downloads R2 bucket that g1t.sh serves at
7+// /downloads/mobile/ (apps/web/app/routes/downloads-runner.ts). The
8+// download page lists the newest (apps/web/app/routes/download.tsx).
9+//
10+// Google Play and the App Store publish an organization's apps only once
11+// it is verified with a D-U-N-S number; until Flagon's is, this is how the
12+// app is given out. Installed copies take most changes over the air
13+// (expo-updates, the `preview` channel: `eas update --channel preview`);
14+// a release here is for what needs a new install.
15+//
16+// node scripts/mobile-release.mjs build # EAS builds the APK (about 15 minutes)
17+// node scripts/mobile-release.mjs fetch [--build <id>] # downloads the newest finished preview build, or that one
18+// node scripts/mobile-release.mjs manifest [--notes "…"] # latest.json and SHA256SUMS from what was fetched
19+// node scripts/mobile-release.mjs publish [--dry-run]
20+//
21+// What a release is, at mobile/<version>/ in the bucket:
22+//
23+// g1t-<version>-android.apk the app, signed with the upload key EAS keeps
24+// SHA256SUMS `sha256 name`
25+// manifest.json { version, build, pub_date, notes, downloads }
26+//
27+// and at mobile/: latest.json, the newest release's manifest. `version` is
28+// apps/mobile/app.config.ts's, `build` the Android versionCode EAS gave it.
29+//
30+// Environment:
31+// EXPO_TOKEN for build and fetch (an Expo access token, from expo.dev)
32+// CLOUDFLARE_API_TOKEN for publish
33+import { spawnSync } from "node:child_process";
34+import { createHash } from "node:crypto";
35+import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
36+import { dirname, join } from "node:path";
37+import { fileURLToPath } from "node:url";
38+
39+const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
40+const APP = join(ROOT, "apps/mobile");
41+const BUCKET = "g1t-downloads";
42+/** The EAS command line, pinned so a release does not change with its newest version. */
43+const EAS = ["--yes", "eas-cli@24.12.1"];
44+const PROFILE = "preview";
45+
46+/** The app's version, as app.config.ts says it. */
47+export function version() {
48+ const config = readFileSync(join(APP, "app.config.ts"), "utf8");
49+ const found = /^\s*version:\s*"([^"]+)"/m.exec(config);
50+ if (!found) throw new Error("no version in apps/mobile/app.config.ts");
51+ return found[1];
52+}
53+
54+/** What a release's APK is called. */
55+export function apkName(v) {
56+ return `g1t-${v}-android.apk`;
57+}
58+
59+const outDir = (v) => join(ROOT, "target/mobile-release", v);
60+
61+/**
62+ * The manifest for a release folder's files: each one's size and SHA-256,
63+ * and SHA256SUMS as `sha256sum` writes it.
64+ */
65+export function manifestFor({ version: v, build, notes, files, now = new Date() }) {
66+ const downloads = files.map((file) => ({ platform: "android", kind: "apk", name: file.name, size: file.size, sha256: file.sha256 }));
67+ return {
68+ manifest: { version: v, build, pub_date: now.toISOString(), notes: notes || undefined, downloads },
69+ sums: files.map((file) => `${file.sha256} ${file.name}`).join("\n") + "\n",
70+ };
71+}
72+
73+/** The newest finished build, from `eas build:list --json`, or null. */
74+export function newestBuild(list) {
75+ const finished = (Array.isArray(list) ? list : []).filter((build) => build?.status === "FINISHED" && build?.artifacts?.buildUrl);
76+ finished.sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")));
77+ return finished[0] ?? null;
78+}
79+
80+function run(command, args, options = {}) {
81+ const result = spawnSync(command, args, { stdio: options.capture ? ["ignore", "pipe", "inherit"] : "inherit", encoding: "utf8", ...options });
82+ if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed`);
83+ return result.stdout ?? "";
84+}
85+
86+function build() {
87+ run("npx", [...EAS, "build", "--platform", "android", "--profile", PROFILE, "--non-interactive", "--wait"], { cwd: APP });
88+}
89+
90+async function fetchBuild(id) {
91+ const v = version();
92+ const args = id
93+ ? ["build:view", id, "--json"]
94+ : ["build:list", "--platform", "android", "--build-profile", PROFILE, "--status", "finished", "--limit", "5", "--json", "--non-interactive"];
95+ const answer = JSON.parse(run("npx", [...EAS, ...args], { cwd: APP, capture: true }));
96+ const chosen = id ? answer : newestBuild(answer);
97+ if (!chosen?.artifacts?.buildUrl) throw new Error("no finished preview build: run build first");
98+ if (chosen.appVersion && chosen.appVersion !== v) {
99+ throw new Error(`the build is version ${chosen.appVersion}, but apps/mobile is ${v}`);
100+ }
101+ const response = await fetch(chosen.artifacts.buildUrl);
102+ if (!response.ok) throw new Error(`downloading the APK failed: ${response.status}`);
103+ const dir = outDir(v);
104+ rmSync(dir, { recursive: true, force: true });
105+ mkdirSync(dir, { recursive: true });
106+ writeFileSync(join(dir, apkName(v)), Buffer.from(await response.arrayBuffer()));
107+ writeFileSync(join(dir, "build.json"), JSON.stringify({ id: chosen.id, build: chosen.appBuildVersion ?? null }, null, 2));
108+ console.log(`fetched build ${chosen.id} (versionCode ${chosen.appBuildVersion ?? "?"}) into ${dir}`);
109+}
110+
111+function writeManifest(notes) {
112+ const v = version();
113+ const dir = outDir(v);
114+ const apk = join(dir, apkName(v));
115+ if (!existsSync(apk)) throw new Error("no APK: run fetch first");
116+ const meta = existsSync(join(dir, "build.json")) ? JSON.parse(readFileSync(join(dir, "build.json"), "utf8")) : {};
117+ const bytes = readFileSync(apk);
118+ const files = [{ name: apkName(v), size: statSync(apk).size, sha256: createHash("sha256").update(bytes).digest("hex") }];
119+ const { manifest, sums } = manifestFor({ version: v, build: meta.build ?? null, notes, files });
120+ writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2));
121+ writeFileSync(join(dir, "latest.json"), JSON.stringify(manifest, null, 2));
122+ writeFileSync(join(dir, "SHA256SUMS"), sums);
123+ console.log(`${apkName(v)}: ${files[0].size} bytes, sha256 ${files[0].sha256}`);
124+}
125+
126+function publish(dryRun) {
127+ const v = version();
128+ const dir = outDir(v);
129+ if (!existsSync(join(dir, "latest.json"))) throw new Error("no latest.json: run manifest first");
130+ const put = (key, file, type) => {
131+ const args = ["wrangler", "r2", "object", "put", `${BUCKET}/${key}`, "--file", file, "--remote", "--content-type", type];
132+ if (dryRun) console.log(`would put ${key}`);
133+ else run("npx", args, { cwd: join(ROOT, "apps/web") });
134+ };
135+ const type = (name) =>
136+ name.endsWith(".apk") ? "application/vnd.android.package-archive" : name.endsWith(".json") ? "application/json" : "text/plain";
137+ // The version's files first; latest.json last, so the page never offers a file that is not there yet.
138+ for (const name of readdirSync(dir)) {
139+ if (name === "latest.json" || name === "build.json") continue;
140+ put(`mobile/${v}/${name}`, join(dir, name), type(name));
141+ }
142+ put("mobile/latest.json", join(dir, "latest.json"), "application/json");
143+}
144+
145+if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/mobile-release.mjs")) {
146+ const [command, ...rest] = process.argv.slice(2);
147+ const option = (name) => {
148+ const at = rest.indexOf(`--${name}`);
149+ return at >= 0 ? rest[at + 1] : undefined;
150+ };
151+ try {
152+ switch (command) {
153+ case "build":
154+ build();
155+ break;
156+ case "fetch":
157+ await fetchBuild(option("build"));
158+ break;
159+ case "manifest":
160+ writeManifest(option("notes") ?? "");
161+ break;
162+ case "publish":
163+ publish(rest.includes("--dry-run"));
164+ break;
165+ default:
166+ console.error("usage: node scripts/mobile-release.mjs build|fetch|manifest|publish");
167+ process.exit(2);
168+ }
169+ } catch (error) {
170+ console.error(error instanceof Error ? error.message : error);
171+ process.exit(1);
172+ }
173+}
+38−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { apkName, manifestFor, newestBuild, version } from "./mobile-release.mjs";
5+
6+test("the version is the app's, and the APK is named by it", () => {
7+ assert.match(version(), /^\d+\.\d+\.\d+$/);
8+ assert.equal(apkName("0.2.0"), "g1t-0.2.0-android.apk");
9+});
10+
11+test("the manifest lists the APK with its size and SHA-256, and SHA256SUMS matches", () => {
12+ const { manifest, sums } = manifestFor({
13+ version: "0.2.0",
14+ build: 7,
15+ notes: "",
16+ files: [{ name: "g1t-0.2.0-android.apk", size: 42, sha256: "ab".repeat(32) }],
17+ now: new Date("2026-10-11T00:00:00Z"),
18+ });
19+ assert.deepEqual(manifest, {
20+ version: "0.2.0",
21+ build: 7,
22+ pub_date: "2026-10-11T00:00:00.000Z",
23+ notes: undefined,
24+ downloads: [{ platform: "android", kind: "apk", name: "g1t-0.2.0-android.apk", size: 42, sha256: "ab".repeat(32) }],
25+ });
26+ assert.equal(sums, `${"ab".repeat(32)} g1t-0.2.0-android.apk\n`);
27+});
28+
29+test("the newest finished build with an APK is the one fetched", () => {
30+ const list = [
31+ { id: "a", status: "FINISHED", createdAt: "2026-10-10T00:00:00Z", artifacts: { buildUrl: "https://x/a.apk" } },
32+ { id: "b", status: "ERRORED", createdAt: "2026-10-12T00:00:00Z", artifacts: {} },
33+ { id: "c", status: "FINISHED", createdAt: "2026-10-11T00:00:00Z", artifacts: { buildUrl: "https://x/c.apk" } },
34+ ];
35+ assert.equal(newestBuild(list)?.id, "c");
36+ assert.equal(newestBuild([]), null);
37+ assert.equal(newestBuild(null), null);
38+});
+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(