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