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>
234 lines
7.1 KiB
Go
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)
|
|
}
|