Win release artifacts now live in their own Azure Blob container
(heicodeblob/win/) — Manager download page and Tauri auto-updater both
point at win/updater/win-latest.json. Mac stays on the existing custom
sha256-verified mac-latest.json until the Mac CI/CD pipeline migrates
to Tauri minisign format.
Also fixes the in-app balance pill: /v1/dashboard/billing/{subscription,usage}
on the Manager accepts the `sk-` channel token, /api/user/self does not.
Switched the proxy in heicode-auth to use the billing endpoints so the
BalanceBar actually renders real remaining/used quota.
- cc-haha/desktop/src-tauri/tauri.conf.json: updater endpoint → win container
- cc-haha/src/server/api/heicode-auth.ts: balance via /v1/dashboard/billing
- cc-haha/desktop version bump 0.2.4 → 0.2.5 (next Win release)
- cc-haha/.gitignore: exclude installer artifacts (msi, dmg, sig, …)
- heicode/controller/desktop_download.go: dual-feed (Win Tauri + Mac custom)
298 lines
9.0 KiB
Go
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)
|
|
}
|