feat(desktop): add client release updater
Desktop Client Build / Resolve Build Metadata (push) Successful in 19s
Frontend CI / Frontend (push) Successful in 3m20s
Backend CI / Backend (push) Successful in 16m48s
Desktop Client Build / Build Desktop Client (push) Successful in 24m46s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 37s

This commit is contained in:
2026-05-25 19:23:49 +08:00
parent 41b4a765ac
commit 5f6e9f11da
46 changed files with 5508 additions and 160 deletions
@@ -0,0 +1,136 @@
package transport
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/bootstrap"
opsapp "github.com/geo-platform/tenant-api/internal/ops/app"
opsrepo "github.com/geo-platform/tenant-api/internal/ops/repository"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
type DesktopReleaseHandler struct {
svc *opsapp.DesktopClientReleaseService
}
func NewDesktopReleaseHandler(a *bootstrap.App) *DesktopReleaseHandler {
return &DesktopReleaseHandler{
svc: opsapp.NewDesktopClientReleaseService(
opsrepo.NewDesktopClientReleaseRepository(a.DB),
a.ObjectStorage,
nil,
),
}
}
func (h *DesktopReleaseHandler) Latest(c *gin.Context) {
client := MustDesktopClient(c.Request.Context())
data, err := h.svc.CheckLatest(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{
Platform: c.Query("platform"),
Arch: c.Query("arch"),
Channel: defaultDesktopReleaseChannel(c.Query("channel"), client.Channel),
CurrentVersion: c.Query("version"),
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopReleaseHandler) DownloadURL(c *gin.Context) {
client := MustDesktopClient(c.Request.Context())
data, err := h.svc.ResolveLatestDownloadURL(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{
Platform: c.Query("platform"),
Arch: c.Query("arch"),
Channel: defaultDesktopReleaseChannel(c.Query("channel"), client.Channel),
CurrentVersion: c.Query("version"),
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopReleaseHandler) LatestPublic(c *gin.Context) {
data, err := h.svc.CheckLatest(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{
Platform: c.Query("platform"),
Arch: c.Query("arch"),
Channel: c.DefaultQuery("channel", "stable"),
CurrentVersion: c.Query("version"),
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopReleaseHandler) DownloadURLPublic(c *gin.Context) {
data, err := h.svc.ResolveLatestDownloadURL(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{
Platform: c.Query("platform"),
Arch: c.Query("arch"),
Channel: c.DefaultQuery("channel", "stable"),
CurrentVersion: c.Query("version"),
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopReleaseHandler) UpdaterFeedPublic(c *gin.Context) {
feed, err := h.svc.ResolveLatestUpdaterFeed(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{
Platform: firstNonEmpty(c.Param("platform"), c.Query("platform")),
Arch: firstNonEmpty(c.Param("arch"), c.Query("arch")),
Channel: firstNonEmpty(c.Param("channel"), c.DefaultQuery("channel", "stable")),
CurrentVersion: firstNonEmpty(c.Param("version"), c.Query("version")),
}, c.Param("feed"))
if err != nil {
response.Error(c, err)
return
}
c.Header("Content-Type", "application/x-yaml; charset=utf-8")
c.Header("Cache-Control", "no-store")
c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, sanitizeHeaderFileName(feed.FileName)))
c.Data(http.StatusOK, "application/x-yaml; charset=utf-8", feed.Content)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func defaultDesktopReleaseChannel(queryValue string, clientChannel *string) string {
if queryValue != "" {
return queryValue
}
if clientChannel != nil && *clientChannel != "" {
return *clientChannel
}
return "stable"
}
func sanitizeHeaderFileName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "latest.yml"
}
return strings.Map(func(r rune) rune {
if r == '"' || r == '\\' || r == '\r' || r == '\n' {
return -1
}
return r
}, value)
}
@@ -33,6 +33,7 @@ func RegisterRoutes(app *bootstrap.App) {
protected.POST("/auth/logout", authHandler.Logout)
desktopClientHandler := NewDesktopClientHandler(app)
desktopReleaseHandler := NewDesktopReleaseHandler(app)
desktopAccountHandler := NewDesktopAccountHandler(app)
desktopTaskHandler := NewDesktopTaskHandler(app)
desktopDispatchHandler := NewDesktopDispatchHandler(app)
@@ -40,6 +41,10 @@ func RegisterRoutes(app *bootstrap.App) {
desktopContentHandler := NewDesktopContentHandler(app)
publishJobHandler := NewPublishJobHandler(app)
protected.POST("/desktop/clients/register", desktopClientHandler.Register)
app.Engine.GET("/api/desktop/releases/latest", desktopReleaseHandler.LatestPublic)
app.Engine.GET("/api/desktop/releases/download-url", desktopReleaseHandler.DownloadURLPublic)
app.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed", desktopReleaseHandler.UpdaterFeedPublic)
app.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version", desktopReleaseHandler.UpdaterFeedPublic)
desktopAuth := app.Engine.Group("/api/desktop")
desktopAuth.Use(DesktopClientMiddleware(repository.NewDesktopClientRepository(app.DB)))
@@ -47,6 +52,8 @@ func RegisterRoutes(app *bootstrap.App) {
desktopAuth.POST("/clients/heartbeat", desktopClientHandler.Heartbeat)
desktopAuth.POST("/clients/offline", desktopClientHandler.Offline)
desktopAuth.POST("/clients/revoke", desktopClientHandler.Revoke)
desktopAuth.GET("/clients/releases/latest", desktopReleaseHandler.Latest)
desktopAuth.GET("/clients/releases/download-url", desktopReleaseHandler.DownloadURL)
desktopAuth.GET("/dispatch", desktopDispatchHandler.Dispatch)
desktopAuth.GET("/accounts", desktopAccountHandler.List)
desktopAuth.GET("/content/articles/:id", desktopContentHandler.Article)