Files
heicode-win/heicode/controller/desktop_download.go
T
chenchenandClaude Opus 4.6 f88f6a8c46 release: 0.2.1 — Windows path fix + version bump (Win & Mac)
Bump version to 0.2.1 across tauri.conf.json, package.json,
Cargo.toml, updater manifest, and Mac fallback URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-17 19:03:32 +08:00

234 lines
7.1 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 (
manifestURL = "https://heicodeblob.blob.core.windows.net/msi/updater/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"`
}
var (
manifestCacheMu sync.Mutex
manifestCacheData *tauriManifest
manifestCacheLoadedAt 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" }
func filenameFromURL(rawURL string) string {
if i := strings.LastIndex(rawURL, "/"); i >= 0 && i+1 < len(rawURL) {
return rawURL[i+1:]
}
return rawURL
}
// Fallback URLs for platforms the live manifest doesn't currently carry.
// We ship Win and Mac on different cadences (Mac builds depend on physical
// hardware access). When a release goes out Win-only, the manifest only
// names windows-x86_64 — but the Manager download page should still show
// the latest Mac binary that ever shipped to blob, so Mac users aren't
// stranded.
//
// Update these constants whenever a NEW Mac binary is uploaded to blob.
// Mac downloads on the /desktop-client page point at the .dmg installer
// (drag-to-Applications UX), NOT the .app.tar.gz which is the Tauri
// auto-updater payload. Bump these when a new Mac DMG ships to blob.
const (
fallbackMacArmVersion = "0.2.1"
fallbackMacArmURL = "https://heicodeblob.blob.core.windows.net/msi/desktop/0.2.1/HeiCode_0.2.1_aarch64.dmg"
)
// GetDesktopDownloads returns the latest released desktop client metadata
// (per platform) for authenticated users.
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_arm64", "macOS (Apple silicon)", "darwin-aarch64")
add("macos_x64", "macOS (Intel)", "darwin-x86_64")
// If the live manifest doesn't carry a Mac arm64 entry (Win-only
// release), splice in the last known Mac binary so the download
// page never goes blank on Mac users.
if _, hasMac := manifest.Platforms["darwin-aarch64"]; !hasMac {
items = append(items, desktopDownloadItem{
ID: "macos_arm64",
Label: "macOS (Apple silicon, " + fallbackMacArmVersion + ")",
Filename: filenameFromURL(fallbackMacArmURL),
DownloadURL: fallbackMacArmURL,
})
}
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"))
platformKey := map[string]string{
"windows": "windows-x86_64",
"macos_arm64": "darwin-aarch64",
"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)
}