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/` — // 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) }