feat: split Win/Mac updater feeds + new heicodeblob win container

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)
This commit is contained in:
2026-05-19 17:03:15 +08:00
parent c10a8e2cb1
commit 15e26d415e
7 changed files with 148 additions and 77 deletions
+18
View File
@@ -30,6 +30,24 @@ desktop/src-tauri/target/
desktop/src-tauri/gen/
desktop/package-lock.json
# Desktop installer artifacts (any version, any location). These are
# uploaded to Azure Blob during release — never check into git.
# NOTE: *.exe is NOT here because Go test binaries also use that extension
# and root .gitignore already covers Go output; for installer .exe
# (NSIS setup) rely on desktop/src-tauri/target/ exclusion above.
*.msi
*.msi.sig
*.dmg
*.app.tar.gz
*.app.tar.gz.sig
*.AppImage
*.AppImage.sig
*.deb
*.rpm
# Stray scratch files (single-dash filename from accidental redirects)
desktop/-
# Desktop brand asset candidates (keep only selected ones in public/)
desktop/brand-assets/
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "heicode-desktop",
"private": true,
"version": "0.2.4",
"version": "0.2.5",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1525,7 +1525,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "heicode-desktop"
version = "0.2.2"
version = "0.2.5"
dependencies = [
"anyhow",
"portable-pty",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "heicode-desktop"
version = "0.2.4"
version = "0.2.5"
edition = "2021"
[lib]
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json",
"productName": "HeiCode",
"version": "0.2.4",
"version": "0.2.5",
"identifier": "com.heicode.desktop",
"build": {
"frontendDist": "../dist",
@@ -33,7 +33,7 @@
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEI3Q0FBOUQ2MTgwRkM4NjQKUldSa3lBOFkxcW5LdDhLUFZnNUlEdmllY0ZudjFhb2VXTUpSWmpkUmM1cjdoNDFwRW1MZTN5Yi8K",
"endpoints": [
"https://heicodeblob.blob.core.windows.net/msi/updater/latest.json"
"https://heicodeblob.blob.core.windows.net/win/updater/win-latest.json"
],
"windows": {
"installMode": "passive"
+32 -43
View File
@@ -160,64 +160,53 @@ export async function handleHeicodeAuthApi(
}
const heicodeBase = active.baseUrl?.replace(/\/+$/, '') ?? ''
if (active.apiKey && heicodeBase) {
const apiKey = active.apiKey || active.mcpAuth?.accessToken
if (apiKey && heicodeBase) {
try {
const self = await fetch(`${heicodeBase}/api/user/self`, {
const sub = await fetch(`${heicodeBase}/v1/dashboard/billing/subscription`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${active.apiKey}` },
headers: { 'Authorization': `Bearer ${apiKey}` },
signal: AbortSignal.timeout(15_000),
})
if (self.ok) {
const json = await self.json() as {
data?: {
id?: number
username?: string
email?: string
display_name?: string
group?: string
status?: number
quota?: number
used_quota?: number
request_count?: number
}
if (sub.ok) {
const billing = await sub.json() as {
soft_limit_usd?: number
hard_limit_usd?: number
system_hard_limit_usd?: number
access_until?: number
}
const u = json.data ?? {}
const totalQuota = billing.hard_limit_usd ?? billing.soft_limit_usd ?? 0
let usedQuota = 0
try {
const usage = await fetch(`${heicodeBase}/v1/dashboard/billing/usage`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${apiKey}` },
signal: AbortSignal.timeout(15_000),
})
if (usage.ok) {
const usageJson = await usage.json() as { total_usage?: number }
usedQuota = usageJson.total_usage ?? 0
}
} catch {
// usage fetch failed — still return subscription data with 0 used
}
const remaining = Math.max(0, totalQuota - usedQuota)
return new Response(
JSON.stringify({
success: true,
data: {
heicodeUserId: u.id ?? 0,
username: u.username,
email: u.email,
displayName: u.display_name,
group: u.group,
status: u.status,
quota: u.quota ?? 0,
usedQuota: u.used_quota ?? 0,
requestCount: u.request_count,
heicodeUserId: 0,
quota: remaining,
usedQuota,
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}
} catch {
// fall through to mcp-server path
}
}
if (active.mcpAuth?.accessToken && active.mcpAuth.managerLoginUrl) {
const mcpBase = active.mcpAuth.managerLoginUrl.replace(/\/+$/, '')
const upstream = await fetch(`${mcpBase}/api/user/heicode/balance`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${active.mcpAuth.accessToken}` },
signal: AbortSignal.timeout(15_000),
})
if (upstream.ok) {
const body = await upstream.text()
return new Response(body, {
status: 200,
headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' },
})
// fall through
}
}
+93 -29
View File
@@ -24,7 +24,14 @@ import (
// `useManifest` to false to fall back if the blob endpoint is down.
const (
manifestURL = "https://heicodeblob.blob.core.windows.net/msi/updater/latest.json"
// 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
@@ -54,10 +61,24 @@ type tauriManifest struct {
} `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
@@ -117,6 +138,45 @@ 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:]
@@ -124,24 +184,10 @@ func filenameFromURL(rawURL string) string {
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.
// (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 {
@@ -166,18 +212,22 @@ func GetDesktopDownloads(c *gin.Context) {
})
}
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 {
// 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, " + fallbackMacArmVersion + ")",
Filename: filenameFromURL(fallbackMacArmURL),
DownloadURL: fallbackMacArmURL,
Label: "macOS (Apple silicon)",
Filename: filename,
DownloadURL: mac.URL,
})
}
@@ -201,10 +251,24 @@ func GetDesktopDownloads(c *gin.Context) {
// 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_arm64": "darwin-aarch64",
"macos_x64": "darwin-x86_64",
"windows": "windows-x86_64",
"macos_x64": "darwin-x86_64",
}[platform]
if platformKey == "" {
c.JSON(http.StatusNotFound, gin.H{