Build Wisp: on-device transcription studio (web + native, one codebase)
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>
This commit is contained in:
Executable
+253
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# scripts/ci-android-sign.sh
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wire a RELEASE signing config into a freshly-prebuilt Android project.
|
||||
#
|
||||
# Run this AFTER `expo prebuild --platform android` has generated ./android,
|
||||
# and BEFORE `./gradlew assembleRelease`.
|
||||
#
|
||||
# It does two things, both IDEMPOTENTLY (safe to re-run):
|
||||
#
|
||||
# (a) Decodes the base64 keystore from $ANDROID_KEYSTORE_BASE64 into
|
||||
# android/app/wisp.keystore.
|
||||
#
|
||||
# (b) Patches android/app/build.gradle to:
|
||||
# - add a `signingConfigs.release { ... }` block that reads the keystore
|
||||
# path + passwords from Gradle properties (which we source from env),
|
||||
# - point `buildTypes.release.signingConfig` at it.
|
||||
# The patch is marker-based: it only injects once, and re-running detects
|
||||
# the marker and skips.
|
||||
#
|
||||
# Secrets / env consumed (provided by CI as repository secrets, exported into
|
||||
# the build container's environment):
|
||||
# ANDROID_KEYSTORE_BASE64 base64 of the .keystore file
|
||||
# ANDROID_KEYSTORE_PASSWORD keystore (store) password
|
||||
# ANDROID_KEY_ALIAS key alias (we generate keystores with alias 'wisp')
|
||||
# ANDROID_KEY_PASSWORD key (alias) password
|
||||
#
|
||||
# Generate the keystore + learn the exact secret-setting commands with
|
||||
# scripts/gen-keystore.sh. Full runbook: docs/DEPLOY.md.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Strict mode: exit on error, error on unset vars, fail pipelines on any stage.
|
||||
set -euo pipefail
|
||||
|
||||
# --- Resolve paths relative to the repo root (this script lives in scripts/) ---
|
||||
# So the script works regardless of the caller's current working directory.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." >/dev/null 2>&1 && pwd)"
|
||||
|
||||
ANDROID_DIR="${REPO_ROOT}/android"
|
||||
APP_DIR="${ANDROID_DIR}/app"
|
||||
GRADLE_FILE="${APP_DIR}/build.gradle"
|
||||
# Keep the keystore filename in sync with the signingConfigs block we inject.
|
||||
KEYSTORE_FILE="${APP_DIR}/wisp.keystore"
|
||||
# Unique marker so we can detect a prior injection and stay idempotent.
|
||||
MARKER="// WISP_RELEASE_SIGNING (managed by scripts/ci-android-sign.sh)"
|
||||
|
||||
# --- Small logging helpers --------------------------------------------------
|
||||
log() { printf '[ci-android-sign] %s\n' "$*"; }
|
||||
fail() { printf '[ci-android-sign] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# --- Preconditions ----------------------------------------------------------
|
||||
# The android project must already exist (run `expo prebuild` first).
|
||||
[[ -d "${ANDROID_DIR}" ]] || fail "android/ not found. Run 'expo prebuild --platform android' first."
|
||||
[[ -f "${GRADLE_FILE}" ]] || fail "Not found: ${GRADLE_FILE}"
|
||||
|
||||
# Validate required secrets are present (don't print their values).
|
||||
: "${ANDROID_KEYSTORE_BASE64:?ANDROID_KEYSTORE_BASE64 is required (base64 of the keystore)}"
|
||||
: "${ANDROID_KEYSTORE_PASSWORD:?ANDROID_KEYSTORE_PASSWORD is required}"
|
||||
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS is required}"
|
||||
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD is required}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (a) Decode the keystore from base64 into android/app/wisp.keystore
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Decoding keystore -> ${KEYSTORE_FILE}"
|
||||
# `base64 -d` reads the secret from stdin. We feed it via printf to avoid an
|
||||
# "Argument list too long" / arg-leak from putting the blob on the command line.
|
||||
# Tolerate base64 with or without newlines/whitespace (-w0 producers vs. wrapped).
|
||||
printf '%s' "${ANDROID_KEYSTORE_BASE64}" | base64 -d > "${KEYSTORE_FILE}" \
|
||||
|| fail "Failed to base64-decode ANDROID_KEYSTORE_BASE64"
|
||||
|
||||
# Sanity check: a real JKS/PKCS12 keystore is more than a few bytes.
|
||||
if [[ ! -s "${KEYSTORE_FILE}" ]] || [[ "$(wc -c < "${KEYSTORE_FILE}")" -lt 256 ]]; then
|
||||
fail "Decoded keystore looks too small — is ANDROID_KEYSTORE_BASE64 correct?"
|
||||
fi
|
||||
# Lock down the keystore file permissions (best effort).
|
||||
chmod 600 "${KEYSTORE_FILE}" || true
|
||||
log "Keystore decoded ($(wc -c < "${KEYSTORE_FILE}") bytes)."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (b1) Make the signing credentials available to Gradle.
|
||||
# ---------------------------------------------------------------------------
|
||||
# We DON'T hardcode passwords in build.gradle. Instead the injected Groovy reads
|
||||
# project properties (project.findProperty(...)) which we supply by writing to
|
||||
# the project-local gradle.properties. This keeps secrets out of the source and
|
||||
# out of the process list. `assembleRelease` also still works if a CI step
|
||||
# passes the same values via -P flags.
|
||||
#
|
||||
# We APPEND our properties under a marker and strip any previous managed block
|
||||
# first, so re-runs don't accumulate duplicates.
|
||||
GRADLE_PROPS="${ANDROID_DIR}/gradle.properties"
|
||||
PROPS_MARKER="# WISP_RELEASE_SIGNING_PROPS (managed by scripts/ci-android-sign.sh)"
|
||||
|
||||
touch "${GRADLE_PROPS}"
|
||||
# Remove any previously-managed block (from the marker line to EOF). Using a temp
|
||||
# file keeps this portable across GNU/BSD sed.
|
||||
if grep -qF "${PROPS_MARKER}" "${GRADLE_PROPS}"; then
|
||||
log "Refreshing managed signing props in gradle.properties"
|
||||
# Print everything BEFORE the marker line, then we re-append the fresh block.
|
||||
awk -v m="${PROPS_MARKER}" 'index($0, m){found=1} !found{print}' \
|
||||
"${GRADLE_PROPS}" > "${GRADLE_PROPS}.tmp"
|
||||
mv "${GRADLE_PROPS}.tmp" "${GRADLE_PROPS}"
|
||||
fi
|
||||
|
||||
# Append the fresh managed properties block. The store FILE path is relative to
|
||||
# the app module (android/app), so just the filename is correct.
|
||||
{
|
||||
printf '\n%s\n' "${PROPS_MARKER}"
|
||||
printf 'WISP_RELEASE_STORE_FILE=wisp.keystore\n'
|
||||
printf 'WISP_RELEASE_STORE_PASSWORD=%s\n' "${ANDROID_KEYSTORE_PASSWORD}"
|
||||
printf 'WISP_RELEASE_KEY_ALIAS=%s\n' "${ANDROID_KEY_ALIAS}"
|
||||
printf 'WISP_RELEASE_KEY_PASSWORD=%s\n' "${ANDROID_KEY_PASSWORD}"
|
||||
} >> "${GRADLE_PROPS}"
|
||||
chmod 600 "${GRADLE_PROPS}" || true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (b2) Inject the signingConfigs.release block + wire buildTypes.release.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idempotency: if our marker is already present, the gradle file is patched.
|
||||
if grep -qF "${MARKER}" "${GRADLE_FILE}"; then
|
||||
log "build.gradle already patched (marker present) — skipping injection."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The Groovy snippet we add INSIDE the existing `signingConfigs { ... }` block.
|
||||
# It reads from the gradle.properties values written above. We use def-with-
|
||||
# fallback so a missing property yields a clear gradle error rather than NPE.
|
||||
# `IFS=` keeps `read` from trimming the leading indentation of the first line.
|
||||
IFS= read -r -d '' RELEASE_SIGNING_CONFIG <<'GROOVY' || true
|
||||
release {
|
||||
// -- WISP managed release signing config --
|
||||
// Values come from android/gradle.properties (written by
|
||||
// scripts/ci-android-sign.sh from CI secrets). storeFile is resolved
|
||||
// relative to this module (android/app).
|
||||
def wispStoreFile = project.findProperty('WISP_RELEASE_STORE_FILE') ?: 'wisp.keystore'
|
||||
storeFile file(wispStoreFile)
|
||||
storePassword project.findProperty('WISP_RELEASE_STORE_PASSWORD')
|
||||
keyAlias project.findProperty('WISP_RELEASE_KEY_ALIAS')
|
||||
keyPassword project.findProperty('WISP_RELEASE_KEY_PASSWORD')
|
||||
}
|
||||
GROOVY
|
||||
|
||||
# We patch with a small, robust Node script (Node is present in the build image
|
||||
# via Bun/Expo tooling, and string surgery is far safer in JS than in sed for
|
||||
# multi-line, brace-sensitive Groovy). It performs two edits:
|
||||
#
|
||||
# 1. Insert the `release { ... }` config as the FIRST entry inside the
|
||||
# `signingConfigs {` block (the prebuild template ships a `debug` config
|
||||
# there; we add `release` alongside it).
|
||||
# 2. Inside the `buildTypes { ... }`'s `release { ... }` block SPECIFICALLY,
|
||||
# set `signingConfig signingConfigs.release`. We must scope to the release
|
||||
# block via brace matching: a naive global replace of
|
||||
# `signingConfig signingConfigs.debug` would also (wrongly) rewrite the
|
||||
# DEBUG build type, which legitimately keeps the debug signing config.
|
||||
# (The Expo/RN template defaults release to the debug signing config so that
|
||||
# `assembleRelease` works out-of-the-box; we override only the release one.)
|
||||
#
|
||||
# Both edits are guarded so the script fails LOUDLY if the expected anchors are
|
||||
# missing (e.g. a future template change), rather than silently producing an
|
||||
# unsigned/debug-signed APK.
|
||||
log "Patching ${GRADLE_FILE}"
|
||||
|
||||
WISP_MARKER="${MARKER}" \
|
||||
WISP_RELEASE_BLOCK="${RELEASE_SIGNING_CONFIG}" \
|
||||
WISP_GRADLE_FILE="${GRADLE_FILE}" \
|
||||
node <<'NODE'
|
||||
const fs = require('fs');
|
||||
|
||||
const file = process.env.WISP_GRADLE_FILE;
|
||||
const marker = process.env.WISP_MARKER;
|
||||
const block = process.env.WISP_RELEASE_BLOCK;
|
||||
|
||||
let src = fs.readFileSync(file, 'utf8');
|
||||
|
||||
// --- Edit 1: insert `release { ... }` inside `signingConfigs { ... }` --------
|
||||
// Match the literal `signingConfigs {` opener (Groovy DSL). We insert right
|
||||
// after the opening brace so our release config sits beside the template's
|
||||
// `debug` config.
|
||||
const scRe = /signingConfigs\s*\{/;
|
||||
if (!scRe.test(src)) {
|
||||
console.error("[ci-android-sign] Could not find `signingConfigs {` in build.gradle.");
|
||||
process.exit(1);
|
||||
}
|
||||
src = src.replace(
|
||||
scRe,
|
||||
(m) => `${m}\n ${marker}\n${block}`
|
||||
);
|
||||
|
||||
// --- Helper: find the body span of `<keyword> {` via brace matching ----------
|
||||
// Returns {open, close} string indices of the block's content (between the
|
||||
// matching braces) for the FIRST occurrence of `<keyword> {` starting at/after
|
||||
// `fromIndex`, or null if not found / unbalanced. This lets us scope edits to a
|
||||
// specific nested block (e.g. the `release` block INSIDE `buildTypes`) instead
|
||||
// of relying on greedy regexes that cross block boundaries.
|
||||
function findBlockBody(text, keyword, fromIndex = 0) {
|
||||
const opener = new RegExp(`\\b${keyword}\\s*\\{`, 'g');
|
||||
opener.lastIndex = fromIndex;
|
||||
const m = opener.exec(text);
|
||||
if (!m) return null;
|
||||
// Index of the `{` that opens the block.
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
// body spans (just after the opening brace) .. (this closing brace)
|
||||
return { open: m.index + m[0].length, close: i };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null; // unbalanced braces
|
||||
}
|
||||
|
||||
// --- Edit 2: point the RELEASE buildType at signingConfigs.release -----------
|
||||
// Scope to buildTypes { ... } first, then to its release { ... } sub-block.
|
||||
const buildTypes = findBlockBody(src, 'buildTypes');
|
||||
if (!buildTypes) {
|
||||
console.error("[ci-android-sign] Could not find buildTypes { } in build.gradle.");
|
||||
process.exit(1);
|
||||
}
|
||||
const release = findBlockBody(src, 'release', buildTypes.open);
|
||||
// Guard: the release block must lie INSIDE buildTypes (defensive against a
|
||||
// future template where `release` appears elsewhere first).
|
||||
if (!release || release.close > buildTypes.close) {
|
||||
console.error("[ci-android-sign] Could not find buildTypes.release { } in build.gradle.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let releaseBody = src.slice(release.open, release.close);
|
||||
if (/signingConfig\s+signingConfigs\.debug/.test(releaseBody)) {
|
||||
// Normal case: swap the template default (debug) for our release config.
|
||||
releaseBody = releaseBody.replace(
|
||||
/signingConfig\s+signingConfigs\.debug/,
|
||||
'signingConfig signingConfigs.release'
|
||||
);
|
||||
} else if (/signingConfig\s+signingConfigs\.release/.test(releaseBody)) {
|
||||
// Already points at release (e.g. a template that ships it) — nothing to do.
|
||||
} else {
|
||||
// No signingConfig line present — inject one as the first statement.
|
||||
releaseBody = '\n signingConfig signingConfigs.release' + releaseBody;
|
||||
}
|
||||
src = src.slice(0, release.open) + releaseBody + src.slice(release.close);
|
||||
|
||||
fs.writeFileSync(file, src);
|
||||
console.log("[ci-android-sign] build.gradle patched: added signingConfigs.release and wired release buildType.");
|
||||
NODE
|
||||
|
||||
log "Done. Release signing is configured. You can now run: (cd android && ./gradlew assembleRelease)"
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# scripts/gen-keystore.sh
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generate a NEW dedicated release keystore for Wisp, then print the exact
|
||||
# commands to base64-encode it and the list of Gitea Actions secrets to set.
|
||||
#
|
||||
# Run this ONCE on a trusted machine. The resulting keystore is the SINGLE
|
||||
# source of truth for signing every future Wisp APK — if you lose it (or its
|
||||
# passwords), you cannot ship an update that upgrades an already-installed APK
|
||||
# (signature mismatch). BACK IT UP somewhere safe (password manager / offline).
|
||||
#
|
||||
# This script does NOT touch CI, does NOT commit anything, and does NOT generate
|
||||
# a "real" production key for you silently — YOU run it, YOU keep the output.
|
||||
#
|
||||
# Requires: keytool (ships with any JDK).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/gen-keystore.sh # interactive password prompts
|
||||
# STORE_PASS=... KEY_PASS=... ./scripts/gen-keystore.sh # non-interactive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- Tunables (override via env) --------------------------------------------
|
||||
# Output keystore path. Default lands beside the script, OUTSIDE the app tree so
|
||||
# you don't accidentally commit it (.gitignore already excludes *.jks/*.keystore,
|
||||
# but we keep it out of the way regardless).
|
||||
KEYSTORE_OUT="${KEYSTORE_OUT:-./wisp-release.keystore}"
|
||||
# Key alias. MUST match ANDROID_KEY_ALIAS in CI. scripts/ci-android-sign.sh and
|
||||
# the docs assume the alias 'wisp'.
|
||||
KEY_ALIAS="${KEY_ALIAS:-wisp}"
|
||||
# RSA 2048, valid for 10000 days (~27 years) — Google Play's minimum validity
|
||||
# recommendation for upload/app-signing keys is well within this.
|
||||
KEY_ALG="RSA"
|
||||
KEY_SIZE="2048"
|
||||
VALIDITY_DAYS="10000"
|
||||
# Distinguished Name. Edit to taste; only CN is really meaningful for an app key.
|
||||
DNAME="${DNAME:-CN=Wisp, OU=Wisp, O=briggen.dev, L=, ST=, C=DE}"
|
||||
|
||||
log() { printf '[gen-keystore] %s\n' "$*"; }
|
||||
fail() { printf '[gen-keystore] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# --- Preconditions ----------------------------------------------------------
|
||||
command -v keytool >/dev/null 2>&1 || fail "keytool not found. Install a JDK (e.g. JDK 17)."
|
||||
|
||||
if [[ -e "${KEYSTORE_OUT}" ]]; then
|
||||
fail "Refusing to overwrite existing keystore at ${KEYSTORE_OUT}. Move/delete it first."
|
||||
fi
|
||||
|
||||
# --- Collect passwords ------------------------------------------------------
|
||||
# Allow non-interactive use via env (STORE_PASS / KEY_PASS); otherwise prompt.
|
||||
# We do NOT echo passwords. For most setups STORE_PASS and KEY_PASS can be equal.
|
||||
STORE_PASS="${STORE_PASS:-}"
|
||||
KEY_PASS="${KEY_PASS:-}"
|
||||
|
||||
if [[ -z "${STORE_PASS}" ]]; then
|
||||
read -r -s -p "Enter NEW keystore (store) password: " STORE_PASS; echo
|
||||
read -r -s -p "Confirm keystore password: " STORE_PASS_CONFIRM; echo
|
||||
[[ "${STORE_PASS}" == "${STORE_PASS_CONFIRM}" ]] || fail "Store passwords do not match."
|
||||
fi
|
||||
[[ "${#STORE_PASS}" -ge 6 ]] || fail "Keystore password must be at least 6 characters (keytool requirement)."
|
||||
|
||||
if [[ -z "${KEY_PASS}" ]]; then
|
||||
read -r -s -p "Enter NEW key (alias) password [blank = same as store]: " KEY_PASS; echo
|
||||
KEY_PASS="${KEY_PASS:-$STORE_PASS}"
|
||||
fi
|
||||
|
||||
# --- Generate the keystore --------------------------------------------------
|
||||
# -storetype PKCS12 is the modern default (JKS is deprecated). keytool will emit
|
||||
# a PKCS12 keystore; that's fully supported by Gradle/apksigner.
|
||||
log "Generating ${KEY_ALG} ${KEY_SIZE} key, alias '${KEY_ALIAS}', valid ${VALIDITY_DAYS} days..."
|
||||
keytool -genkeypair \
|
||||
-keystore "${KEYSTORE_OUT}" \
|
||||
-storetype PKCS12 \
|
||||
-alias "${KEY_ALIAS}" \
|
||||
-keyalg "${KEY_ALG}" \
|
||||
-keysize "${KEY_SIZE}" \
|
||||
-validity "${VALIDITY_DAYS}" \
|
||||
-dname "${DNAME}" \
|
||||
-storepass "${STORE_PASS}" \
|
||||
-keypass "${KEY_PASS}"
|
||||
|
||||
log "Keystore created: ${KEYSTORE_OUT}"
|
||||
echo
|
||||
|
||||
# --- Print follow-up instructions -------------------------------------------
|
||||
# Show the EXACT base64 command and the secrets to configure in Gitea. We print
|
||||
# the base64 command (not the base64 itself, and never the passwords) so the
|
||||
# human runs it themselves and pastes into the Gitea secrets UI.
|
||||
cat <<EOF
|
||||
=============================================================================
|
||||
NEXT STEPS — configure Gitea Actions secrets for the build-apk job
|
||||
=============================================================================
|
||||
|
||||
1) Base64-encode the keystore (single line, no wrapping) and copy it:
|
||||
|
||||
# Linux:
|
||||
base64 -w0 "${KEYSTORE_OUT}"
|
||||
|
||||
# macOS (no -w flag; -b0 disables wrapping):
|
||||
base64 -b0 "${KEYSTORE_OUT}"
|
||||
|
||||
Copy the entire single-line output — that is ANDROID_KEYSTORE_BASE64.
|
||||
|
||||
2) In Gitea -> the Wisp repo -> Settings -> Actions -> Secrets, add:
|
||||
|
||||
ANDROID_KEYSTORE_BASE64 = <the base64 string from step 1>
|
||||
ANDROID_KEYSTORE_PASSWORD = <the store password you just chose>
|
||||
ANDROID_KEY_ALIAS = ${KEY_ALIAS}
|
||||
ANDROID_KEY_PASSWORD = <the key password you just chose>
|
||||
|
||||
(These names match scripts/ci-android-sign.sh and .gitea/workflows/ci.yml.)
|
||||
|
||||
3) BACK UP "${KEYSTORE_OUT}" and BOTH passwords in a safe place (password
|
||||
manager / offline). If lost, you can never update an already-installed APK.
|
||||
|
||||
4) Do NOT commit the keystore. (.gitignore already excludes *.keystore/*.jks.)
|
||||
=============================================================================
|
||||
EOF
|
||||
Reference in New Issue
Block a user