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:
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "heicode-desktop",
|
||||
"private": true,
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -1525,7 +1525,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "heicode-desktop"
|
||||
version = "0.2.2"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"portable-pty",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "heicode-desktop"
|
||||
version = "0.2.4"
|
||||
version = "0.2.5"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 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
|
||||
}
|
||||
const u = json.data ?? {}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,9 +251,23 @@ 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",
|
||||
}[platform]
|
||||
if platformKey == "" {
|
||||
|
||||
Reference in New Issue
Block a user