@lobehub/lobehub 2.0.0-next.292 → 2.0.0-next.293

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,6 +34,11 @@ on:
34
34
  required: false
35
35
  type: boolean
36
36
  default: true
37
+ build_mac_intel:
38
+ description: 'Build macOS (Intel x64)'
39
+ required: false
40
+ type: boolean
41
+ default: true
37
42
  build_windows:
38
43
  description: 'Build Windows'
39
44
  required: false
@@ -147,6 +152,12 @@ jobs:
147
152
  static_matrix=$(echo "$static_matrix" | jq -c --argjson entry "$arm_entry" '. + [$entry]')
148
153
  fi
149
154
 
155
+ if [[ "${{ github.event_name }}" != "workflow_dispatch" ]] || [[ "${{ inputs.build_mac_intel }}" == "true" ]]; then
156
+ echo "Using GitHub-Hosted Runner for macOS Intel x64"
157
+ intel_entry='{"os": "macos-15-intel", "name": "macos-intel"}'
158
+ static_matrix=$(echo "$static_matrix" | jq -c --argjson entry "$intel_entry" '. + [$entry]')
159
+ fi
160
+
150
161
  # 输出
151
162
  echo "matrix={\"include\":$static_matrix}" >> $GITHUB_OUTPUT
152
163
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## [Version 2.0.0-next.293](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.292...v2.0.0-next.293)
6
+
7
+ <sup>Released on **2026-01-15**</sup>
8
+
9
+ #### ✨ Features
10
+
11
+ - **desktop**: Add desktop release service and API endpoint.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's improved
19
+
20
+ - **desktop**: Add desktop release service and API endpoint, closes [#11520](https://github.com/lobehub/lobe-chat/issues/11520) ([e3dc5be](https://github.com/lobehub/lobe-chat/commit/e3dc5be))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
5
30
  ## [Version 2.0.0-next.292](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.291...v2.0.0-next.292)
6
31
 
7
32
  <sup>Released on **2026-01-15**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "children": {},
4
+ "date": "2026-01-15",
5
+ "version": "2.0.0-next.293"
6
+ },
2
7
  {
3
8
  "children": {
4
9
  "improvements": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/lobehub",
3
- "version": "2.0.0-next.292",
3
+ "version": "2.0.0-next.293",
4
4
  "description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -1,3 +1,4 @@
1
1
  export enum FetchCacheTag {
2
2
  Changelog = 'changelog',
3
+ DesktopRelease = 'desktop-release',
3
4
  }
@@ -0,0 +1,115 @@
1
+ import debug from 'debug';
2
+ import { NextResponse } from 'next/server';
3
+ import { z } from 'zod';
4
+
5
+ import { zodValidator } from '@/app/(backend)/middleware/validate';
6
+ import {
7
+ type DesktopDownloadType,
8
+ getLatestDesktopReleaseFromGithub,
9
+ getStableDesktopReleaseInfoFromUpdateServer,
10
+ resolveDesktopDownload,
11
+ resolveDesktopDownloadFromUrls,
12
+ } from '@/server/services/desktopRelease';
13
+
14
+ const log = debug('api-route:desktop:latest');
15
+
16
+ const SupportedTypes = ['mac-arm', 'mac-intel', 'windows', 'linux'] as const;
17
+
18
+ const truthyStringToBoolean = z.preprocess((value) => {
19
+ if (!value) return undefined;
20
+ if (typeof value === 'boolean') return value;
21
+ if (typeof value !== 'string') return undefined;
22
+
23
+ const v = value.trim().toLowerCase();
24
+ if (!v) return undefined;
25
+
26
+ return v === '1' || v === 'true' || v === 'yes' || v === 'y';
27
+ }, z.boolean());
28
+
29
+ const downloadTypeSchema = z.preprocess((value) => {
30
+ if (typeof value !== 'string') return value;
31
+ return value;
32
+ }, z.enum(SupportedTypes));
33
+
34
+ const querySchema = z
35
+ .object({
36
+ asJson: truthyStringToBoolean.optional(),
37
+ as_json: truthyStringToBoolean.optional(),
38
+ type: downloadTypeSchema.optional(),
39
+ })
40
+ .strip()
41
+ .transform((value) => ({
42
+ asJson: value.as_json ?? value.asJson ?? false,
43
+ type: value.type,
44
+ }))
45
+ .superRefine((value, ctx) => {
46
+ if (!value.asJson && !value.type) {
47
+ ctx.addIssue({
48
+ code: z.ZodIssueCode.custom,
49
+ message: '`type` is required when `as_json` is false',
50
+ path: ['type'],
51
+ });
52
+ }
53
+ });
54
+
55
+ export const GET = zodValidator(querySchema)(async (req, _context, query) => {
56
+ try {
57
+ const { asJson, type } = query;
58
+
59
+ const stableInfo = await getStableDesktopReleaseInfoFromUpdateServer();
60
+
61
+ if (!type) {
62
+ if (stableInfo) {
63
+ return NextResponse.json({
64
+ links: {
65
+ 'linux': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'linux' }),
66
+ 'mac-arm': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'mac-arm' }),
67
+ 'mac-intel': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'mac-intel' }),
68
+ 'windows': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'windows' }),
69
+ },
70
+ tag: stableInfo.tag,
71
+ version: stableInfo.version,
72
+ });
73
+ }
74
+
75
+ const release = await getLatestDesktopReleaseFromGithub();
76
+ const resolveOne = (t: DesktopDownloadType) => resolveDesktopDownload(release, t);
77
+
78
+ return NextResponse.json({
79
+ links: {
80
+ 'linux': resolveOne('linux'),
81
+ 'mac-arm': resolveOne('mac-arm'),
82
+ 'mac-intel': resolveOne('mac-intel'),
83
+ 'windows': resolveOne('windows'),
84
+ },
85
+ tag: release.tag_name,
86
+ version: release.tag_name.replace(/^v/i, ''),
87
+ });
88
+ }
89
+
90
+ const s3Resolved = stableInfo ? resolveDesktopDownloadFromUrls({ ...stableInfo, type }) : null;
91
+ if (s3Resolved) {
92
+ if (asJson) return NextResponse.json(s3Resolved);
93
+ return NextResponse.redirect(s3Resolved.url, { status: 302 });
94
+ }
95
+
96
+ const release = await getLatestDesktopReleaseFromGithub();
97
+ const resolved = resolveDesktopDownload(release, type);
98
+ if (!resolved) {
99
+ return NextResponse.json(
100
+ { error: 'No matched asset for type', supportedTypes: SupportedTypes, type },
101
+ { status: 404 },
102
+ );
103
+ }
104
+
105
+ if (asJson) return NextResponse.json(resolved);
106
+
107
+ return NextResponse.redirect(resolved.url, { status: 302 });
108
+ } catch (e) {
109
+ log('Failed to resolve latest desktop download: %O', e);
110
+ return NextResponse.json(
111
+ { error: 'Failed to resolve latest desktop download' },
112
+ { status: 500 },
113
+ );
114
+ }
115
+ });
@@ -0,0 +1,61 @@
1
+ import { NextRequest } from 'next/server';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { z } from 'zod';
4
+
5
+ import { createValidator } from './createValidator';
6
+
7
+ describe('createValidator', () => {
8
+ it('should validate query for GET and pass parsed data to handler', async () => {
9
+ const validate = createValidator({
10
+ errorStatus: 422,
11
+ stopOnFirstError: true,
12
+ omitNotShapeField: true,
13
+ });
14
+ const schema = z.object({ type: z.enum(['a', 'b']) });
15
+
16
+ const handler = validate(schema)(async (_req: Request, _ctx: unknown, data: any) => {
17
+ return new Response(JSON.stringify({ ok: true, data }), { status: 200 });
18
+ });
19
+
20
+ const res = await handler(new NextRequest('https://example.com/api?type=a'));
21
+ expect(res.status).toBe(200);
22
+ expect(await res.json()).toEqual({ ok: true, data: { type: 'a' } });
23
+ });
24
+
25
+ it('should return 422 with one issue when stopOnFirstError', async () => {
26
+ const validate = createValidator({
27
+ errorStatus: 422,
28
+ stopOnFirstError: true,
29
+ omitNotShapeField: true,
30
+ });
31
+ const schema = z.object({
32
+ foo: z.string().min(2),
33
+ type: z.enum(['a', 'b']),
34
+ });
35
+
36
+ const handler = validate(schema)(async () => new Response('ok'));
37
+ const res = await handler(new NextRequest('https://example.com/api?foo=x&type=c'));
38
+ expect(res.status).toBe(422);
39
+ const body = await res.json();
40
+ expect(body.error).toBe('Invalid request');
41
+ expect(Array.isArray(body.issues)).toBe(true);
42
+ expect(body.issues).toHaveLength(1);
43
+ });
44
+
45
+ it('should omit unknown fields when omitNotShapeField enabled', async () => {
46
+ const validate = createValidator({
47
+ errorStatus: 422,
48
+ stopOnFirstError: true,
49
+ omitNotShapeField: true,
50
+ });
51
+ const schema = z.object({ type: z.enum(['a', 'b']) });
52
+
53
+ const handler = validate(schema)(async (_req: Request, _ctx: unknown, data: any) => {
54
+ return new Response(JSON.stringify(data), { status: 200 });
55
+ });
56
+
57
+ const res = await handler(new NextRequest('https://example.com/api?type=a&extra=1'));
58
+ expect(res.status).toBe(200);
59
+ expect(await res.json()).toEqual({ type: 'a' });
60
+ });
61
+ });
@@ -0,0 +1,79 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+
4
+ export interface ValidatorOptions {
5
+ errorStatus?: number;
6
+ omitNotShapeField?: boolean;
7
+ stopOnFirstError?: boolean;
8
+ }
9
+
10
+ type InferInput<TSchema extends z.ZodTypeAny> = z.input<TSchema>;
11
+ type InferOutput<TSchema extends z.ZodTypeAny> = z.output<TSchema>;
12
+ type MaybePromise<T> = T | Promise<T>;
13
+
14
+ const getRequestInput = async (req: Request): Promise<Record<string, unknown>> => {
15
+ const method = req.method?.toUpperCase?.() ?? 'GET';
16
+ if (method === 'GET' || method === 'HEAD') {
17
+ return Object.fromEntries(new URL(req.url).searchParams.entries());
18
+ }
19
+
20
+ const contentType = req.headers.get('content-type') || '';
21
+ if (contentType.includes('application/json')) {
22
+ try {
23
+ return (await req.json()) as any;
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ try {
30
+ return (await (req as any).json?.()) as any;
31
+ } catch {
32
+ return Object.fromEntries(new URL(req.url).searchParams.entries());
33
+ }
34
+ };
35
+
36
+ const applyOptionsToSchema = <TSchema extends z.ZodTypeAny>(
37
+ schema: TSchema,
38
+ options: ValidatorOptions,
39
+ ): z.ZodTypeAny => {
40
+ if (!options.omitNotShapeField) return schema;
41
+ if (schema instanceof z.ZodObject) return schema.strip();
42
+ return schema;
43
+ };
44
+
45
+ export const createValidator =
46
+ (options: ValidatorOptions = {}) =>
47
+ <TSchema extends z.ZodTypeAny>(schema: TSchema) => {
48
+ const errorStatus = options.errorStatus ?? 422;
49
+ const effectiveSchema = applyOptionsToSchema(schema, options) as z.ZodType<
50
+ InferOutput<TSchema>
51
+ >;
52
+
53
+ return <TReq extends NextRequest, TContext>(
54
+ handler: (
55
+ req: TReq,
56
+ context: TContext,
57
+ data: InferOutput<TSchema>,
58
+ ) => MaybePromise<Response>,
59
+ ) =>
60
+ async (req: TReq, context?: TContext) => {
61
+ const input = (await getRequestInput(req)) as InferInput<TSchema>;
62
+ const result = effectiveSchema.safeParse(input);
63
+
64
+ if (!result.success) {
65
+ const issues = options.stopOnFirstError
66
+ ? result.error.issues.slice(0, 1)
67
+ : result.error.issues;
68
+ return NextResponse.json({ error: 'Invalid request', issues }, { status: errorStatus });
69
+ }
70
+
71
+ return handler(req, context as TContext, result.data);
72
+ };
73
+ };
74
+
75
+ export const zodValidator = createValidator({
76
+ errorStatus: 422,
77
+ omitNotShapeField: true,
78
+ stopOnFirstError: true,
79
+ });
@@ -0,0 +1,3 @@
1
+ export type { ValidatorOptions } from './createValidator';
2
+ export { createValidator } from './createValidator';
3
+ export { zodValidator } from './createValidator';
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ type DesktopDownloadType,
5
+ resolveDesktopDownload,
6
+ resolveDesktopDownloadFromUrls,
7
+ } from './index';
8
+
9
+ const mockRelease = {
10
+ assets: [
11
+ {
12
+ browser_download_url: 'https://example.com/LobeHub-2.0.0-arm64.dmg',
13
+ name: 'LobeHub-2.0.0-arm64.dmg',
14
+ },
15
+ {
16
+ browser_download_url: 'https://example.com/LobeHub-2.0.0-x64.dmg',
17
+ name: 'LobeHub-2.0.0-x64.dmg',
18
+ },
19
+ {
20
+ browser_download_url: 'https://example.com/LobeHub-2.0.0-setup.exe',
21
+ name: 'LobeHub-2.0.0-setup.exe',
22
+ },
23
+ {
24
+ browser_download_url: 'https://example.com/LobeHub-2.0.0.AppImage',
25
+ name: 'LobeHub-2.0.0.AppImage',
26
+ },
27
+ ],
28
+ published_at: '2026-01-01T00:00:00.000Z',
29
+ tag_name: 'v2.0.0',
30
+ };
31
+
32
+ describe('desktopRelease', () => {
33
+ it.each([
34
+ ['mac-arm', 'LobeHub-2.0.0-arm64.dmg'],
35
+ ['mac-intel', 'LobeHub-2.0.0-x64.dmg'],
36
+ ['windows', 'LobeHub-2.0.0-setup.exe'],
37
+ ['linux', 'LobeHub-2.0.0.AppImage'],
38
+ ] as Array<[DesktopDownloadType, string]>)(
39
+ 'resolveDesktopDownload(%s)',
40
+ (type, expectedAssetName) => {
41
+ const resolved = resolveDesktopDownload(mockRelease as any, type);
42
+ expect(resolved?.assetName).toBe(expectedAssetName);
43
+ expect(resolved?.version).toBe('2.0.0');
44
+ expect(resolved?.tag).toBe('v2.0.0');
45
+ expect(resolved?.type).toBe(type);
46
+ expect(resolved?.url).toContain(expectedAssetName);
47
+ },
48
+ );
49
+
50
+ it('resolveDesktopDownloadFromUrls should match basename', () => {
51
+ const resolved = resolveDesktopDownloadFromUrls({
52
+ publishedAt: '2026-01-01T00:00:00.000Z',
53
+ tag: 'v2.0.0',
54
+ type: 'windows',
55
+ urls: [
56
+ 'https://releases.example.com/stable/2.0.0/LobeHub-2.0.0-setup.exe?download=1',
57
+ 'https://releases.example.com/stable/2.0.0/LobeHub-2.0.0-x64.dmg',
58
+ ],
59
+ version: '2.0.0',
60
+ });
61
+
62
+ expect(resolved?.assetName).toBe('LobeHub-2.0.0-setup.exe');
63
+ expect(resolved?.url).toContain('setup.exe');
64
+ });
65
+ });
@@ -0,0 +1,208 @@
1
+ import urlJoin from 'url-join';
2
+ import { parse } from 'yaml';
3
+
4
+ import { FetchCacheTag } from '@/const/cacheControl';
5
+
6
+ export type DesktopDownloadType = 'linux' | 'mac-arm' | 'mac-intel' | 'windows';
7
+
8
+ export interface DesktopDownloadInfo {
9
+ assetName: string;
10
+ publishedAt?: string;
11
+ tag: string;
12
+ type: DesktopDownloadType;
13
+ url: string;
14
+ version: string;
15
+ }
16
+
17
+ type GithubReleaseAsset = {
18
+ browser_download_url: string;
19
+ name: string;
20
+ };
21
+
22
+ type GithubRelease = {
23
+ assets: GithubReleaseAsset[];
24
+ published_at?: string;
25
+ tag_name: string;
26
+ };
27
+
28
+ type UpdateServerManifestFile = {
29
+ url: string;
30
+ };
31
+
32
+ type UpdateServerManifest = {
33
+ files?: UpdateServerManifestFile[];
34
+ path?: string;
35
+ releaseDate?: string;
36
+ version?: string;
37
+ };
38
+
39
+ const getBasename = (pathname: string) => {
40
+ const cleaned = pathname.split('?')[0] || '';
41
+ const lastSlash = cleaned.lastIndexOf('/');
42
+ return lastSlash >= 0 ? cleaned.slice(lastSlash + 1) : cleaned;
43
+ };
44
+
45
+ const isAbsoluteUrl = (value: string) => /^https?:\/\//i.test(value);
46
+
47
+ const buildTypeMatchers = (type: DesktopDownloadType) => {
48
+ switch (type) {
49
+ case 'mac-arm': {
50
+ return [/-arm64\.dmg$/i, /-arm64-mac\.zip$/i, /-arm64\.zip$/i, /\.dmg$/i, /\.zip$/i];
51
+ }
52
+ case 'mac-intel': {
53
+ return [/-x64\.dmg$/i, /-x64-mac\.zip$/i, /-x64\.zip$/i, /\.dmg$/i, /\.zip$/i];
54
+ }
55
+ case 'windows': {
56
+ return [/-setup\.exe$/i, /\.exe$/i];
57
+ }
58
+ case 'linux': {
59
+ return [/\.appimage$/i, /\.deb$/i, /\.rpm$/i, /\.snap$/i, /\.tar\.gz$/i];
60
+ }
61
+ }
62
+ };
63
+
64
+ export const resolveDesktopDownloadFromUrls = (options: {
65
+ publishedAt?: string;
66
+ tag: string;
67
+ type: DesktopDownloadType;
68
+ urls: string[];
69
+ version: string;
70
+ }): DesktopDownloadInfo | null => {
71
+ const matchers = buildTypeMatchers(options.type);
72
+
73
+ const matchedUrl = matchers
74
+ .map((matcher) => options.urls.find((url) => matcher.test(getBasename(url))))
75
+ .find(Boolean);
76
+
77
+ if (!matchedUrl) return null;
78
+
79
+ return {
80
+ assetName: getBasename(matchedUrl),
81
+ publishedAt: options.publishedAt,
82
+ tag: options.tag,
83
+ type: options.type,
84
+ url: matchedUrl,
85
+ version: options.version,
86
+ };
87
+ };
88
+
89
+ export const resolveDesktopDownload = (
90
+ release: GithubRelease,
91
+ type: DesktopDownloadType,
92
+ ): DesktopDownloadInfo | null => {
93
+ const tag = release.tag_name;
94
+ const version = tag.replace(/^v/i, '');
95
+ const matchers = buildTypeMatchers(type);
96
+
97
+ const matchedAsset = matchers
98
+ .map((matcher) => release.assets.find((asset) => matcher.test(asset.name)))
99
+ .find(Boolean);
100
+
101
+ if (!matchedAsset) return null;
102
+
103
+ return {
104
+ assetName: matchedAsset.name,
105
+ publishedAt: release.published_at,
106
+ tag,
107
+ type,
108
+ url: matchedAsset.browser_download_url,
109
+ version,
110
+ };
111
+ };
112
+
113
+ export const getLatestDesktopReleaseFromGithub = async (options?: {
114
+ owner?: string;
115
+ repo?: string;
116
+ token?: string;
117
+ }): Promise<GithubRelease> => {
118
+ const owner = options?.owner || 'lobehub';
119
+ const repo = options?.repo || 'lobe-chat';
120
+ const token = options?.token || process.env.GITHUB_TOKEN;
121
+
122
+ const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, {
123
+ headers: {
124
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
125
+ 'Accept': 'application/vnd.github+json',
126
+ 'User-Agent': 'lobehub-server',
127
+ },
128
+ next: { revalidate: 300, tags: [FetchCacheTag.DesktopRelease] },
129
+ });
130
+
131
+ if (!res.ok) {
132
+ const text = await res.text().catch(() => '');
133
+ throw new Error(`GitHub releases/latest request failed: ${res.status} ${text}`.trim());
134
+ }
135
+
136
+ return (await res.json()) as GithubRelease;
137
+ };
138
+
139
+ const fetchUpdateServerManifest = async (
140
+ baseUrl: string,
141
+ manifestName: string,
142
+ ): Promise<UpdateServerManifest> => {
143
+ const res = await fetch(urlJoin(baseUrl, manifestName), {
144
+ next: { revalidate: 300, tags: [FetchCacheTag.DesktopRelease] },
145
+ });
146
+
147
+ if (!res.ok) {
148
+ const text = await res.text().catch(() => '');
149
+ throw new Error(`Update server manifest request failed: ${res.status} ${text}`.trim());
150
+ }
151
+
152
+ const text = await res.text();
153
+ return (parse(text) || {}) as UpdateServerManifest;
154
+ };
155
+
156
+ const normalizeManifestUrls = (baseUrl: string, manifest: UpdateServerManifest) => {
157
+ const urls: string[] = [];
158
+
159
+ for (const file of manifest.files || []) {
160
+ if (!file?.url) continue;
161
+ urls.push(isAbsoluteUrl(file.url) ? file.url : urlJoin(baseUrl, file.url));
162
+ }
163
+
164
+ if (manifest.path) {
165
+ urls.push(isAbsoluteUrl(manifest.path) ? manifest.path : urlJoin(baseUrl, manifest.path));
166
+ }
167
+
168
+ return urls;
169
+ };
170
+
171
+ export const getStableDesktopReleaseInfoFromUpdateServer = async (options?: {
172
+ baseUrl?: string;
173
+ }): Promise<{ publishedAt?: string; tag: string; urls: string[]; version: string } | null> => {
174
+ const baseUrl =
175
+ options?.baseUrl || process.env.DESKTOP_UPDATE_SERVER_URL || process.env.UPDATE_SERVER_URL;
176
+ if (!baseUrl) return null;
177
+
178
+ const [mac, win, linux] = await Promise.all([
179
+ fetchUpdateServerManifest(baseUrl, 'stable-mac.yml').catch(() => null),
180
+ fetchUpdateServerManifest(baseUrl, 'stable.yml').catch(() => null),
181
+ fetchUpdateServerManifest(baseUrl, 'stable-linux.yml').catch(() => null),
182
+ ]);
183
+
184
+ const manifests = [mac, win, linux].filter(Boolean) as UpdateServerManifest[];
185
+ const version = manifests.map((m) => m.version).find(Boolean) || '';
186
+ if (!version) return null;
187
+
188
+ const tag = `v${version.replace(/^v/i, '')}`;
189
+ const publishedAt = manifests.map((m) => m.releaseDate).find(Boolean);
190
+
191
+ const urls = [
192
+ ...(mac ? normalizeManifestUrls(baseUrl, mac) : []),
193
+ ...(win ? normalizeManifestUrls(baseUrl, win) : []),
194
+ ...(linux ? normalizeManifestUrls(baseUrl, linux) : []),
195
+ ];
196
+
197
+ return { publishedAt, tag, urls, version: version.replace(/^v/i, '') };
198
+ };
199
+
200
+ export const resolveDesktopDownloadFromUpdateServer = async (options: {
201
+ baseUrl?: string;
202
+ type: DesktopDownloadType;
203
+ }): Promise<DesktopDownloadInfo | null> => {
204
+ const info = await getStableDesktopReleaseInfoFromUpdateServer({ baseUrl: options.baseUrl });
205
+ if (!info) return null;
206
+
207
+ return resolveDesktopDownloadFromUrls({ ...info, type: options.type });
208
+ };