9f42ee2460
Private, offline speech-to-text that runs Whisper on the user's own device — free, no account, no per-minute fees. Replaces Otter.ai / Rev. - Pure, tested engine: chunking, overlap timestamp-stitching, exports (SRT/VTT/TXT/MD/JSON), WAV codec, resampler, job queue, model catalog (142 tests). - Platform-abstracted TranscriptionEngine: transformers.js on web (loaded from CDN at runtime to dodge Metro's onnxruntime-web bundling limits), whisper.rn on native. Shared pipeline orchestrates decode -> chunk -> transcribe -> stitch. - Cross-platform StorageRepo (Dexie web / expo-sqlite native), Zod-validated. - UI: library + search, import, live-progress transcription, synced click-to-seek editor, multi-format export; model picker + privacy in settings. - Web ships as a single-page PWA with COOP/COEP isolation for threaded WASM; Docker (nginx) image + Traefik compose for wisp.briggen.dev. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
221 lines
8.8 KiB
Markdown
221 lines
8.8 KiB
Markdown
# Wisp — Deployment Runbook
|
|
|
|
CI/CD for Wisp runs on **Gitea Actions**. A push to `master` will:
|
|
|
|
1. **Deploy the web app** to the `briggen.dev` server as a Docker container
|
|
(nginx serving the Expo/Metro static export, with cross-origin isolation
|
|
headers for multi-threaded WASM).
|
|
2. **Build a signed Android APK** and publish it both to the server
|
|
(`https://<wisp-host>/wisp.apk`) and as a downloadable CI artifact.
|
|
|
|
Pipeline: [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml).
|
|
|
|
---
|
|
|
|
## 1. Required Gitea secrets
|
|
|
|
Set these in **Gitea → Wisp repo → Settings → Actions → Secrets**.
|
|
|
|
| Secret | Used by | What it is |
|
|
| --- | --- | --- |
|
|
| `SSH_HOST` | deploy-web, build-apk | Hostname/IP of the briggen.dev server (SSH target). |
|
|
| `SSH_USER` | deploy-web, build-apk | SSH user with permission to run `docker` and write `/srv/wisp`. |
|
|
| `SSH_KEY` | deploy-web, build-apk | **Private** SSH key (PEM) whose public half is in the server's `authorized_keys`. |
|
|
| `ANDROID_KEYSTORE_BASE64` | build-apk | Base64 of the Wisp release keystore (see §3). |
|
|
| `ANDROID_KEYSTORE_PASSWORD` | build-apk | Keystore (store) password. |
|
|
| `ANDROID_KEY_ALIAS` | build-apk | Key alias — `wisp` if you used `scripts/gen-keystore.sh` defaults. |
|
|
| `ANDROID_KEY_PASSWORD` | build-apk | Key (alias) password (may equal the store password). |
|
|
|
|
> The SSH key should be a **dedicated deploy key** with the minimum access
|
|
> needed (docker + write to `/srv/wisp`), not your personal key.
|
|
|
|
---
|
|
|
|
## 2. One-time server setup (briggen.dev)
|
|
|
|
1. **Install Docker + Compose plugin** on the server (the runner uses
|
|
`docker compose` over SSH).
|
|
|
|
2. **Create the deploy directory and place the compose file:**
|
|
|
|
```bash
|
|
sudo mkdir -p /srv/wisp/web
|
|
# Copy this repo's docker-compose.yml to the server:
|
|
scp docker-compose.yml <user>@<server>:/srv/wisp/docker-compose.yml
|
|
```
|
|
|
|
- `/srv/wisp/docker-compose.yml` — the service definition CI runs
|
|
(`docker compose -f /srv/wisp/docker-compose.yml up -d`).
|
|
- `/srv/wisp/web/wisp.apk` — where CI drops the signed APK; it is mounted
|
|
read-only into nginx's web root by the compose file. (It's fine if it
|
|
doesn't exist before the first APK build — nginx 404s `/wisp.apk` until then.)
|
|
|
|
3. **Create the proxy network** the compose file attaches to (or reuse your
|
|
existing reverse-proxy network and rename it in `docker-compose.yml`):
|
|
|
|
```bash
|
|
docker network create proxy
|
|
```
|
|
|
|
4. **Wire your reverse proxy + TLS** for the Wisp host on `briggen.dev`
|
|
(e.g. `wisp.briggen.dev`). The container listens on **port 8080** and is
|
|
reachable as `wisp-web:8080` on the `proxy` network. Point your proxy at it
|
|
and terminate TLS there. Examples:
|
|
|
|
- **Caddy:**
|
|
```
|
|
wisp.briggen.dev {
|
|
reverse_proxy wisp-web:8080
|
|
}
|
|
```
|
|
- **Traefik:** add router/service labels for `wisp-web` (host rule
|
|
`Host(\`wisp.briggen.dev\`)`, service port `8080`, your TLS cert resolver).
|
|
- **Host nginx (proxy not in Docker):** use **Option B** in
|
|
`docker-compose.yml` (publish `127.0.0.1:8080:8080`) and
|
|
`proxy_pass http://127.0.0.1:8080;`.
|
|
|
|
> **Do not strip or override** the `Cross-Origin-Opener-Policy` /
|
|
> `Cross-Origin-Embedder-Policy` headers at the proxy — they must reach the
|
|
> browser intact (see §4).
|
|
|
|
5. **Ensure the Gitea Actions runner host has Docker.** The `deploy-web` and
|
|
`build-apk` jobs run `docker build`, `docker save/load`, `ssh`, and `scp` on
|
|
the runner. The `test` job runs inside the `oven/bun:1` container.
|
|
|
|
The first push to `master` after this is set up will deploy.
|
|
|
|
---
|
|
|
|
## 3. Generate the Android release keystore
|
|
|
|
Run **once** on a trusted machine, then back it up forever:
|
|
|
|
```bash
|
|
./scripts/gen-keystore.sh
|
|
```
|
|
|
|
This creates a new RSA-2048 keystore (alias `wisp`, valid 10000 days) and prints
|
|
the exact `base64` command plus the list of secrets to set. Then:
|
|
|
|
```bash
|
|
base64 -w0 wisp-release.keystore # Linux (macOS: base64 -b0 ...)
|
|
```
|
|
|
|
Paste that single line into `ANDROID_KEYSTORE_BASE64`, and set the password /
|
|
alias secrets to match what you chose.
|
|
|
|
> **Critical:** the keystore + passwords are the *only* way to ship updates to an
|
|
> already-installed APK. Lose them and users must uninstall/reinstall. Back them
|
|
> up in a password manager / offline. Never commit them (`.gitignore` excludes
|
|
> `*.keystore` / `*.jks`).
|
|
|
|
How signing is wired at build time: `scripts/ci-android-sign.sh` base64-decodes
|
|
the keystore into `android/app/wisp.keystore`, writes the passwords into
|
|
`android/gradle.properties`, and idempotently patches `android/app/build.gradle`
|
|
to add `signingConfigs.release` and point `buildTypes.release` at it.
|
|
|
|
---
|
|
|
|
## 4. How COOP/COEP cross-origin isolation works (and why it matters)
|
|
|
|
The web app runs Whisper as **multi-threaded WASM**, which needs
|
|
`SharedArrayBuffer`. Browsers only expose `SharedArrayBuffer` to pages that are
|
|
**cross-origin isolated**, which requires both response headers on the document:
|
|
|
|
- `Cross-Origin-Opener-Policy: same-origin`
|
|
- `Cross-Origin-Embedder-Policy: require-corp`
|
|
|
|
When both are present, `self.crossOriginIsolated === true` and threads/SAB work.
|
|
These headers are set in [`docker/nginx.conf`](../docker/nginx.conf) and served by
|
|
our nginx container.
|
|
|
|
### Why GitHub Pages can't host this
|
|
|
|
Cross-origin isolation requires **setting custom response headers** (COOP/COEP).
|
|
**GitHub Pages does not let you set arbitrary response headers**, so you cannot
|
|
make a Pages-hosted site cross-origin isolated, and threaded WASM will not run
|
|
there. That's precisely why Wisp's web app is self-hosted behind our own nginx
|
|
(which can send the headers) instead of on GitHub Pages.
|
|
|
|
### Model weights from Hugging Face + COEP
|
|
|
|
The app fetches Whisper model weights at runtime from the **Hugging Face Hub**, a
|
|
cross-origin host. Under `COEP: require-corp`, every cross-origin subresource must
|
|
satisfy either a `Cross-Origin-Resource-Policy` header **or** valid CORS. HF
|
|
generally serves permissive CORS, so CORS-mode fetches work. **If** a future HF /
|
|
CDN response lacks the needed CORP/CORS, the download is blocked. The fallback is
|
|
`Cross-Origin-Embedder-Policy: credentialless` (keeps isolation, drops the hard
|
|
CORP requirement by sending no-CORS cross-origin requests without credentials).
|
|
A ready-to-enable commented alternative line is in `docker/nginx.conf`.
|
|
(Caveat: `credentialless` isn't supported by Safari, hence `require-corp` default.)
|
|
|
|
---
|
|
|
|
## 5. Android APK download
|
|
|
|
After a successful `master` build:
|
|
|
|
- **Direct download:** `https://<wisp-host>/wisp.apk`
|
|
(served by nginx from `/srv/wisp/web/wisp.apk`).
|
|
- **CI artifact:** the workflow run's summary page → artifact `wisp-release-apk`.
|
|
|
|
---
|
|
|
|
## 6. Build the APK locally via Docker
|
|
|
|
Reproduce the CI Android build on your machine (no Android Studio needed):
|
|
|
|
```bash
|
|
# 1. Build the toolchain image (JDK 17 + Android SDK 36 + Bun).
|
|
docker build -f docker/android.Dockerfile -t wisp-android:latest .
|
|
|
|
# 2. Run the build inside it, repo bind-mounted, secrets via env.
|
|
# (Use a LOCAL test keystore or your real secrets.)
|
|
docker run --rm \
|
|
-v "$PWD":/workspace -w /workspace \
|
|
-e ANDROID_KEYSTORE_BASE64="$(base64 -w0 wisp-release.keystore)" \
|
|
-e ANDROID_KEYSTORE_PASSWORD="<store-pass>" \
|
|
-e ANDROID_KEY_ALIAS="wisp" \
|
|
-e ANDROID_KEY_PASSWORD="<key-pass>" \
|
|
wisp-android:latest \
|
|
bash -lc '
|
|
bun install --frozen-lockfile
|
|
bunx expo prebuild --platform android --no-install
|
|
bash scripts/ci-android-sign.sh
|
|
cd android && ./gradlew assembleRelease --no-daemon
|
|
'
|
|
|
|
# 3. The signed APK is at:
|
|
# android/app/build/outputs/apk/release/app-release.apk
|
|
```
|
|
|
|
> **JDK version:** the dev machine may have **JDK 26**, but AGP 8.x targets
|
|
> **JDK 17**. CI and `docker/android.Dockerfile` both pin **JDK 17** so builds are
|
|
> reproducible and supported. Build through the Docker image to avoid local-JDK
|
|
> surprises; don't run `./gradlew` against a host JDK 26.
|
|
|
|
### Pinned Android toolchain (Expo SDK 56 / RN 0.85)
|
|
|
|
From `node_modules/react-native/gradle/libs.versions.toml`:
|
|
`compileSdk 36`, `targetSdk 36`, `minSdk 24`, `build-tools 36.0.0`,
|
|
`NDK 27.1.12297006`, `AGP 8.12.0`, `Kotlin 2.1.20`, Gradle wrapper `9.3.1`.
|
|
|
|
> **Heads-up:** `app.json` does not set `android.package`. Vanilla
|
|
> `expo prebuild` will derive a package name (from the `scheme`/slug) — set an
|
|
> explicit `expo.android.package` (e.g. `dev.briggen.wisp`) before the first
|
|
> store-bound build so the application ID is stable across releases.
|
|
|
|
---
|
|
|
|
## 7. Deploy flow at a glance
|
|
|
|
```
|
|
push master
|
|
└─ test (bun: typecheck + vitest)
|
|
├─ deploy-web docker build → save|gzip → ssh docker load → compose up
|
|
└─ build-apk docker build android image
|
|
→ prebuild → ci-android-sign.sh → gradlew assembleRelease
|
|
→ scp app-release.apk → /srv/wisp/web/wisp.apk
|
|
→ upload-artifact wisp-release-apk
|
|
```
|