14d7678624
- flutter analyze: Riverpod 3 dropped StateProvider; replaced
_dismissedVersionsProvider with a small Notifier<Set<String>>
+ NotifierProvider, mutating via an `add(version)` method.
Public API (shouldShowUpdateBannerProvider, dismissUpdateProvider)
unchanged.
- golangci errcheck: defer f.Close() now wrapped in func() { _ = f.Close() }();
os.Setenv calls in test helper switched to t.Setenv (cleaner — auto-restores
on test cleanup, no manual Unsetenv needed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
106 lines
3.3 KiB
Go
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 func() { _ = 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)
|
|
}
|