Files
heicode-mananger/heicode/controller/desktop_download.go
T

298 lines
9.0 KiB
Go

package controller
import (
"context"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/i18n"
)
// Heicode desktop downloads pull from the same Azure Blob updater manifest
// (`https://heicodeblob.blob.core.windows.net/msi/updater/latest.json`) that
// the in-app Tauri updater consumes. The Manager front-end's download page
// therefore always reflects the latest release without having to manage
// VM-local file copies + env vars.
//
// The previous file-based implementation (HEICODE_DESKTOP_FILE_WINDOWS etc.)
// is preserved at the bottom of this file in a commented-out block; flip
// `useManifest` to false to fall back if the blob endpoint is down.
const (
// Windows updater feed (Tauri updater format). Lives in the dedicated
// `win` container so the Win release pipeline owns its own storage
// namespace independent of Mac. Both the Manager download page and the
// in-app Tauri auto-updater read this URL.
manifestURL = "https://heicodeblob.blob.core.windows.net/win/updater/win-latest.json"
// Mac CI/CD-maintained feed (custom format, sha256 verified .dmg).
// Still in the `msi` container until the Mac pipeline migrates.
macManifestURL = "https://heicodeblob.blob.core.windows.net/msi/updater/mac-latest.json"
// Short cache so a fresh release reaches users within ~5min without
// hammering the blob endpoint on every page hit.
manifestCacheTTL = 5 * time.Minute
)
type desktopDownloadItem struct {
ID string `json:"id"`
Label string `json:"label"`
Filename string `json:"filename"`
DownloadURL string `json:"downloadUrl"`
}
type desktopDownloadsPayload struct {
Version string `json:"version"`
Notes string `json:"notes,omitempty"`
Items []desktopDownloadItem `json:"items"`
}
// tauriManifest mirrors the schema produced by scripts/release-desktop.mjs.
type tauriManifest struct {
Version string `json:"version"`
Notes string `json:"notes"`
PubDate string `json:"pub_date"`
Platforms map[string]struct {
Signature string `json:"signature"`
URL string `json:"url"`
} `json:"platforms"`
}
// macManifest mirrors the schema produced by the Mac CI/CD release pipeline
// (uploaded to desktop/latest.json). It is a flat per-platform record, not
// the Tauri updater format.
type macManifest struct {
Version string `json:"version"`
Platform string `json:"platform"`
URL string `json:"url"`
File string `json:"file"`
}
var (
manifestCacheMu sync.Mutex
manifestCacheData *tauriManifest
manifestCacheLoadedAt time.Time
macManifestCacheMu sync.Mutex
macManifestCacheData *macManifest
macManifestCacheLoadedAt time.Time
)
// fetchManifest returns the parsed Tauri updater manifest, with a short
// in-process cache. Network failures while a cached copy exists return
// the stale copy rather than 500-ing.
func fetchManifest(ctx context.Context) (*tauriManifest, error) {
manifestCacheMu.Lock()
defer manifestCacheMu.Unlock()
if manifestCacheData != nil && time.Since(manifestCacheLoadedAt) < manifestCacheTTL {
return manifestCacheData, nil
}
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, manifestURL, nil)
if err != nil {
if manifestCacheData != nil {
return manifestCacheData, nil
}
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
if manifestCacheData != nil {
return manifestCacheData, nil
}
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if manifestCacheData != nil {
return manifestCacheData, nil
}
return nil, &manifestError{status: resp.StatusCode}
}
body, err := io.ReadAll(resp.Body)
if err != nil {
if manifestCacheData != nil {
return manifestCacheData, nil
}
return nil, err
}
var m tauriManifest
if err := common.Unmarshal(body, &m); err != nil {
if manifestCacheData != nil {
return manifestCacheData, nil
}
return nil, err
}
manifestCacheData = &m
manifestCacheLoadedAt = time.Now()
return &m, nil
}
type manifestError struct{ status int }
func (e *manifestError) Error() string { return "manifest fetch returned non-200" }
// fetchMacManifest reads the Mac CI/CD-maintained feed at desktop/latest.json.
// On failure we fall back to whichever stale value we last had; if no copy
// exists, we return nil (the caller treats Mac as "not yet released" and
// uses the hardcoded fallback below).
func fetchMacManifest(ctx context.Context) *macManifest {
macManifestCacheMu.Lock()
defer macManifestCacheMu.Unlock()
if macManifestCacheData != nil && time.Since(macManifestCacheLoadedAt) < manifestCacheTTL {
return macManifestCacheData
}
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, macManifestURL, nil)
if err != nil {
return macManifestCacheData
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return macManifestCacheData
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return macManifestCacheData
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return macManifestCacheData
}
var m macManifest
if err := common.Unmarshal(body, &m); err != nil {
return macManifestCacheData
}
macManifestCacheData = &m
macManifestCacheLoadedAt = time.Now()
return &m
}
func filenameFromURL(rawURL string) string {
if i := strings.LastIndex(rawURL, "/"); i >= 0 && i+1 < len(rawURL) {
return rawURL[i+1:]
}
return rawURL
}
// GetDesktopDownloads returns the latest released desktop client metadata
// (per platform) for authenticated users. Win and Mac live on separate
// feeds (updater/win-latest.json + updater/mac-latest.json) so platform
// release cadences don't collide.
func GetDesktopDownloads(c *gin.Context) {
manifest, err := fetchManifest(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgRetryLater),
})
return
}
items := make([]desktopDownloadItem, 0, 3)
add := func(id, label, key string) {
p, ok := manifest.Platforms[key]
if !ok || strings.TrimSpace(p.URL) == "" {
return
}
items = append(items, desktopDownloadItem{
ID: id,
Label: label,
Filename: filenameFromURL(p.URL),
DownloadURL: p.URL,
})
}
add("windows", "Windows (x64)", "windows-x86_64")
add("macos_x64", "macOS (Intel)", "darwin-x86_64")
// Mac arm64 has its own feed because the Mac CI/CD pipeline runs
// independently of Win releases. Splice it in from the Mac manifest;
// if the Mac feed is unreachable, the Mac button just won't appear
// (the Win download still works).
if mac := fetchMacManifest(c.Request.Context()); mac != nil && strings.TrimSpace(mac.URL) != "" {
filename := mac.File
if filename == "" {
filename = filenameFromURL(mac.URL)
}
items = append(items, desktopDownloadItem{
ID: "macos_arm64",
Label: "macOS (Apple silicon)",
Filename: filename,
DownloadURL: mac.URL,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
// `Notes` deliberately dropped here — the download page is for the
// download links + version number only; users don't want a wall of
// release-note text below it.
"data": desktopDownloadsPayload{
Version: manifest.Version,
Items: items,
},
})
}
// DownloadDesktopFile is kept for backward compatibility with any link
// that still points at `/api/user/desktop-downloads/file/<platform>` —
// it 302s to the blob URL the manifest reports. New front-end builds use
// the direct blob URL returned by GetDesktopDownloads, so this path is
// only hit by stale clients.
func DownloadDesktopFile(c *gin.Context) {
platform := strings.TrimSpace(c.Param("platform"))
// Mac arm64 lives on its own feed (mac-latest.json) so handle it
// before falling through to the Win manifest lookup.
if platform == "macos_arm64" {
if mac := fetchMacManifest(c.Request.Context()); mac != nil && strings.TrimSpace(mac.URL) != "" {
c.Redirect(http.StatusFound, mac.URL)
return
}
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgNotFound),
})
return
}
platformKey := map[string]string{
"windows": "windows-x86_64",
"macos_x64": "darwin-x86_64",
}[platform]
if platformKey == "" {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgInvalidParams),
})
return
}
manifest, err := fetchManifest(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgRetryLater),
})
return
}
entry, ok := manifest.Platforms[platformKey]
if !ok || strings.TrimSpace(entry.URL) == "" {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgNotFound),
})
return
}
c.Redirect(http.StatusFound, entry.URL)
}