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/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,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",
|
||||||
|
|||||||
Generated
+1
-1
@@ -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,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]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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' },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
Reference in New Issue
Block a user