diff --git a/cc-haha/.gitignore b/cc-haha/.gitignore index fadf0b0..989c3df 100644 --- a/cc-haha/.gitignore +++ b/cc-haha/.gitignore @@ -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/ diff --git a/cc-haha/desktop/package.json b/cc-haha/desktop/package.json index 64b63ff..f0f3da7 100644 --- a/cc-haha/desktop/package.json +++ b/cc-haha/desktop/package.json @@ -1,7 +1,7 @@ { "name": "heicode-desktop", "private": true, - "version": "0.2.4", + "version": "0.2.5", "type": "module", "scripts": { "dev": "vite", diff --git a/cc-haha/desktop/src-tauri/Cargo.lock b/cc-haha/desktop/src-tauri/Cargo.lock index a1a7bd9..f8e5bbe 100644 --- a/cc-haha/desktop/src-tauri/Cargo.lock +++ b/cc-haha/desktop/src-tauri/Cargo.lock @@ -1525,7 +1525,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "heicode-desktop" -version = "0.2.2" +version = "0.2.5" dependencies = [ "anyhow", "portable-pty", diff --git a/cc-haha/desktop/src-tauri/Cargo.toml b/cc-haha/desktop/src-tauri/Cargo.toml index 62ffa50..dc2f775 100644 --- a/cc-haha/desktop/src-tauri/Cargo.toml +++ b/cc-haha/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "heicode-desktop" -version = "0.2.4" +version = "0.2.5" edition = "2021" [lib] diff --git a/cc-haha/desktop/src-tauri/tauri.conf.json b/cc-haha/desktop/src-tauri/tauri.conf.json index 2350e9a..8bb9cf0 100644 --- a/cc-haha/desktop/src-tauri/tauri.conf.json +++ b/cc-haha/desktop/src-tauri/tauri.conf.json @@ -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" diff --git a/cc-haha/src/server/api/heicode-auth.ts b/cc-haha/src/server/api/heicode-auth.ts index 80bb971..35f14f6 100644 --- a/cc-haha/src/server/api/heicode-auth.ts +++ b/cc-haha/src/server/api/heicode-auth.ts @@ -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 } } diff --git a/heicode/controller/desktop_download.go b/heicode/controller/desktop_download.go index 0f42318..e7910dd 100644 --- a/heicode/controller/desktop_download.go +++ b/heicode/controller/desktop_download.go @@ -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{