feat: add a platform-aware apps download page
ci / api / api (push) Successful in 48s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m10s
ci / db / db (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
ci / desktop / rust (desktop proxy) (push) Successful in 5m37s
ci / ts / ts (packages + web) (push) Successful in 4m10s
release / release (версия и заметки) (push) Successful in 11s
release / android (APK в релиз) (push) Successful in 4m22s

Serve desktop and Android builds from the latest Gitea release on /download, and fix the desktop compile and CI flakes that blocked v1.3.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mrleo1nidandCursor committed 2026-10-22 00:10:03 +03:00
1 parent efe78d7e07
commit 597067689a
30 files changed
+1180 -395

No files matched your search

+212 -52
View File
@@ -1,5 +1,5 @@
// Package appupdate — самообновление Android-приложения через сервер (docs/mobile.md#самообновление):
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и APK.
// Package appupdate — раздача клиентов через сервер (docs/mobile.md#самообновление):
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и файлы.
package appupdate
import (
@@ -25,7 +25,7 @@ import (
"pipu/api/internal/platform/httpx"
)
// CodeUnavailable — обновления не настроены или релиза с APK нет.
// CodeUnavailable — обновления не настроены или релиза с клиентами нет.
const CodeUnavailable = "app.update_unavailable"
const cacheTTL = 10 * time.Minute
@@ -73,14 +73,22 @@ type release struct {
Assets []asset `json:"assets"`
}
// Latest — сведения о последней версии.
type clientFile struct {
Kind, Name, URL, ChecksumURL, SHA256 string
Size int64
}
// Latest — сведения о последней версии и файлах клиентов.
type Latest struct {
Version string
VersionCode int
Notes string
Size int64
SHA256 string
apkURL string
Files map[string]clientFile
}
func (l *Latest) android() (clientFile, bool) {
f, ok := l.Files["android"]
return f, ok && f.SHA256 != ""
}
// Handler реализует app-часть gen.StrictServerInterface.
@@ -92,7 +100,7 @@ type Handler struct {
mu sync.Mutex
cached *Latest
cachedAt time.Time
dl sync.Mutex // одна загрузка APK за раз
dl sync.Mutex
}
// NewHandler создаёт обработчик.
@@ -100,7 +108,6 @@ func NewHandler(cfg Config, log *slog.Logger) *Handler {
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
}
// configured — адрес и репозиторий заданы, и это не заглушка из .env.example.
func (h *Handler) configured() bool {
return h.cfg.GiteaURL != "" && h.cfg.Repo != "" &&
!strings.Contains(h.cfg.GiteaURL, "example.") && !strings.HasPrefix(h.cfg.Repo, "owner/")
@@ -125,7 +132,70 @@ func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) {
return res, nil
}
// latest — последний релиз с APK (кэш на 10 минут).
func classifyAsset(name string) string {
n := strings.ToLower(name)
if strings.HasSuffix(n, ".sha256") {
return ""
}
switch {
case strings.HasSuffix(n, ".apk"):
return "android"
case strings.HasSuffix(n, ".exe"), strings.HasSuffix(n, ".msi"):
return "windows"
case strings.HasSuffix(n, ".appimage"), strings.HasSuffix(n, ".deb"), strings.HasSuffix(n, ".rpm"):
return "linux"
case strings.HasSuffix(n, ".dmg"), strings.Contains(n, ".app.tar"):
return "macos"
default:
return ""
}
}
func assetRank(kind, name string) int {
n := strings.ToLower(name)
switch kind {
case "windows":
if strings.Contains(n, "setup") {
return 2
}
return 1
case "linux":
switch {
case strings.HasSuffix(n, ".appimage"):
return 3
case strings.HasSuffix(n, ".deb"):
return 2
default:
return 1
}
default:
return 1
}
}
func pickClientFiles(assets []asset) map[string]clientFile {
sums := map[string]string{}
for _, a := range assets {
if strings.HasSuffix(strings.ToLower(a.Name), ".sha256") {
sums[strings.TrimSuffix(a.Name, ".sha256")] = a.URL
sums[strings.TrimSuffix(a.Name, ".SHA256")] = a.URL
}
}
out := map[string]clientFile{}
for _, a := range assets {
kind := classifyAsset(a.Name)
if kind == "" {
continue
}
cur, ok := out[kind]
if ok && assetRank(kind, a.Name) <= assetRank(kind, cur.Name) {
continue
}
out[kind] = clientFile{Kind: kind, Name: a.Name, URL: a.URL, Size: a.Size, ChecksumURL: sums[a.Name]}
}
return out
}
func (h *Handler) latest(ctx context.Context) (*Latest, error) {
if !h.configured() {
return nil, apperr.NotFound(CodeUnavailable, "gitea is not configured")
@@ -142,7 +212,7 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
if err != nil {
h.log.WarnContext(ctx, "app update: gitea unavailable", "err", err)
if h.cached != nil {
return h.cached, nil // устаревший ответ лучше, чем никакого
return h.cached, nil
}
return nil, apperr.NotFound(CodeUnavailable, "release is unavailable")
}
@@ -156,35 +226,77 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
if err != nil {
return nil, apperr.NotFound(CodeUnavailable, err.Error())
}
name := "pipu-music-" + version + ".apk"
var apk, sum *asset
for i := range rel.Assets {
switch rel.Assets[i].Name {
case name:
apk = &rel.Assets[i]
case name + ".sha256":
sum = &rel.Assets[i]
files := pickClientFiles(rel.Assets)
if len(files) == 0 {
return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
}
if apk, ok := files["android"]; ok {
if apk.ChecksumURL == "" {
delete(files, "android")
} else if digest, err := h.readDigest(ctx, apk.ChecksumURL); err != nil {
h.log.WarnContext(ctx, "app update: apk checksum unavailable", "err", err)
delete(files, "android")
} else {
apk.SHA256 = digest
files["android"] = apk
}
if len(files) == 0 {
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
}
}
if apk == nil || sum == nil {
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
}
sres, err := h.get(ctx, sum.URL)
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Files: files}
h.cachedAt = time.Now()
return h.cached, nil
}
func (h *Handler) readDigest(ctx context.Context, url string) (string, error) {
sres, err := h.get(ctx, url)
if err != nil {
return nil, apperr.NotFound(CodeUnavailable, "checksum is unavailable")
return "", err
}
raw, err := io.ReadAll(io.LimitReader(sres.Body, 256))
_ = sres.Body.Close()
if err != nil {
return nil, err
return "", err
}
digest := strings.ToLower(strings.Fields(string(raw) + " x")[0])
if len(digest) != 64 {
return nil, apperr.NotFound(CodeUnavailable, "malformed checksum")
return "", fmt.Errorf("malformed checksum")
}
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Size: apk.Size, SHA256: digest, apkURL: apk.URL}
h.cachedAt = time.Now()
return h.cached, nil
return digest, nil
}
func (h *Handler) fileURL(kind, version string) string {
return "/api/v1/app/download/" + kind + "/" + version
}
// GetAppClients реализует gen.StrictServerInterface.
func (h *Handler) GetAppClients(ctx context.Context, _ gen.GetAppClientsRequestObject) (gen.GetAppClientsResponseObject, error) {
l, err := h.latest(ctx)
if err != nil {
return nil, err
}
order := []string{"windows", "macos", "linux", "android"}
clients := make([]gen.AppClientFile, 0, len(l.Files))
for _, kind := range order {
f, ok := l.Files[kind]
if !ok {
continue
}
item := gen.AppClientFile{
Client: gen.AppClient(kind),
Filename: f.Name,
Size: f.Size,
DownloadUrl: h.fileURL(kind, l.Version),
}
if f.SHA256 != "" {
item.Sha256 = &f.SHA256
}
clients = append(clients, item)
}
return gen.GetAppClients200JSONResponse{
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Clients: clients,
}, nil
}
// GetAndroidLatest реализует gen.StrictServerInterface.
@@ -193,15 +305,18 @@ func (h *Handler) GetAndroidLatest(ctx context.Context, _ gen.GetAndroidLatestRe
if err != nil {
return nil, err
}
f, ok := l.android()
if !ok {
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
}
return gen.GetAndroidLatest200JSONResponse{
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: l.Size, Sha256: l.SHA256,
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: f.Size, Sha256: f.SHA256,
DownloadUrl: "/api/v1/app/android/download/" + l.Version, MinSupported: h.cfg.MinSupported,
}, nil
}
// ensure скачивает APK в кэш (если его там нет) и сверяет SHA-256 из релиза.
func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
dst := filepath.Join(h.cfg.Dir, "pipu-music-"+l.Version+".apk")
func (h *Handler) ensure(ctx context.Context, f clientFile) (string, error) {
dst := filepath.Join(h.cfg.Dir, f.Name)
if _, err := os.Stat(dst); err == nil {
return dst, nil
}
@@ -213,12 +328,12 @@ func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
if err := os.MkdirAll(h.cfg.Dir, 0o755); err != nil {
return "", err
}
res, err := h.get(ctx, l.apkURL)
res, err := h.get(ctx, f.URL)
if err != nil {
return "", err
}
defer func() { _ = res.Body.Close() }()
tmp, err := os.CreateTemp(h.cfg.Dir, ".apk-*")
tmp, err := os.CreateTemp(h.cfg.Dir, ".dl-*")
if err != nil {
return "", err
}
@@ -231,26 +346,53 @@ func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
if err := tmp.Close(); err != nil {
return "", err
}
if got := hex.EncodeToString(hash.Sum(nil)); got != l.SHA256 {
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, l.SHA256)
if f.SHA256 != "" {
if got := hex.EncodeToString(hash.Sum(nil)); got != f.SHA256 {
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, f.SHA256)
}
}
if err := os.Rename(tmp.Name(), dst); err != nil {
return "", err
}
h.cleanup(dst)
h.cleanup(f.Name)
return dst, nil
}
// cleanup оставляет в кэше только текущий APK.
func (h *Handler) cleanup(keep string) {
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*.apk"))
func (h *Handler) cleanup(keepName string) {
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*"))
ver := versionFromName(keepName)
for _, m := range matches {
if m != keep {
base := filepath.Base(m)
if ver != "" && strings.Contains(base, ver) {
continue
}
if base != keepName {
_ = os.Remove(m)
}
}
}
func versionFromName(name string) string {
m := regexp.MustCompile(`(\d+\.\d+\.\d+(?:-rc\.\d+)?)`).FindStringSubmatch(name)
if m == nil {
return ""
}
return m[1]
}
func (h *Handler) serveFile(ctx context.Context, l *Latest, kind string) (fileResponse, error) {
f, ok := l.Files[kind]
if !ok || (kind == "android" && f.SHA256 == "") {
return fileResponse{}, apperr.NotFound(CodeUnavailable, "client build is unavailable")
}
path, err := h.ensure(ctx, f)
if err != nil {
h.log.ErrorContext(ctx, "app update: download failed", "err", err, "client", kind)
return fileResponse{}, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the file")
}
return fileResponse{path: path, name: f.Name, req: httpx.RequestFrom(ctx)}, nil
}
// DownloadAndroidApk реализует gen.StrictServerInterface. Отдаётся только последняя версия.
func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) {
l, err := h.latest(ctx)
@@ -260,22 +402,36 @@ func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroi
if req.Version != l.Version {
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
}
path, err := h.ensure(ctx, l)
if err != nil {
h.log.ErrorContext(ctx, "app update: apk download failed", "err", err)
return nil, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the APK")
}
return apkResponse{path: path, name: filepath.Base(path), req: httpx.RequestFrom(ctx)}, nil
return h.serveFile(ctx, l, "android")
}
type apkResponse struct {
// DownloadAppClient реализует gen.StrictServerInterface.
func (h *Handler) DownloadAppClient(ctx context.Context, req gen.DownloadAppClientRequestObject) (gen.DownloadAppClientResponseObject, error) {
l, err := h.latest(ctx)
if err != nil {
return nil, err
}
if req.Version != l.Version {
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
}
return h.serveFile(ctx, l, string(req.Client))
}
type fileResponse struct {
path string
name string
req *http.Request
}
// VisitDownloadAndroidApkResponse реализует gen.DownloadAndroidApkResponseObject (Range — для докачки).
func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
func (a fileResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
return a.write(w)
}
func (a fileResponse) VisitDownloadAppClientResponse(w http.ResponseWriter) error {
return a.write(w)
}
func (a fileResponse) write(w http.ResponseWriter) error {
f, err := os.Open(a.path)
if err != nil {
return err
@@ -288,7 +444,11 @@ func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) erro
if a.req == nil {
return errors.New("appupdate: request missing in context")
}
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
ctype := "application/octet-stream"
if strings.HasSuffix(strings.ToLower(a.name), ".apk") {
ctype = "application/vnd.android.package-archive"
}
w.Header().Set("Content-Type", ctype)
w.Header().Set("Content-Disposition", `attachment; filename="`+a.name+`"`)
http.ServeContent(w, a.req, a.name, info.ModTime(), f)
return nil