The phone app is given out on g1t.sh/download until it is in Google Play, and g1t is built on Node 24.
Google Play and the App Store publish a company's apps only once it is verified with a D-U-N-S number, which Flagon is waiting on. Until then the download page's On your phone offers the Android app's newest release, the preview build (g1t (beta), a signed release APK that needs no computer): Download for Android, a QR code to fetch it from a computer, its SHA-256, how Android asks to allow the install, and why it is a download and not a store. A self-hosted g1t's page sends people to g1t.sh/download for the app, beside its own connect code. A release is a tag mobile-v<version>: .g1t/workflows/mobile-release.yml has EAS build the preview APK and publishes it to g1t.sh/downloads/mobile/<version>/ with mobile/latest.json last, which the page reads; scripts/mobile-release.mjs does the same by hand (build, fetch, manifest, publish), with tests, in CI beside the desktop's. The downloads route serves the APK as one, saved rather than shown. expo-updates is in, on the preview channel, so installed copies take most changes over the air (eas update) with no new install, and preview builds count their own versionCode so each release installs over the last. Node 24 is the version: .nvmrc, engines (>=24, as the self-hosted image already ran), EAS builds on 24.21.0, and the README, deploying and the app's README say so. Every check was run on 24.21.0 from a clean install. The download page's SHA-256 is shown whole rather than in a native title, as the site's hints rule asks. The phone app guide says how to install it, why it is a download, how updates arrive and how a release is made; CONTRIBUTING's Deploying has the phone app beside the desktop app.
- Claude-Session:
- https://claude.ai/code/session_01MQE64a4WNN14F8GELCiuc9
| 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 |
| 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 |
| 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 |
| 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. | |
| 16 | 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. | |
| 37 | + | ||
| 17 | 38 | The bar along the bottom is the site's phone bar: | |
| 18 | 39 | ||
| 19 | 40 | | Tab | What is on it | | |
| ⋯ | |||
| 141 | 162 | ||
| 142 | 163 | ## What's not there yet | |
| 143 | 164 | ||
| 144 | − | - **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. | |
| 145 | 168 | - **Home, Notifications, Agents and Artifacts** as tabs of their own. | |
| 146 | 169 | Their pages open on the site meanwhile. | |
| 147 | 170 | - **Formatting in Code.** Descriptions and comments show their Markdown | |
| ⋯ | |||
| 167 | 190 | ||
| 168 | 191 | The three install side by side. `eas submit --platform android` sends | |
| 169 | 192 | the latest production build to Google Play's internal testing track as a | |
| 170 | − | 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. | |
| 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, | |
| 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", |
| 41 | 41 | ||
| 42 | 42 | type Platform = DesktopDownload["platform"]; | |
| 43 | 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 | + | ||
| 44 | 66 | /** Which platform a browser says it is on; null when it does not say. */ | |
| 45 | 67 | export function platformOf(userAgent: string | null): Platform | null { | |
| 46 | 68 | if (!userAgent) return null; | |
| ⋯ | |||
| 71 | 93 | } catch (error) { | |
| 72 | 94 | console.error("download: the release could not be read", error); | |
| 73 | 95 | } | |
| 96 | + | const mobile = await readJson<MobileRelease>(bucket, "mobile/latest.json", (read) => typeof read.version === "string" && Array.isArray(read.downloads)); | |
| 74 | 97 | const { site } = addresses(); | |
| 75 | − | return { release, platform: platformOf(request.headers.get("user-agent")), site, hosted: site === HOSTED_ADDRESSES.site }; | |
| 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 | + | }; | |
| 76 | 107 | } | |
| 77 | 108 | ||
| 78 | 109 | const PLATFORMS: { id: Platform; name: string; icon: ReactNode; note: string }[] = [ | |
| ⋯ | |||
| 107 | 138 | ]; | |
| 108 | 139 | ||
| 109 | 140 | export default function DownloadPage() { | |
| 110 | − | const { release, platform, site, hosted } = useLoaderData<typeof loader>(); | |
| 141 | + | const { release, platform, site, hosted, mobile, android } = useLoaderData<typeof loader>(); | |
| 111 | 142 | const ordered = platform ? [...PLATFORMS.filter((p) => p.id === platform), ...PLATFORMS.filter((p) => p.id !== platform)] : PLATFORMS; | |
| 112 | 143 | const yours = platform && release ? release.downloads.filter((d) => d.platform === platform) : []; | |
| 113 | 144 | const featured = yours.find((d) => d.kind === "installer" || d.kind === "dmg" || d.kind === "appimage") ?? yours[0] ?? null; | |
| ⋯ | |||
| 220 | 251 | </p> | |
| 221 | 252 | </section> | |
| 222 | 253 | )} | |
| 223 | − | <PhoneApp site={site} hosted={hosted} /> | |
| 254 | + | <PhoneApp site={site} hosted={hosted} mobile={mobile} android={android} /> | |
| 224 | 255 | </div> | |
| 225 | 256 | </TrustPage> | |
| 226 | 257 | ); | |
| ⋯ | |||
| 228 | 259 | ||
| 229 | 260 | /** | |
| 230 | 261 | * The phone app: Chat with people and agents, live, and notifications on | |
| 231 | − | * the phone. On a g1t of your own, a link and its QR code point the app | |
| 232 | − | * here, so nobody types the address; on g1t.sh the app starts here anyway. | |
| 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. | |
| 233 | 267 | */ | |
| 234 | − | function PhoneApp({ site, hosted }: { site: string; hosted: boolean }) { | |
| 268 | + | function PhoneApp({ site, hosted, mobile, android }: { site: string; hosted: boolean; mobile: MobileRelease | null; android: boolean }) { | |
| 235 | 269 | const link = connectLink(site); | |
| 236 | 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; | |
| 237 | 274 | return ( | |
| 238 | 275 | <section className="mt-14"> | |
| 239 | 276 | <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight"> | |
| ⋯ | |||
| 242 | 279 | </h2> | |
| 243 | 280 | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 244 | 281 | Chat with your team and its agents, live, with notifications for messages to you, mentions, replies and agents waiting on you; | |
| 245 | − | review and approve pull requests on the way. Android comes first; it is in testing now, and the stores are next. | |
| 282 | + | review and approve pull requests on the way. Android first, with iOS to follow. | |
| 246 | 283 | </p> | |
| 247 | − | {hosted ? ( | |
| 248 | − | <p className="mt-4 text-sm text-muted"> | |
| 249 | − | How it works, and how to build it yourself meanwhile, is in{" "} | |
| 250 | − | <a href="https://docs.g1t.sh/guides/mobile/" className="text-accent hover:underline"> | |
| 251 | − | the phone app guide | |
| 252 | − | </a> | |
| 253 | − | . | |
| 254 | − | </p> | |
| 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> | |
| 255 | 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 && ( | |
| 256 | 336 | <Card className="mt-4 flex flex-col gap-5 p-5 sm:flex-row sm:items-center"> | |
| 257 | − | <QrCode value={link} label={`Open the g1t app on ${host}`} size={152} /> | |
| 337 | + | <div className="shrink-0"> | |
| 338 | + | <QrCode value={link} label={`Open the g1t app on ${host}`} size={152} /> | |
| 339 | + | </div> | |
| 258 | 340 | <div className="min-w-0"> | |
| 259 | 341 | <p className="font-medium">Use the app with {host}</p> | |
| 260 | 342 | <p className="mt-1 text-sm text-muted"> | |
| ⋯ | |||
| 269 | 351 | </div> | |
| 270 | 352 | </Card> | |
| 271 | 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> | |
| 272 | 362 | </section> | |
| 273 | 363 | ); | |
| 274 | 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 | } | |
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", | |
| 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 | + | }); |