release: 0.1.6 — balance pill under composer + Manager download fix
cc-haha desktop (0.1.6): - New sidecar route `/api/heicode-auth/balance` proxies mcp-server §4 `/api/user/heicode/balance` using the active provider's stored access token. Returns null silently on 401 / network failure so the UI doesn't flash error strips. - New BalanceBar component renders a compact pill right below the ChatInput: `[wallet icon] $X.XX 剩余 · 已用 $Y.YY · N 次`. Polls every 60s. Hidden when not logged in. - Quota → USD display uses NewAPI convention (500_000 units = $1). heicode Manager (Go controller): - `GetDesktopDownloads` rewritten to pull from the same Azure Blob updater manifest the in-app updater uses (`heicodeblob/.../ updater/latest.json`). 5-min in-process cache; stale-on-error fallback. Stops the Manager web from showing stale `Heicode_0.1.0_x64-setup.exe` after fresh releases. - `DownloadDesktopFile` kept for back-compat — it now 302s to the manifest's blob URL instead of streaming a VM-local file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "heicode-desktop",
|
"name": "heicode-desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "heicode-desktop"
|
name = "heicode-desktop"
|
||||||
version = "0.1.5"
|
version = "0.1.6"
|
||||||
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.1.5",
|
"version": "0.1.6",
|
||||||
"identifier": "com.heicode.desktop",
|
"identifier": "com.heicode.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -5,6 +5,26 @@
|
|||||||
|
|
||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
|
|
||||||
|
// mcp-server §4 balance envelope. Quota fields are integer "quota-units"
|
||||||
|
// (Heicode billing uses 500_000 units = $1 by NewAPI convention; the UI
|
||||||
|
// applies that conversion only at display time).
|
||||||
|
export type HeicodeBalance = {
|
||||||
|
heicodeUserId: number
|
||||||
|
username?: string
|
||||||
|
email?: string
|
||||||
|
displayName?: string
|
||||||
|
group?: string
|
||||||
|
status?: number
|
||||||
|
quota: number
|
||||||
|
usedQuota: number
|
||||||
|
requestCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeicodeBalanceEnvelope = {
|
||||||
|
success?: boolean
|
||||||
|
data?: HeicodeBalance
|
||||||
|
}
|
||||||
|
|
||||||
export type HeicodeProviderId = 'taijiaicloud'
|
export type HeicodeProviderId = 'taijiaicloud'
|
||||||
|
|
||||||
export type HeicodeLoginProviderInfo = {
|
export type HeicodeLoginProviderInfo = {
|
||||||
@@ -104,4 +124,13 @@ export const heicodeAuthApi = {
|
|||||||
logout() {
|
logout() {
|
||||||
return api.post<{ ok: true }>('/api/heicode-auth/logout')
|
return api.post<{ ok: true }>('/api/heicode-auth/logout')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Wraps mcp-server §4 /api/user/heicode/balance. Returns null on 401 /
|
||||||
|
// network failure so the UI can keep silent (rather than flash an error
|
||||||
|
// strip) — balance is informational, not gating.
|
||||||
|
balance(): Promise<HeicodeBalance | null> {
|
||||||
|
return api.get<HeicodeBalanceEnvelope>('/api/heicode-auth/balance')
|
||||||
|
.then((env) => env?.data ?? null)
|
||||||
|
.catch(() => null)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { heicodeAuthApi, type HeicodeBalance } from '../../api/heicodeAuth'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
|
// Quota unit convention (NewAPI / Heicode): 500,000 units = USD $1. The
|
||||||
|
// Manager web has a fancier currency converter; for the in-app pill we
|
||||||
|
// just hard-code the constant — display is informational, not used for
|
||||||
|
// charging math.
|
||||||
|
const QUOTA_PER_USD = 500_000
|
||||||
|
const POLL_MS = 60_000
|
||||||
|
|
||||||
|
function formatUsd(quota: number): string {
|
||||||
|
const usd = quota / QUOTA_PER_USD
|
||||||
|
// Show 2 decimals up to $100, 4 decimals below to make pennies legible.
|
||||||
|
return `$${usd >= 100 ? usd.toFixed(2) : usd >= 1 ? usd.toFixed(2) : usd.toFixed(4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact balance pill that lives right under the chat composer. Renders
|
||||||
|
* nothing until we have a positive answer — silent on logged-out / failed
|
||||||
|
* states so the layout doesn't flash an error strip.
|
||||||
|
*/
|
||||||
|
export function BalanceBar() {
|
||||||
|
const [balance, setBalance] = useState<HeicodeBalance | null>(null)
|
||||||
|
const t = useTranslation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
const tick = async () => {
|
||||||
|
const b = await heicodeAuthApi.balance()
|
||||||
|
if (!cancelled) setBalance(b)
|
||||||
|
}
|
||||||
|
void tick()
|
||||||
|
timer = setInterval(tick, POLL_MS)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!balance) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto mb-2 mt-1 flex w-fit max-w-full items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)]/60 px-3 py-1 text-[11px] text-[var(--color-text-secondary)]">
|
||||||
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">
|
||||||
|
account_balance_wallet
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-[var(--color-text-primary)]">
|
||||||
|
{formatUsd(balance.quota)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--color-text-tertiary)]">{t('balance.remaining')}</span>
|
||||||
|
<span className="h-3 w-px bg-[var(--color-border)]/70" aria-hidden />
|
||||||
|
<span className="tabular-nums text-[var(--color-text-tertiary)]">
|
||||||
|
{t('balance.usedPrefix')}{formatUsd(balance.usedQuota)}
|
||||||
|
</span>
|
||||||
|
{typeof balance.requestCount === 'number' && balance.requestCount > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="h-3 w-px bg-[var(--color-border)]/70" aria-hidden />
|
||||||
|
<span className="tabular-nums text-[var(--color-text-tertiary)]">
|
||||||
|
{balance.requestCount} {t('balance.requestsSuffix')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ export const en = {
|
|||||||
'sidebar.timeFilter.title': 'Filter by recency',
|
'sidebar.timeFilter.title': 'Filter by recency',
|
||||||
'sidebar.timeFilter.all': 'All',
|
'sidebar.timeFilter.all': 'All',
|
||||||
'sidebar.userCard.openProfile': 'Open account on Heicode Manager',
|
'sidebar.userCard.openProfile': 'Open account on Heicode Manager',
|
||||||
|
'balance.remaining': 'left',
|
||||||
|
'balance.usedPrefix': 'used ',
|
||||||
|
'balance.requestsSuffix': 'reqs',
|
||||||
'sidebar.collapse': 'Collapse sidebar',
|
'sidebar.collapse': 'Collapse sidebar',
|
||||||
'sidebar.expand': 'Expand sidebar',
|
'sidebar.expand': 'Expand sidebar',
|
||||||
'sidebar.logout': 'Log out',
|
'sidebar.logout': 'Log out',
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'sidebar.timeFilter.title': '按时间筛选',
|
'sidebar.timeFilter.title': '按时间筛选',
|
||||||
'sidebar.timeFilter.all': '全部',
|
'sidebar.timeFilter.all': '全部',
|
||||||
'sidebar.userCard.openProfile': '在 Heicode Manager 查看账号',
|
'sidebar.userCard.openProfile': '在 Heicode Manager 查看账号',
|
||||||
|
'balance.remaining': '剩余',
|
||||||
|
'balance.usedPrefix': '已用 ',
|
||||||
|
'balance.requestsSuffix': '次',
|
||||||
'sidebar.collapse': '折叠侧边栏',
|
'sidebar.collapse': '折叠侧边栏',
|
||||||
'sidebar.expand': '展开侧边栏',
|
'sidebar.expand': '展开侧边栏',
|
||||||
'sidebar.logout': '退出登录',
|
'sidebar.logout': '退出登录',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useTeamStore } from '../stores/teamStore'
|
|||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
import { MessageList } from '../components/chat/MessageList'
|
import { MessageList } from '../components/chat/MessageList'
|
||||||
import { ChatInput } from '../components/chat/ChatInput'
|
import { ChatInput } from '../components/chat/ChatInput'
|
||||||
|
import { BalanceBar } from '../components/chat/BalanceBar'
|
||||||
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
|
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
|
||||||
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
|
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
|
||||||
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
|
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
|
||||||
@@ -207,6 +208,7 @@ export function ActiveSession() {
|
|||||||
<TeamStatusBar />
|
<TeamStatusBar />
|
||||||
|
|
||||||
<ChatInput variant={isEmpty && !isMemberSession ? 'hero' : 'default'} />
|
<ChatInput variant={isEmpty && !isMemberSession ? 'hero' : 'default'} />
|
||||||
|
{!isMemberSession && <BalanceBar />}
|
||||||
|
|
||||||
{!isMemberSession && activeTabId ? (
|
{!isMemberSession && activeTabId ? (
|
||||||
<ComputerUsePermissionModal
|
<ComputerUsePermissionModal
|
||||||
|
|||||||
@@ -142,6 +142,31 @@ export async function handleHeicodeAuthApi(
|
|||||||
return Response.json({ ok: true })
|
return Response.json({ ok: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/heicode-auth/balance — proxies mcp-server §4 to surface
|
||||||
|
// the active user's remaining quota / usage in the desktop UI. Reads
|
||||||
|
// the access token from the active provider's mcpAuth record (same
|
||||||
|
// source the in-app TitleBar uses for the user pill).
|
||||||
|
if (action === 'balance' && req.method === 'GET') {
|
||||||
|
const { providers, activeId } = await providerService.listProviders()
|
||||||
|
const active = activeId ? providers.find((p) => p.id === activeId) : null
|
||||||
|
if (!active?.mcpAuth?.accessToken || !active.mcpAuth.managerLoginUrl) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'not_logged_in' }),
|
||||||
|
{ status: 401, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const base = active.mcpAuth.managerLoginUrl.replace(/\/+$/, '')
|
||||||
|
const upstream = await fetch(`${base}/api/user/heicode/balance`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': `Bearer ${active.mcpAuth.accessToken}` },
|
||||||
|
})
|
||||||
|
const body = await upstream.text()
|
||||||
|
return new Response(body, {
|
||||||
|
status: upstream.status,
|
||||||
|
headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// /api/heicode-auth/oauth/*
|
// /api/heicode-auth/oauth/*
|
||||||
if (action === 'oauth') {
|
if (action === 'oauth') {
|
||||||
if (subAction === 'start' && req.method === 'POST') {
|
if (subAction === 'start' && req.method === 'POST') {
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/heicode/manager/common"
|
"github.com/heicode/manager/common"
|
||||||
"github.com/heicode/manager/i18n"
|
"github.com/heicode/manager/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
const envDesktopVersion = "HEICODE_DESKTOP_CLIENT_VERSION"
|
// Heicode desktop downloads pull from the same Azure Blob updater manifest
|
||||||
|
// (`https://heicodeblob.blob.core.windows.net/msi/updater/latest.json`) that
|
||||||
|
// the in-app Tauri updater consumes. The Manager front-end's download page
|
||||||
|
// therefore always reflects the latest release without having to manage
|
||||||
|
// VM-local file copies + env vars.
|
||||||
|
//
|
||||||
|
// The previous file-based implementation (HEICODE_DESKTOP_FILE_WINDOWS etc.)
|
||||||
|
// is preserved at the bottom of this file in a commented-out block; flip
|
||||||
|
// `useManifest` to false to fall back if the blob endpoint is down.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
envWindows = "HEICODE_DESKTOP_FILE_WINDOWS"
|
manifestURL = "https://heicodeblob.blob.core.windows.net/msi/updater/latest.json"
|
||||||
envMacArm = "HEICODE_DESKTOP_FILE_MACOS_ARM64"
|
// Short cache so a fresh release reaches users within ~5min without
|
||||||
envMacIntel = "HEICODE_DESKTOP_FILE_MACOS_X64"
|
// hammering the blob endpoint on every page hit.
|
||||||
envNotes = "HEICODE_DESKTOP_DOWNLOAD_NOTES"
|
manifestCacheTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type desktopDownloadItem struct {
|
type desktopDownloadItem struct {
|
||||||
@@ -33,93 +43,161 @@ type desktopDownloadsPayload struct {
|
|||||||
Items []desktopDownloadItem `json:"items"`
|
Items []desktopDownloadItem `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveDesktopPath(envKey string) string {
|
// tauriManifest mirrors the schema produced by scripts/release-desktop.mjs.
|
||||||
p := strings.TrimSpace(os.Getenv(envKey))
|
type tauriManifest struct {
|
||||||
if p == "" {
|
Version string `json:"version"`
|
||||||
return ""
|
Notes string `json:"notes"`
|
||||||
}
|
PubDate string `json:"pub_date"`
|
||||||
if abs, err := filepath.Abs(p); err == nil {
|
Platforms map[string]struct {
|
||||||
return abs
|
Signature string `json:"signature"`
|
||||||
}
|
URL string `json:"url"`
|
||||||
return filepath.Clean(p)
|
} `json:"platforms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func safeFileBase(path string) string {
|
var (
|
||||||
return filepath.Base(path)
|
manifestCacheMu sync.Mutex
|
||||||
}
|
manifestCacheData *tauriManifest
|
||||||
|
manifestCacheLoadedAt time.Time
|
||||||
|
)
|
||||||
|
|
||||||
// GetDesktopDownloads returns metadata for authenticated users (login required via UserAuth).
|
// fetchManifest returns the parsed Tauri updater manifest, with a short
|
||||||
func GetDesktopDownloads(c *gin.Context) {
|
// in-process cache. Network failures while a cached copy exists return
|
||||||
version := common.GetEnvOrDefaultString(envDesktopVersion, "")
|
// the stale copy rather than 500-ing.
|
||||||
if version == "" {
|
func fetchManifest(ctx context.Context) (*tauriManifest, error) {
|
||||||
version = "0.0.0"
|
manifestCacheMu.Lock()
|
||||||
|
defer manifestCacheMu.Unlock()
|
||||||
|
|
||||||
|
if manifestCacheData != nil && time.Since(manifestCacheLoadedAt) < manifestCacheTTL {
|
||||||
|
return manifestCacheData, nil
|
||||||
}
|
}
|
||||||
notes := strings.TrimSpace(os.Getenv(envNotes))
|
|
||||||
|
|
||||||
items := make([]desktopDownloadItem, 0, 3)
|
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
add := func(id, label, envKey string) {
|
defer cancel()
|
||||||
p := resolveDesktopPath(envKey)
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, manifestURL, nil)
|
||||||
if p == "" {
|
if err != nil {
|
||||||
return
|
if manifestCacheData != nil {
|
||||||
|
return manifestCacheData, nil
|
||||||
}
|
}
|
||||||
if st, err := os.Stat(p); err != nil || st.IsDir() {
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if manifestCacheData != nil {
|
||||||
|
return manifestCacheData, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if manifestCacheData != nil {
|
||||||
|
return manifestCacheData, nil
|
||||||
|
}
|
||||||
|
return nil, &manifestError{status: resp.StatusCode}
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
if manifestCacheData != nil {
|
||||||
|
return manifestCacheData, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m tauriManifest
|
||||||
|
if err := common.Unmarshal(body, &m); err != nil {
|
||||||
|
if manifestCacheData != nil {
|
||||||
|
return manifestCacheData, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
manifestCacheData = &m
|
||||||
|
manifestCacheLoadedAt = time.Now()
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifestError struct{ status int }
|
||||||
|
|
||||||
|
func (e *manifestError) Error() string { return "manifest fetch returned non-200" }
|
||||||
|
|
||||||
|
func filenameFromURL(rawURL string) string {
|
||||||
|
if i := strings.LastIndex(rawURL, "/"); i >= 0 && i+1 < len(rawURL) {
|
||||||
|
return rawURL[i+1:]
|
||||||
|
}
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDesktopDownloads returns the latest released desktop client metadata
|
||||||
|
// (per platform) for authenticated users.
|
||||||
|
func GetDesktopDownloads(c *gin.Context) {
|
||||||
|
manifest, err := fetchManifest(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": common.TranslateMessage(c, i18n.MsgRetryLater),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]desktopDownloadItem, 0, len(manifest.Platforms))
|
||||||
|
add := func(id, label, key string) {
|
||||||
|
p, ok := manifest.Platforms[key]
|
||||||
|
if !ok || strings.TrimSpace(p.URL) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items = append(items, desktopDownloadItem{
|
items = append(items, desktopDownloadItem{
|
||||||
ID: id,
|
ID: id,
|
||||||
Label: label,
|
Label: label,
|
||||||
Filename: safeFileBase(p),
|
Filename: filenameFromURL(p.URL),
|
||||||
DownloadURL: "/api/user/desktop-downloads/file/" + id,
|
DownloadURL: p.URL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
add("windows", "Windows (x64)", envWindows)
|
add("windows", "Windows (x64)", "windows-x86_64")
|
||||||
add("macos_arm64", "macOS (Apple silicon)", envMacArm)
|
add("macos_arm64", "macOS (Apple silicon)", "darwin-aarch64")
|
||||||
add("macos_x64", "macOS (Intel)", envMacIntel)
|
add("macos_x64", "macOS (Intel)", "darwin-x86_64")
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
"data": desktopDownloadsPayload{
|
"data": desktopDownloadsPayload{
|
||||||
Version: version,
|
Version: manifest.Version,
|
||||||
Notes: notes,
|
Notes: manifest.Notes,
|
||||||
Items: items,
|
Items: items,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var desktopPlatformEnv = map[string]string{
|
// DownloadDesktopFile is kept for backward compatibility with any link
|
||||||
"windows": envWindows,
|
// that still points at `/api/user/desktop-downloads/file/<platform>` —
|
||||||
"macos_arm64": envMacArm,
|
// it 302s to the blob URL the manifest reports. New front-end builds use
|
||||||
"macos_x64": envMacIntel,
|
// the direct blob URL returned by GetDesktopDownloads, so this path is
|
||||||
}
|
// only hit by stale clients.
|
||||||
|
|
||||||
// DownloadDesktopFile streams an installer for logged-in users only.
|
|
||||||
func DownloadDesktopFile(c *gin.Context) {
|
func DownloadDesktopFile(c *gin.Context) {
|
||||||
platform := strings.TrimSpace(c.Param("platform"))
|
platform := strings.TrimSpace(c.Param("platform"))
|
||||||
envKey, ok := desktopPlatformEnv[platform]
|
platformKey := map[string]string{
|
||||||
if !ok {
|
"windows": "windows-x86_64",
|
||||||
|
"macos_arm64": "darwin-aarch64",
|
||||||
|
"macos_x64": "darwin-x86_64",
|
||||||
|
}[platform]
|
||||||
|
if platformKey == "" {
|
||||||
c.JSON(http.StatusNotFound, gin.H{
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": common.TranslateMessage(c, i18n.MsgInvalidParams),
|
"message": common.TranslateMessage(c, i18n.MsgInvalidParams),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
path := resolveDesktopPath(envKey)
|
manifest, err := fetchManifest(c.Request.Context())
|
||||||
if path == "" {
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": common.TranslateMessage(c, i18n.MsgRetryLater),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry, ok := manifest.Platforms[platformKey]
|
||||||
|
if !ok || strings.TrimSpace(entry.URL) == "" {
|
||||||
c.JSON(http.StatusNotFound, gin.H{
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": common.TranslateMessage(c, i18n.MsgNotFound),
|
"message": common.TranslateMessage(c, i18n.MsgNotFound),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
st, err := os.Stat(path)
|
c.Redirect(http.StatusFound, entry.URL)
|
||||||
if err != nil || st.IsDir() {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"message": common.TranslateMessage(c, i18n.MsgNotFound),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Header("Content-Disposition", "attachment; filename=\""+safeFileBase(path)+"\"")
|
|
||||||
c.File(path)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user