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/src-tauri/gen/
desktop/package-lock.json 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 asset candidates (keep only selected ones in public/)
desktop/brand-assets/ desktop/brand-assets/
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "heicode-desktop", "name": "heicode-desktop",
"private": true, "private": true,
"version": "0.2.4", "version": "0.2.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1525,7 +1525,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "heicode-desktop" name = "heicode-desktop"
version = "0.2.2" version = "0.2.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"portable-pty", "portable-pty",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "heicode-desktop" name = "heicode-desktop"
version = "0.2.4" version = "0.2.5"
edition = "2021" edition = "2021"
[lib] [lib]
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json", "$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json",
"productName": "HeiCode", "productName": "HeiCode",
"version": "0.2.4", "version": "0.2.5",
"identifier": "com.heicode.desktop", "identifier": "com.heicode.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
@@ -33,7 +33,7 @@
"updater": { "updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEI3Q0FBOUQ2MTgwRkM4NjQKUldSa3lBOFkxcW5LdDhLUFZnNUlEdmllY0ZudjFhb2VXTUpSWmpkUmM1cjdoNDFwRW1MZTN5Yi8K", "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEI3Q0FBOUQ2MTgwRkM4NjQKUldSa3lBOFkxcW5LdDhLUFZnNUlEdmllY0ZudjFhb2VXTUpSWmpkUmM1cjdoNDFwRW1MZTN5Yi8K",
"endpoints": [ "endpoints": [
"https://heicodeblob.blob.core.windows.net/msi/updater/latest.json" "https://heicodeblob.blob.core.windows.net/win/updater/win-latest.json"
], ],
"windows": { "windows": {
"installMode": "passive" "installMode": "passive"
+32 -43
View File
@@ -160,64 +160,53 @@ export async function handleHeicodeAuthApi(
} }
const heicodeBase = active.baseUrl?.replace(/\/+$/, '') ?? '' const heicodeBase = active.baseUrl?.replace(/\/+$/, '') ?? ''
if (active.apiKey && heicodeBase) { const apiKey = active.apiKey || active.mcpAuth?.accessToken
if (apiKey && heicodeBase) {
try { try {
const self = await fetch(`${heicodeBase}/api/user/self`, { const sub = await fetch(`${heicodeBase}/v1/dashboard/billing/subscription`, {
method: 'GET', method: 'GET',
headers: { 'Authorization': `Bearer ${active.apiKey}` }, headers: { 'Authorization': `Bearer ${apiKey}` },
signal: AbortSignal.timeout(15_000), signal: AbortSignal.timeout(15_000),
}) })
if (self.ok) { if (sub.ok) {
const json = await self.json() as { const billing = await sub.json() as {
data?: { soft_limit_usd?: number
id?: number hard_limit_usd?: number
username?: string system_hard_limit_usd?: number
email?: string access_until?: number
display_name?: string
group?: string
status?: number
quota?: number
used_quota?: number
request_count?: 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( return new Response(
JSON.stringify({ JSON.stringify({
success: true, success: true,
data: { data: {
heicodeUserId: u.id ?? 0, heicodeUserId: 0,
username: u.username, quota: remaining,
email: u.email, usedQuota,
displayName: u.display_name,
group: u.group,
status: u.status,
quota: u.quota ?? 0,
usedQuota: u.used_quota ?? 0,
requestCount: u.request_count,
}, },
}), }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }, { status: 200, headers: { 'Content-Type': 'application/json' } },
) )
} }
} catch { } catch {
// fall through to mcp-server path // fall through
}
}
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' },
})
} }
} }
+93 -29
View File
@@ -24,7 +24,14 @@ import (
// `useManifest` to false to fall back if the blob endpoint is down. // `useManifest` to false to fall back if the blob endpoint is down.
const ( 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 // Short cache so a fresh release reaches users within ~5min without
// hammering the blob endpoint on every page hit. // hammering the blob endpoint on every page hit.
manifestCacheTTL = 5 * time.Minute manifestCacheTTL = 5 * time.Minute
@@ -54,10 +61,24 @@ type tauriManifest struct {
} `json:"platforms"` } `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 ( var (
manifestCacheMu sync.Mutex manifestCacheMu sync.Mutex
manifestCacheData *tauriManifest manifestCacheData *tauriManifest
manifestCacheLoadedAt time.Time manifestCacheLoadedAt time.Time
macManifestCacheMu sync.Mutex
macManifestCacheData *macManifest
macManifestCacheLoadedAt time.Time
) )
// fetchManifest returns the parsed Tauri updater manifest, with a short // 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" } 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 { func filenameFromURL(rawURL string) string {
if i := strings.LastIndex(rawURL, "/"); i >= 0 && i+1 < len(rawURL) { if i := strings.LastIndex(rawURL, "/"); i >= 0 && i+1 < len(rawURL) {
return rawURL[i+1:] return rawURL[i+1:]
@@ -124,24 +184,10 @@ func filenameFromURL(rawURL string) string {
return rawURL 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 // 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) { func GetDesktopDownloads(c *gin.Context) {
manifest, err := fetchManifest(c.Request.Context()) manifest, err := fetchManifest(c.Request.Context())
if err != nil { if err != nil {
@@ -166,18 +212,22 @@ func GetDesktopDownloads(c *gin.Context) {
}) })
} }
add("windows", "Windows (x64)", "windows-x86_64") add("windows", "Windows (x64)", "windows-x86_64")
add("macos_arm64", "macOS (Apple silicon)", "darwin-aarch64")
add("macos_x64", "macOS (Intel)", "darwin-x86_64") add("macos_x64", "macOS (Intel)", "darwin-x86_64")
// If the live manifest doesn't carry a Mac arm64 entry (Win-only // Mac arm64 has its own feed because the Mac CI/CD pipeline runs
// release), splice in the last known Mac binary so the download // independently of Win releases. Splice it in from the Mac manifest;
// page never goes blank on Mac users. // if the Mac feed is unreachable, the Mac button just won't appear
if _, hasMac := manifest.Platforms["darwin-aarch64"]; !hasMac { // (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{ items = append(items, desktopDownloadItem{
ID: "macos_arm64", ID: "macos_arm64",
Label: "macOS (Apple silicon, " + fallbackMacArmVersion + ")", Label: "macOS (Apple silicon)",
Filename: filenameFromURL(fallbackMacArmURL), Filename: filename,
DownloadURL: fallbackMacArmURL, DownloadURL: mac.URL,
}) })
} }
@@ -201,10 +251,24 @@ func GetDesktopDownloads(c *gin.Context) {
// only hit by stale clients. // only hit by stale clients.
func DownloadDesktopFile(c *gin.Context) { func DownloadDesktopFile(c *gin.Context) {
platform := strings.TrimSpace(c.Param("platform")) 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{ platformKey := map[string]string{
"windows": "windows-x86_64", "windows": "windows-x86_64",
"macos_arm64": "darwin-aarch64", "macos_x64": "darwin-x86_64",
"macos_x64": "darwin-x86_64",
}[platform] }[platform]
if platformKey == "" { if platformKey == "" {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{