diff --git a/.gitea/workflows/android-build.yaml b/.gitea/workflows/android-build.yaml new file mode 100644 index 0000000..8eb9463 --- /dev/null +++ b/.gitea/workflows/android-build.yaml @@ -0,0 +1,172 @@ +# Manual signed-APK build for the standalone Android app (RuStore). Runs ONLY from master, ONLY on +# workflow_dispatch with confirm=build — the same deliberate-manual shape as prod-deploy.yaml, never on +# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and +# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP). +# +# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without +# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline). +# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook) and +# ANDROID_PLAN.md §E. The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the +# runner host needs nothing pre-installed. +name: android-build +run-name: "android build ${{ github.sha }}" + +on: + workflow_dispatch: + inputs: + confirm: + description: 'Type "build" to confirm an APK build from master.' + required: true + default: "" + +permissions: + contents: read + +env: + NO_COLOR: "1" + # The dictionary release, one source of truth (same Gitea variable the backend image + CI use). It + # both fetches the DAWGs and labels the bundled files (VITE_DICT_VERSION must equal __DICT_VERSION__). + DICT_VERSION: ${{ vars.DICT_VERSION }} + VITE_DICT_VERSION: ${{ vars.DICT_VERSION }} + # Hide in-app purchases in the RuStore MVP (RuStore, not Google Play — VITE_GP_BUILD stays unset). + VITE_PAYMENTS_DISABLED: "1" + # The update overlay's store target; empty until publication (the button no-ops, and the version gate + # is dormant in the MVP so it never fires). Set the ANDROID_RUSTORE_URL variable when the app is live. + VITE_RUSTORE_URL: ${{ vars.ANDROID_RUSTORE_URL }} + # Host-executor runner: the Android SDK is pre-installed on the host. Override ANDROID_SDK_DIR if it + # lives elsewhere (the default matches deploy/README.md's install path). + ANDROID_HOME: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }} + ANDROID_SDK_ROOT: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }} + +jobs: + build: + if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'build' }} + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Host-executor runner: the Android SDK is pre-installed on the host (JDK 21 comes from setup-java + # below). Fail fast + legibly if the runner user cannot read/execute it or a needed package is + # missing — this doubles as the runner-access check (deploy/README.md). + - name: Verify the host Android SDK + run: | + sm="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + if [ ! -x "$sm" ]; then + echo "::error::sdkmanager not found/executable at $sm — set the ANDROID_SDK_DIR variable if the SDK lives elsewhere, or grant the runner user read+exec: sudo chmod -R a+rX \"$ANDROID_HOME\""; exit 1 + fi + for pkg in "platforms/android-36" "build-tools"; do + if [ ! -d "$ANDROID_HOME/$pkg" ]; then + echo "::error::missing $ANDROID_HOME/$pkg — run: \"$sm\" 'platforms;android-36' 'build-tools;36.0.0'"; exit 1 + fi + done + echo "Android SDK OK at $ANDROID_HOME"; "$sm" --version + + - name: Compute version + native build env + id: prep + env: + PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }} + run: | + # A store release must sit on an exact vMAJOR.MINOR.PATCH tag (G tags before dispatch) so the + # versionCode is deterministic and strictly increasing across uploads. Refuse anything else + # rather than derive a versionCode from a "-N-gSHA" describe. + desc="$(git describe --tags --exact-match 2>/dev/null || true)" + case "$desc" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "::error::HEAD is not on a clean vX.Y.Z tag (git describe --exact-match = '${desc:-none}'); tag the release first"; exit 1 ;; + esac + v="${desc#v}" + IFS=. read -r MA MI PA <<< "$v" + # 10# forces base-10 so a zero-padded part is never read as octal. + code=$(( 10#$MA * 1000000 + 10#$MI * 1000 + 10#$PA )) + # The native SPA talks to the production origin (reuse the prod public base URL); strip any + # trailing slash so the Connect endpoint never doubles it. + gateway="${PUBLIC_BASE_URL%/}" + { + echo "tag=$desc" + echo "name=$v" + echo "code=$code" + echo "gateway=$gateway" + } >> "$GITHUB_OUTPUT" + echo "release $desc -> versionName $v versionCode $code, gateway $gateway" + + # Same release + fetch as the Go/UI jobs in ci.yaml — the bundled dicts come from this tarball, + # NOT the scrabble-solver sibling (ui is a Node project outside go.work). + - name: Fetch dictionary DAWGs + run: | + mkdir -p "${GITHUB_WORKSPACE}/dawg" + curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz" + tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg" + ls -la "${GITHUB_WORKSPACE}/dawg" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install pnpm + run: npm install -g pnpm@11.0.9 + + - name: Install deps + working-directory: ui + run: pnpm install --frozen-lockfile + + - name: Build the SPA (native flavour) + working-directory: ui + env: + VITE_GATEWAY_URL: ${{ steps.prep.outputs.gateway }} + VITE_APP_VERSION: ${{ steps.prep.outputs.tag }} + run: pnpm run build + + # Copy the release DAWGs into dist/dict/@.dawg for the offline-first bundled tier + # (after the build, before cap sync copies dist/ into the native assets). + - name: Bundle the dictionaries + working-directory: ui + env: + DICT_DIR: ${{ github.workspace }}/dawg + run: node scripts/bundle-dicts.mjs + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + # Local cap binary (dodges the corepack pre-flight flake); syncs dist/ (incl. dict/) + native deps. + - name: Sync the native project + working-directory: ui + run: node_modules/.bin/cap sync android + + - name: Decode the release keystore + id: keystore + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks" + echo "file=${GITHUB_WORKSPACE}/release.jks" >> "$GITHUB_OUTPUT" + echo "keystore decoded -> signed release build" + else + echo "file=" >> "$GITHUB_OUTPUT" + echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)" + fi + + - name: Assemble the release APK + working-directory: ui/android + env: + ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain + + - name: Upload the APK artifact + uses: actions/upload-artifact@v4 + with: + name: erudit-${{ steps.prep.outputs.name }}-apk + path: ui/android/app/build/outputs/apk/release/*.apk + if-no-files-found: error diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md index 7b05b6d..fb6b6f9 100644 --- a/ANDROID_PLAN.md +++ b/ANDROID_PLAN.md @@ -86,7 +86,7 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif gate in `connectsrv` — `Execute` returns `result_code="update_required"` before the registry lookup, `Subscribe` returns `FailedPrecondition`; fail-open on an absent/garbled header. Client: `X-Client-Version` on every call (`transport.ts headers()`), a terminal `update.svelte.ts` store + - `UpdateOverlay.svelte` (native → `VITE_STORE_URL`, web → reload), `retry.ts` maps + `UpdateOverlay.svelte` (native → `VITE_RUSTORE_URL`, web → reload), `retry.ts` maps `FailedPrecondition → update_required`, the `__update` mock hook. `gofmt`/`vet` clean, Go `clientver`/config/`connectsrv` tests green, `svelte-check` 0, `vitest` 591, e2e 232 (incl. `update.spec.ts`), client build clean. Silent reconciliation seam deferred to D (owner); in-app @@ -135,7 +135,35 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif flow, so the behaviour **must change** — a native guest should still see / resume their local games when online. Owner agreed it needs changing but wants to design it **at the very end of the Android work (before release — see §G)**; out of D.3/D.4 scope, tracked here so it is not lost. -- **E–G — pending.** +- **E. CI — signed APK artifact — ✅ CODE-COMPLETE & locally-proven (2026-07-12).** New manual workflow + `.gitea/workflows/android-build.yaml` (`workflow_dispatch` + `confirm=build`, `if` gated to `master` — + mirrors `prod-deploy.yaml`; never on a PR) builds the native-flavoured SPA, bundles the offline dicts, and + assembles a release APK uploaded as a run artifact. Like `prod-deploy`, it is **provable only by a dispatch + from `master`** (that is §G), so E was verified as far as possible now: locally via `assembleDebug` + a + **keyless** `assembleRelease` (→ `app-release-unsigned.apk`, `versionCode 1017000` / `versionName 1.17.0` + for a `v1.17.0` tag — confirmed in `output-metadata.json`), `svelte-check` 0, YAML structure checked. + As-built: + - **`ui/android/app/build.gradle`:** `versionCode`/`versionName` read `-PversionCode`/`-PversionName` (defaults + keep local `assembleDebug` building). **CORRECTION vs the plan snippet — use `=` assignment** + (`versionCode = (…).toInteger()`), NOT the command form `versionCode (…).toInteger()`: the latter binds + `.toInteger()` to the DSL setter's null return (`> Value is null`). A guarded `signingConfigs.release` reads + the keystore from env and is attached **only when the keystore file exists** — so **no keystore ⇒ an UNSIGNED + release APK, not a failure** (a dispatch proves the pipeline before the keystore exists). + - **Toolchain:** the **CI runner is a host-executor on a remote Debian host**. JDK 21 comes from + `actions/setup-java`; the **Android SDK is host-provisioned** (pre-installed at `ANDROID_SDK_DIR`, default + `/opt/android-sdk`), with a fail-fast **Verify the host Android SDK** pre-flight that also checks the `runner` + user's read/exec access (`platforms;android-36` + `build-tools`). Unit tests need no new tooling (they ride + the existing `unit`/`ui` jobs). + - **Env:** `VITE_GATEWAY_URL` reuses `vars.PROD_PUBLIC_BASE_URL` (trailing slash stripped); `VITE_DICT_VERSION` / + `DICT_VERSION` = the `DICT_VERSION` var; `VITE_PAYMENTS_DISABLED=1`. **`VITE_STORE_URL` was renamed to + `VITE_RUSTORE_URL`** (owner) — left **empty** via the (unset) `vars.ANDROID_RUSTORE_URL`, so the update button + no-ops until publication (`UpdateOverlay.svelte` already guards `if (url)`; the gate is dormant in the MVP). + - **versionCode from an exact tag:** the workflow refuses anything but a clean `git describe --exact-match` + `vX.Y.Z` (deterministic + monotonic store versionCode); §G tags before dispatch. + - **Delivery:** `actions/upload-artifact@v4` (assumes Gitea 1.26 artifact support — verify at the §G dispatch). + Secrets to set before a **signed** build (§G / publication): `ANDROID_KEYSTORE_BASE64`, + `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. +- **F–G — pending.** Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate *every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today @@ -334,7 +362,7 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r `wallet.purchasesSoon`, `data-testid="purchases-hidden"` — not an empty tab and not a store link; the same note can replace the RuStore stub in the later Google Play anti-steering variant. `purchasesHidden()` also carries a mock-only `?nopay` force (mirrors `?gp`) so the e2e drives the - state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`, + state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_RUSTORE_URL?: string`, `VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`. 4. **Native env matrix** — see Build & env. @@ -426,7 +454,7 @@ purchase actions are absent; `pnpm check` + `pnpm test:unit` pass. `case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`. - **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable; shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) → - `window.open(import.meta.env.VITE_STORE_URL, '_system')`; web → `location.reload()`. i18n keys + `window.open(import.meta.env.VITE_RUSTORE_URL, '_system')`; web → `location.reload()`. i18n keys `update.title/body/action` (add siblings to the maintenance keys). - **`ui/src/App.svelte`**: import + place `` right after `` (line 139). - **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`. @@ -585,7 +613,7 @@ silently establishes a server guest (Profile + online play light up). Emulator s → "Native Android build", with the extra `DICT_DIR= VITE_DICT_VERSION= node scripts/bundle-dicts.mjs` between `pnpm build` and `cap sync`. -### E. CI — signed APK artifact +### E. CI — signed APK artifact — ✅ CODE-COMPLETE (workflow written + `build.gradle` wired + locally proven; the signed dispatch is §G) New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-deploy.yaml`'s `workflow_dispatch` + `confirm` gate; from `master`; not on PRs). Steps: @@ -603,6 +631,9 @@ New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-depl 5. Setup JDK 21 (`actions/setup-java@v4`, temurin 21). 6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-36`, `build-tools;36.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual. + *As built:* the runner is a **host-executor** on a remote Debian host, so the **SDK is host-provisioned** + (`ANDROID_SDK_DIR` var, default `/opt/android-sdk`) and a **Verify the host Android SDK** step fail-fasts on a + missing package / no runner-user access; JDK 21 still comes from `actions/setup-java`. 7. `npx cap sync android`. 8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the tag (scheme below). @@ -617,9 +648,12 @@ for the MVP. **`versionCode`/`versionName` scheme** (deterministic from the tag): `v{MA}.{MI}.{PA}` → `versionCode = MA*1_000_000 + MI*1_000 + PA` (e.g. `v1.17.0` → `1017000`), `versionName = "{MA}.{MI}.{PA}"`. Wire in `ui/android/app/build.gradle` `defaultConfig`: -`versionCode (project.findProperty('versionCode') ?: '1').toInteger()`, -`versionName (project.findProperty('versionName') ?: '0.0.0')`, plus a `signingConfigs.release` -reading env/props (guarded so a local `assembleDebug` still builds unsigned). +`versionCode = (project.findProperty('versionCode') ?: '1').toInteger()`, +`versionName = (project.findProperty('versionName') ?: '0.0.0').toString()`, plus a guarded +`signingConfigs.release` reading env/props. **Use `=` assignment, not the command form** +`versionCode (…).toInteger()` — the latter binds `.toInteger()` to the DSL setter's null return +(`> Value is null`). The signing block attaches only when `ANDROID_KEYSTORE_FILE` exists, so a keyless +`assembleRelease` (and every `assembleDebug`) builds UNSIGNED instead of failing. ### F. Docs (bake in the same PR) @@ -634,8 +668,11 @@ reading env/props (guarded so a local `assembleDebug` still builds unsigned). test, and the **manual on-device Android smoke checklist** (installs; airplane-mode cold launch to guest lobby; local vs_ai move; 2-player hotseat; Back button; share link resolves to the gateway; network on → online lights up; overlay when `GATEWAY_MIN_CLIENT_VERSION` is bumped). -- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_STORE_URL`, - `VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value. +- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_RUSTORE_URL`, + `VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value. Gitea CI vars/secrets + for the APK build: `ANDROID_RUSTORE_URL` (var, empty until publish), `PROD_PUBLIC_BASE_URL` (reused as the + gateway origin), and the release-signing secrets `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / + `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`. - `deploy/README.md`: the Android build/release runbook (keystore + secrets, dispatch, RuStore upload) **and** the discipline rule — bump `GATEWAY_MIN_CLIENT_VERSION` in the same prod deploy that ships an incompatible wire change. @@ -675,8 +712,10 @@ target, built later. ## Build & env matrix **Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`): -- `VITE_GATEWAY_URL=https://erudit-game.ru` — absolute origin (the native-critical var). -- `VITE_STORE_URL=https://www.rustore.ru/catalog/app/ru.eruditgame.app` — the update-overlay target. +- `VITE_GATEWAY_URL` — absolute gateway origin; the workflow reuses `vars.PROD_PUBLIC_BASE_URL` + (`https://erudit-game.ru`, trailing slash stripped). The native-critical var. +- `VITE_RUSTORE_URL` — the update-overlay store target. **Empty in the MVP** (set the `ANDROID_RUSTORE_URL` + Gitea variable once published); the gate is dormant so the button never fires yet. - `VITE_APP_VERSION=$(git describe --tags)` — feeds `__APP_VERSION__` = the `X-Client-Version` header. - `VITE_DICT_VERSION=` — the bundled-dict version the offline path requests. - `VITE_PAYMENTS_DISABLED=1` — hide purchases in the MVP. (`VITE_GP_BUILD` unset — RuStore, not GP.) diff --git a/ui/android/app/build.gradle b/ui/android/app/build.gradle index baa3db2..a219c61 100644 --- a/ui/android/app/build.gradle +++ b/ui/android/app/build.gradle @@ -3,12 +3,22 @@ apply plugin: 'com.android.application' android { namespace = "ru.eruditgame.app" compileSdk = rootProject.ext.compileSdkVersion + + // Release signing is supplied by the android-build CI workflow via env: it decodes the keystore + // secret to a file and points ANDROID_KEYSTORE_FILE at it. A local build or a keyless CI run leaves + // it unset, so the release APK is produced UNSIGNED rather than failing — assembleDebug and + // assembleRelease both build without the keystore. The keystore is never committed (see .gitignore). + def keystoreFile = System.getenv('ANDROID_KEYSTORE_FILE') + def hasKeystore = keystoreFile != null && !keystoreFile.isEmpty() && file(keystoreFile).exists() + defaultConfig { applicationId "ru.eruditgame.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" + // Deterministic from the release tag by the android-build workflow (vMA.MI.PA -> + // MA*1_000_000 + MI*1_000 + PA); the defaults keep a local assembleDebug building. + versionCode = (project.findProperty('versionCode') ?: '1').toInteger() + versionName = (project.findProperty('versionName') ?: '0.0.0').toString() testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -16,10 +26,27 @@ android { ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } } + signingConfigs { + release { + // Populated only when the workflow provided a keystore (see hasKeystore above); left empty + // otherwise so it is never referenced with a null storeFile. + if (hasKeystore) { + storeFile file(keystoreFile) + storePassword System.getenv('ANDROID_KEYSTORE_PASSWORD') + keyAlias System.getenv('ANDROID_KEY_ALIAS') + keyPassword System.getenv('ANDROID_KEY_PASSWORD') + } + } + } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + // Sign only when a keystore was supplied; a keyless release build stays unsigned instead + // of failing. + if (hasKeystore) { + signingConfig signingConfigs.release + } } } } diff --git a/ui/src/components/UpdateOverlay.svelte b/ui/src/components/UpdateOverlay.svelte index 7fb51e0..b0e8676 100644 --- a/ui/src/components/UpdateOverlay.svelte +++ b/ui/src/components/UpdateOverlay.svelte @@ -2,7 +2,7 @@ // Non-dismissable "update required" cover. Shown when the gateway has refused a foreground call // as too old (update.svelte.ts — the client-version gate). Unlike the maintenance overlay this is // terminal: an installed build cannot become compatible without an actual update, so its one - // action takes the user to the fix — the store listing on a native build (VITE_STORE_URL, opened + // action takes the user to the fix — the store listing on a native build (VITE_RUSTORE_URL, opened // in the system browser / store app) or a plain reload on the web (which fetches the current // client). Mirrors MaintenanceOverlay.svelte's look. import { updateRequired } from '../lib/update.svelte'; @@ -13,7 +13,7 @@ const ch = clientChannel(); if (ch === 'android' || ch === 'ios') { // '_system' hands the URL to the OS (the store app / external browser) rather than the WebView. - const url = import.meta.env.VITE_STORE_URL; + const url = import.meta.env.VITE_RUSTORE_URL; if (url) window.open(url, '_system'); } else { location.reload(); diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts index af7aa8d..de27400 100644 --- a/ui/src/vite-env.d.ts +++ b/ui/src/vite-env.d.ts @@ -7,8 +7,9 @@ interface ImportMetaEnv { readonly VITE_GATEWAY_URL?: string; /** "1" hides the in-app-currency purchase actions in the thin native MVP that defers store billing. */ readonly VITE_PAYMENTS_DISABLED?: string; - /** Store listing URL the native update overlay opens for its "update" action (e.g. the RuStore page). */ - readonly VITE_STORE_URL?: string; + /** RuStore listing URL the native update overlay opens for its "update" action. Empty until the app + * is published (the button then no-ops); the version gate is dormant in the MVP so it never fires yet. */ + readonly VITE_RUSTORE_URL?: string; /** Bundled-dictionary version the native offline path requests; matches the packaged DAWG files. */ readonly VITE_DICT_VERSION?: string; }