Files
minstrel/internal/api/client_assets.go
T
bvandeusen a2bea8601a feat(server): /api/client/version + /api/client/apk endpoints (#397 phase 1)
Phase 1 of the in-app update flow — server side. Endpoints serve the
bundled Android APK + sidecar version file from /app/client/.
Returns 404 gracefully when files aren't present, so dev environments
and pre-CI-wiring images degrade cleanly to "no update available."

- internal/api/client_assets.go: handleClientVersion + handleClientAPK.
  Both unauthenticated (matches /healthz) so install flow doesn't
  depend on a live session. APK served with proper
  application/vnd.android.package-archive Content-Type +
  http.ServeContent so Range requests work for resumable downloads
  on flaky networks.
- Path resolves to /app/client/ by default; MINSTREL_CLIENT_APK_DIR
  env var overrides for dev.
- Dockerfile creates /app/client/ + commented COPY hooks for the CI
  sequencing phase.
- Tests cover all four states: missing apk, apk-but-no-version,
  both present (200 with correct shape), apk stream (200 with
  correct Content-Type + body bytes).

Phases 2 (Flutter client provider + banner + install intent + Android
manifest changes) and 3 (CI sequencing to bake the APK into the image)
land in follow-up commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:35:24 -04:00

106 lines
3.3 KiB
Go

package api
// In-app update endpoints (#397). The Android APK ships bundled with
// the server image so the client can self-update without an external
// app store. CI sequencing bakes the APK + sidecar version file into
// /app/client/ at image build time.
//
// Both endpoints are unauthenticated — install flow shouldn't depend
// on a live session, and the URL is hard to guess at scale.
//
// Returns 404 gracefully when the APK isn't present (dev environments,
// pre-CI-wiring); the Flutter client treats 404 as "no update channel
// available" and falls back to the manual download.
import (
"errors"
"net/http"
"os"
"path/filepath"
"strings"
)
const (
defaultClientAPKDir = "/app/client"
clientAPKFilename = "minstrel.apk"
clientVersionFile = "minstrel.apk.version"
)
// clientAPKDir resolves the directory holding the bundled APK. Env
// var MINSTREL_CLIENT_APK_DIR overrides for dev; default matches the
// Dockerfile's COPY destination.
func clientAPKDir() string {
if d, ok := os.LookupEnv("MINSTREL_CLIENT_APK_DIR"); ok && d != "" {
return d
}
return defaultClientAPKDir
}
type clientVersionResponse struct {
Version string `json:"version"`
APKURL string `json:"apk_url"`
SizeBytes int64 `json:"size_bytes"`
}
// handleClientVersion returns the bundled Android client version + a
// URL to fetch the APK. 404 when no APK is bundled.
func (h *handlers) handleClientVersion(w http.ResponseWriter, _ *http.Request) {
dir := clientAPKDir()
apkPath := filepath.Join(dir, clientAPKFilename)
versionPath := filepath.Join(dir, clientVersionFile)
stat, err := os.Stat(apkPath)
if errors.Is(err, os.ErrNotExist) {
http.Error(w, `{"error":{"code":"no_client_apk","message":"no bundled client apk"}}`, http.StatusNotFound)
return
}
if err != nil {
writeErrWithLog(w, h.logger, "client_version: stat apk", err)
return
}
versionBytes, err := os.ReadFile(versionPath)
if errors.Is(err, os.ErrNotExist) {
http.Error(w, `{"error":{"code":"no_client_version","message":"apk present but version file missing"}}`, http.StatusNotFound)
return
}
if err != nil {
writeErrWithLog(w, h.logger, "client_version: read version", err)
return
}
writeJSON(w, http.StatusOK, clientVersionResponse{
Version: strings.TrimSpace(string(versionBytes)),
APKURL: "/api/client/apk",
SizeBytes: stat.Size(),
})
}
// handleClientAPK streams the bundled APK with the correct
// Content-Type so Android's PackageInstaller accepts it.
func (h *handlers) handleClientAPK(w http.ResponseWriter, r *http.Request) {
apkPath := filepath.Join(clientAPKDir(), clientAPKFilename)
f, err := os.Open(apkPath)
if errors.Is(err, os.ErrNotExist) {
http.Error(w, `{"error":{"code":"no_client_apk","message":"no bundled client apk"}}`, http.StatusNotFound)
return
}
if err != nil {
writeErrWithLog(w, h.logger, "client_apk: open", err)
return
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
writeErrWithLog(w, h.logger, "client_apk: stat", err)
return
}
// Use http.ServeContent so Range requests work — install flows on
// flaky networks may resume rather than restart.
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
w.Header().Set("Content-Disposition", `attachment; filename="minstrel.apk"`)
http.ServeContent(w, r, clientAPKFilename, stat.ModTime(), f)
}