feat: add downloadable release management for desktop client
- Extend DesktopClientReleaseCheckResponse with platform, arch, and channel fields. - Introduce DesktopClientDownloadableRelease and DesktopClientDownloadableReleaseListResponse interfaces for managing downloadable releases. - Implement ListDownloadable method in DesktopClientReleaseService to retrieve downloadable releases based on the specified channel. - Update DesktopClientReleaseCheckResult to include platform, arch, and channel information. - Add resolveInstallerDownloadURL method to handle download URL resolution for releases. - Create new endpoint in DesktopReleaseHandler for listing downloadable releases. - Update router to include the new downloadable releases route. - Enhance tests to cover the new functionality for resolving download URLs and listing downloadable releases.
This commit is contained in:
@@ -254,13 +254,11 @@ defineExpose({
|
||||
|
||||
<div v-if="variable.type !== 'checkbox' && variable.type !== 'select'" class="form-item">
|
||||
<label>{{ t('kol.manage.variable.placeholder') }}</label>
|
||||
<a-input
|
||||
<a-textarea
|
||||
:value="variable.placeholder"
|
||||
size="small"
|
||||
@input="
|
||||
(e: Event) =>
|
||||
updateVariable(index, { placeholder: (e.target as HTMLInputElement).value })
|
||||
"
|
||||
:rows="3"
|
||||
@update:value="(val: string) => updateVariable(index, { placeholder: val })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ import type {
|
||||
CreatePublishJobRequest,
|
||||
CreatePublishJobResponse,
|
||||
DesktopAccountInfo,
|
||||
DesktopClientDownloadableRelease,
|
||||
DesktopClientDownloadableReleaseListResponse,
|
||||
DesktopClientReleaseCheckResponse,
|
||||
DesktopClientReleaseDownloadURLResponse,
|
||||
FolderDeletePreview,
|
||||
GenerateFromRuleRequest,
|
||||
GenerateFromRuleResponse,
|
||||
@@ -1285,6 +1289,80 @@ export const tenantAccountsApi = {
|
||||
},
|
||||
}
|
||||
|
||||
export const desktopClientApi = {
|
||||
downloadableReleases(channel = 'stable') {
|
||||
return apiClient.get<DesktopClientDownloadableReleaseListResponse>(
|
||||
'/api/tenant/desktop-client/releases/downloadable',
|
||||
{ params: { channel } },
|
||||
)
|
||||
},
|
||||
publicLatest(params: { platform: string; arch: string; channel?: string; version?: string }) {
|
||||
return publicClient.get<DesktopClientReleaseCheckResponse>('/api/desktop/releases/latest', {
|
||||
params: {
|
||||
platform: params.platform,
|
||||
arch: params.arch,
|
||||
channel: params.channel ?? 'stable',
|
||||
version: params.version ?? '0.0.0',
|
||||
},
|
||||
})
|
||||
},
|
||||
publicDownloadURL(params: {
|
||||
platform: string
|
||||
arch: string
|
||||
channel?: string
|
||||
version?: string
|
||||
}) {
|
||||
return publicClient.get<DesktopClientReleaseDownloadURLResponse>(
|
||||
'/api/desktop/releases/download-url',
|
||||
{
|
||||
params: {
|
||||
platform: params.platform,
|
||||
arch: params.arch,
|
||||
channel: params.channel ?? 'stable',
|
||||
version: params.version ?? '0.0.0',
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
async publicFallbackDownloadableReleases(): Promise<DesktopClientDownloadableReleaseListResponse> {
|
||||
const targets = [
|
||||
{ platform: 'win32', arch: 'x64' },
|
||||
{ platform: 'darwin', arch: 'arm64' },
|
||||
]
|
||||
const results = await Promise.allSettled(
|
||||
targets.map((target) => this.publicLatest({ ...target, channel: 'stable' })),
|
||||
)
|
||||
return {
|
||||
items: results.flatMap((result, index) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
return []
|
||||
}
|
||||
const release = result.value
|
||||
const target = targets[index]
|
||||
const item: DesktopClientDownloadableRelease = {
|
||||
id: index + 1,
|
||||
platform: release.platform ?? target.platform,
|
||||
arch: release.arch ?? target.arch,
|
||||
channel: release.channel ?? 'stable',
|
||||
version: release.latest_version,
|
||||
download_url: release.download_url ?? null,
|
||||
oss_object_key: release.oss_object_key ?? null,
|
||||
file_name: release.file_name ?? null,
|
||||
file_size_bytes: release.file_size_bytes ?? null,
|
||||
sha256: release.sha256 ?? null,
|
||||
release_notes: release.release_notes ?? null,
|
||||
published_at: release.published_at ?? null,
|
||||
updated_at: release.published_at ?? new Date(0).toISOString(),
|
||||
force_update: release.force_update,
|
||||
min_supported_version: release.min_supported_version,
|
||||
download_source: release.download_source,
|
||||
}
|
||||
return [item]
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const publishJobsApi = {
|
||||
create(payload: CreatePublishJobRequest) {
|
||||
return apiClient.post<CreatePublishJobResponse, CreatePublishJobRequest>(
|
||||
|
||||
+1140
-194
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,9 @@ export interface DesktopClientReleaseCheckResponse {
|
||||
update_available: boolean
|
||||
latest_version: string
|
||||
current_version: string
|
||||
platform?: 'darwin' | 'win32' | 'linux' | string
|
||||
arch?: 'universal' | 'x64' | 'arm64' | 'ia32' | string
|
||||
channel?: string
|
||||
min_supported_version: string | null
|
||||
force_update: boolean
|
||||
download_source: 'oss' | 'custom'
|
||||
@@ -164,10 +167,33 @@ export interface DesktopClientReleaseCheckResponse {
|
||||
}
|
||||
|
||||
export interface DesktopClientReleaseDownloadURLResponse {
|
||||
download_url: string
|
||||
download_url?: string | null
|
||||
expires_in?: number | null
|
||||
}
|
||||
|
||||
export interface DesktopClientDownloadableRelease {
|
||||
id: number
|
||||
platform: 'darwin' | 'win32' | 'linux' | string
|
||||
arch: 'universal' | 'x64' | 'arm64' | 'ia32' | string
|
||||
channel: string
|
||||
version: string
|
||||
download_url?: string | null
|
||||
file_name?: string | null
|
||||
file_size_bytes?: number | null
|
||||
sha256?: string | null
|
||||
release_notes?: string | null
|
||||
published_at?: string | null
|
||||
updated_at: string
|
||||
force_update: boolean
|
||||
min_supported_version?: string | null
|
||||
download_source: 'oss' | 'custom' | string
|
||||
oss_object_key?: string | null
|
||||
}
|
||||
|
||||
export interface DesktopClientDownloadableReleaseListResponse {
|
||||
items: DesktopClientDownloadableRelease[]
|
||||
}
|
||||
|
||||
export type DesktopClientUpdateStage =
|
||||
| 'idle'
|
||||
| 'checking'
|
||||
|
||||
@@ -100,6 +100,29 @@ type DesktopClientReleaseListResult struct {
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
type DesktopClientDownloadableRelease struct {
|
||||
ID int64 `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
Channel string `json:"channel"`
|
||||
Version string `json:"version"`
|
||||
DownloadURL *string `json:"download_url"`
|
||||
FileName *string `json:"file_name"`
|
||||
FileSizeBytes *int64 `json:"file_size_bytes"`
|
||||
SHA256 *string `json:"sha256"`
|
||||
ReleaseNotes *string `json:"release_notes"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ForceUpdate bool `json:"force_update"`
|
||||
MinSupportedVersion *string `json:"min_supported_version"`
|
||||
DownloadSource string `json:"download_source"`
|
||||
OSSObjectKey *string `json:"oss_object_key"`
|
||||
}
|
||||
|
||||
type DesktopClientDownloadableReleaseListResult struct {
|
||||
Items []DesktopClientDownloadableRelease `json:"items"`
|
||||
}
|
||||
|
||||
type DesktopClientReleaseInput struct {
|
||||
Platform string
|
||||
Arch string
|
||||
@@ -151,6 +174,9 @@ type DesktopClientReleaseCheckResult struct {
|
||||
UpdateAvailable bool `json:"update_available"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
Channel string `json:"channel"`
|
||||
MinSupportedVersion *string `json:"min_supported_version"`
|
||||
ForceUpdate bool `json:"force_update"`
|
||||
DownloadSource string `json:"download_source"`
|
||||
@@ -241,6 +267,55 @@ func (s *DesktopClientReleaseService) List(ctx context.Context, in DesktopClient
|
||||
return &DesktopClientReleaseListResult{Items: views, Total: total, Page: page, Size: size}, nil
|
||||
}
|
||||
|
||||
func (s *DesktopClientReleaseService) ListDownloadable(ctx context.Context, channel string) (*DesktopClientDownloadableReleaseListResult, error) {
|
||||
enabled := true
|
||||
normalizedChannel := normalizeDesktopReleaseEnum(channel)
|
||||
if normalizedChannel == "" {
|
||||
normalizedChannel = "stable"
|
||||
}
|
||||
if !isValidDesktopReleaseToken(normalizedChannel) {
|
||||
return nil, response.ErrBadRequest(40062, "invalid_desktop_release_channel", "发布渠道格式无效")
|
||||
}
|
||||
|
||||
items, _, err := s.releases.List(ctx, repository.DesktopClientReleaseFilter{
|
||||
Channel: normalizedChannel,
|
||||
Enabled: &enabled,
|
||||
Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
views := make([]DesktopClientDownloadableRelease, 0, len(items))
|
||||
for i := range items {
|
||||
var downloadURL *string
|
||||
if items[i].DownloadSource == DesktopClientDownloadSourceCustom {
|
||||
if value := strings.TrimSpace(stringPointerValue(items[i].CustomDownloadURL)); value != "" {
|
||||
downloadURL = &value
|
||||
}
|
||||
}
|
||||
views = append(views, DesktopClientDownloadableRelease{
|
||||
ID: items[i].ID,
|
||||
Platform: items[i].Platform,
|
||||
Arch: items[i].Arch,
|
||||
Channel: items[i].Channel,
|
||||
Version: items[i].Version,
|
||||
DownloadURL: downloadURL,
|
||||
FileName: items[i].FileName,
|
||||
FileSizeBytes: items[i].FileSizeBytes,
|
||||
SHA256: items[i].SHA256,
|
||||
ReleaseNotes: items[i].ReleaseNotes,
|
||||
PublishedAt: items[i].PublishedAt,
|
||||
UpdatedAt: items[i].UpdatedAt,
|
||||
ForceUpdate: items[i].ForceUpdate,
|
||||
MinSupportedVersion: items[i].MinSupportedVersion,
|
||||
DownloadSource: items[i].DownloadSource,
|
||||
OSSObjectKey: items[i].OSSObjectKey,
|
||||
})
|
||||
}
|
||||
return &DesktopClientDownloadableReleaseListResult{Items: views}, nil
|
||||
}
|
||||
|
||||
func (s *DesktopClientReleaseService) Create(ctx context.Context, actor *Actor, in DesktopClientReleaseInput) (*DesktopClientReleaseView, error) {
|
||||
normalized, err := normalizeDesktopClientReleaseInput(in)
|
||||
if err != nil {
|
||||
@@ -378,6 +453,9 @@ func (s *DesktopClientReleaseService) CheckLatest(ctx context.Context, in Deskto
|
||||
UpdateAvailable: compareVersionStrings(item.Version, currentVersion) > 0,
|
||||
LatestVersion: item.Version,
|
||||
CurrentVersion: currentVersion,
|
||||
Platform: item.Platform,
|
||||
Arch: item.Arch,
|
||||
Channel: item.Channel,
|
||||
MinSupportedVersion: item.MinSupportedVersion,
|
||||
ForceUpdate: item.ForceUpdate || minSupportedVersionRequiresUpdate(item.MinSupportedVersion, currentVersion),
|
||||
DownloadSource: item.DownloadSource,
|
||||
@@ -519,13 +597,38 @@ func itemUpdaterAsset(item *domain.DesktopClientRelease) desktopClientReleaseAss
|
||||
}
|
||||
|
||||
func (s *DesktopClientReleaseService) resolveDownloadURLResult(item *domain.DesktopClientRelease) (*DesktopClientReleaseDownloadURLResult, error) {
|
||||
downloadURL, err := s.resolveAssetDownloadURL(itemInstallerAsset(item))
|
||||
downloadURL, err := s.resolveInstallerDownloadURL(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DesktopClientReleaseDownloadURLResult{DownloadURL: downloadURL}, nil
|
||||
}
|
||||
|
||||
func (s *DesktopClientReleaseService) resolveInstallerDownloadURL(item *domain.DesktopClientRelease) (string, error) {
|
||||
asset := itemInstallerAsset(item)
|
||||
if !asset.HasTarget() {
|
||||
return "", nil
|
||||
}
|
||||
if stringPointerValue(asset.DownloadSource) == DesktopClientDownloadSourceCustom {
|
||||
return strings.TrimSpace(stringPointerValue(asset.CustomDownloadURL)), nil
|
||||
}
|
||||
|
||||
objectKey := strings.TrimSpace(stringPointerValue(asset.OSSObjectKey))
|
||||
if objectKey == "" {
|
||||
return "", response.ErrInternal(50090, "desktop_client_release_missing_object_key", "OSS object key is missing")
|
||||
}
|
||||
if s.storage == nil {
|
||||
return "", response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable")
|
||||
}
|
||||
downloadURL, err := s.storage.DownloadURL(objectKey)
|
||||
if err != nil {
|
||||
appErr := response.ErrInternal(50091, "object_storage_url_failed", err.Error())
|
||||
appErr.Cause = err
|
||||
return "", appErr
|
||||
}
|
||||
return downloadURL, nil
|
||||
}
|
||||
|
||||
func (s *DesktopClientReleaseService) resolveAssetDownloadURL(asset desktopClientReleaseAsset) (string, error) {
|
||||
if !asset.HasTarget() {
|
||||
return "", nil
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -192,3 +194,56 @@ func TestBuildDesktopClientReleaseObjectKeyReusesContentAddress(t *testing.T) {
|
||||
require.Equal(t, first, second)
|
||||
require.Equal(t, "desktop-client/releases/blobs/"+sum+".exe", first)
|
||||
}
|
||||
|
||||
func TestResolveDownloadURLResultUsesSignedDownloadURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
objectKey := "desktop-client/releases/blobs/client.zip"
|
||||
storage := &desktopReleaseDownloadURLStorage{}
|
||||
svc := &DesktopClientReleaseService{storage: storage}
|
||||
|
||||
result, err := svc.resolveDownloadURLResult(&domain.DesktopClientRelease{
|
||||
DownloadSource: DesktopClientDownloadSourceOSS,
|
||||
OSSObjectKey: &objectKey,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://oss.example.test/signed/client.zip?Expires=123", result.DownloadURL)
|
||||
require.Equal(t, []string{objectKey}, storage.downloadURLCalls)
|
||||
require.Empty(t, storage.publicURLCalls)
|
||||
}
|
||||
|
||||
type desktopReleaseDownloadURLStorage struct {
|
||||
publicURLCalls []string
|
||||
downloadURLCalls []string
|
||||
}
|
||||
|
||||
func (s *desktopReleaseDownloadURLStorage) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *desktopReleaseDownloadURLStorage) PutBytes(context.Context, string, []byte, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *desktopReleaseDownloadURLStorage) GetBytes(context.Context, string) ([]byte, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (s *desktopReleaseDownloadURLStorage) Exists(context.Context, string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *desktopReleaseDownloadURLStorage) Delete(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *desktopReleaseDownloadURLStorage) PublicURL(objectKey string) (string, error) {
|
||||
s.publicURLCalls = append(s.publicURLCalls, objectKey)
|
||||
return "", errors.New("public url should not be used for private release downloads")
|
||||
}
|
||||
|
||||
func (s *desktopReleaseDownloadURLStorage) DownloadURL(objectKey string) (string, error) {
|
||||
s.downloadURLCalls = append(s.downloadURLCalls, objectKey)
|
||||
return "https://oss.example.test/signed/client.zip?Expires=123", nil
|
||||
}
|
||||
|
||||
@@ -85,6 +85,15 @@ func (h *DesktopReleaseHandler) DownloadURLPublic(c *gin.Context) {
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *DesktopReleaseHandler) ListDownloadable(c *gin.Context) {
|
||||
data, err := h.svc.ListDownloadable(c.Request.Context(), c.DefaultQuery("channel", "stable"))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *DesktopReleaseHandler) UpdaterFeedPublic(c *gin.Context) {
|
||||
feed, err := h.svc.ResolveLatestUpdaterFeed(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{
|
||||
Platform: firstNonEmpty(c.Param("platform"), c.Query("platform")),
|
||||
|
||||
@@ -98,6 +98,9 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
media := tenantProtected.Group("/media")
|
||||
media.GET("/platforms", mediaHandler.ListPlatforms)
|
||||
|
||||
desktopClient := tenantProtected.Group("/desktop-client")
|
||||
desktopClient.GET("/releases/downloadable", desktopReleaseHandler.ListDownloadable)
|
||||
|
||||
mediaSupplyHandler := NewMediaSupplyHandler(app)
|
||||
mediaSupply := tenantProtected.Group("/media-supply")
|
||||
mediaSupply.GET("/resources", mediaSupplyHandler.ListResources)
|
||||
|
||||
Reference in New Issue
Block a user