mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
✨ feat(desktop): add desktop release service and API endpoint (#11520)
feat(desktop): add desktop release service and API endpoint ci: add macOS Intel build option to release workflow test: add tests for desktop release service refactor: create validation middleware for API routes
This commit is contained in:
11
.github/workflows/release-desktop-stable.yml
vendored
11
.github/workflows/release-desktop-stable.yml
vendored
@@ -34,6 +34,11 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
build_mac_intel:
|
||||
description: 'Build macOS (Intel x64)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
build_windows:
|
||||
description: 'Build Windows'
|
||||
required: false
|
||||
@@ -147,6 +152,12 @@ jobs:
|
||||
static_matrix=$(echo "$static_matrix" | jq -c --argjson entry "$arm_entry" '. + [$entry]')
|
||||
fi
|
||||
|
||||
if [[ "${{ github.event_name }}" != "workflow_dispatch" ]] || [[ "${{ inputs.build_mac_intel }}" == "true" ]]; then
|
||||
echo "Using GitHub-Hosted Runner for macOS Intel x64"
|
||||
intel_entry='{"os": "macos-15-intel", "name": "macos-intel"}'
|
||||
static_matrix=$(echo "$static_matrix" | jq -c --argjson entry "$intel_entry" '. + [$entry]')
|
||||
fi
|
||||
|
||||
# 输出
|
||||
echo "matrix={\"include\":$static_matrix}" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export enum FetchCacheTag {
|
||||
Changelog = 'changelog',
|
||||
DesktopRelease = 'desktop-release',
|
||||
}
|
||||
|
||||
115
src/app/(backend)/api/desktop/latest/route.ts
Normal file
115
src/app/(backend)/api/desktop/latest/route.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import debug from 'debug';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { zodValidator } from '@/app/(backend)/middleware/validate';
|
||||
import {
|
||||
type DesktopDownloadType,
|
||||
getLatestDesktopReleaseFromGithub,
|
||||
getStableDesktopReleaseInfoFromUpdateServer,
|
||||
resolveDesktopDownload,
|
||||
resolveDesktopDownloadFromUrls,
|
||||
} from '@/server/services/desktopRelease';
|
||||
|
||||
const log = debug('api-route:desktop:latest');
|
||||
|
||||
const SupportedTypes = ['mac-arm', 'mac-intel', 'windows', 'linux'] as const;
|
||||
|
||||
const truthyStringToBoolean = z.preprocess((value) => {
|
||||
if (!value) return undefined;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value !== 'string') return undefined;
|
||||
|
||||
const v = value.trim().toLowerCase();
|
||||
if (!v) return undefined;
|
||||
|
||||
return v === '1' || v === 'true' || v === 'yes' || v === 'y';
|
||||
}, z.boolean());
|
||||
|
||||
const downloadTypeSchema = z.preprocess((value) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
return value;
|
||||
}, z.enum(SupportedTypes));
|
||||
|
||||
const querySchema = z
|
||||
.object({
|
||||
asJson: truthyStringToBoolean.optional(),
|
||||
as_json: truthyStringToBoolean.optional(),
|
||||
type: downloadTypeSchema.optional(),
|
||||
})
|
||||
.strip()
|
||||
.transform((value) => ({
|
||||
asJson: value.as_json ?? value.asJson ?? false,
|
||||
type: value.type,
|
||||
}))
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.asJson && !value.type) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '`type` is required when `as_json` is false',
|
||||
path: ['type'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const GET = zodValidator(querySchema)(async (req, _context, query) => {
|
||||
try {
|
||||
const { asJson, type } = query;
|
||||
|
||||
const stableInfo = await getStableDesktopReleaseInfoFromUpdateServer();
|
||||
|
||||
if (!type) {
|
||||
if (stableInfo) {
|
||||
return NextResponse.json({
|
||||
links: {
|
||||
'linux': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'linux' }),
|
||||
'mac-arm': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'mac-arm' }),
|
||||
'mac-intel': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'mac-intel' }),
|
||||
'windows': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'windows' }),
|
||||
},
|
||||
tag: stableInfo.tag,
|
||||
version: stableInfo.version,
|
||||
});
|
||||
}
|
||||
|
||||
const release = await getLatestDesktopReleaseFromGithub();
|
||||
const resolveOne = (t: DesktopDownloadType) => resolveDesktopDownload(release, t);
|
||||
|
||||
return NextResponse.json({
|
||||
links: {
|
||||
'linux': resolveOne('linux'),
|
||||
'mac-arm': resolveOne('mac-arm'),
|
||||
'mac-intel': resolveOne('mac-intel'),
|
||||
'windows': resolveOne('windows'),
|
||||
},
|
||||
tag: release.tag_name,
|
||||
version: release.tag_name.replace(/^v/i, ''),
|
||||
});
|
||||
}
|
||||
|
||||
const s3Resolved = stableInfo ? resolveDesktopDownloadFromUrls({ ...stableInfo, type }) : null;
|
||||
if (s3Resolved) {
|
||||
if (asJson) return NextResponse.json(s3Resolved);
|
||||
return NextResponse.redirect(s3Resolved.url, { status: 302 });
|
||||
}
|
||||
|
||||
const release = await getLatestDesktopReleaseFromGithub();
|
||||
const resolved = resolveDesktopDownload(release, type);
|
||||
if (!resolved) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No matched asset for type', supportedTypes: SupportedTypes, type },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (asJson) return NextResponse.json(resolved);
|
||||
|
||||
return NextResponse.redirect(resolved.url, { status: 302 });
|
||||
} catch (e) {
|
||||
log('Failed to resolve latest desktop download: %O', e);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to resolve latest desktop download' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createValidator } from './createValidator';
|
||||
|
||||
describe('createValidator', () => {
|
||||
it('should validate query for GET and pass parsed data to handler', async () => {
|
||||
const validate = createValidator({
|
||||
errorStatus: 422,
|
||||
stopOnFirstError: true,
|
||||
omitNotShapeField: true,
|
||||
});
|
||||
const schema = z.object({ type: z.enum(['a', 'b']) });
|
||||
|
||||
const handler = validate(schema)(async (_req: Request, _ctx: unknown, data: any) => {
|
||||
return new Response(JSON.stringify({ ok: true, data }), { status: 200 });
|
||||
});
|
||||
|
||||
const res = await handler(new NextRequest('https://example.com/api?type=a'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true, data: { type: 'a' } });
|
||||
});
|
||||
|
||||
it('should return 422 with one issue when stopOnFirstError', async () => {
|
||||
const validate = createValidator({
|
||||
errorStatus: 422,
|
||||
stopOnFirstError: true,
|
||||
omitNotShapeField: true,
|
||||
});
|
||||
const schema = z.object({
|
||||
foo: z.string().min(2),
|
||||
type: z.enum(['a', 'b']),
|
||||
});
|
||||
|
||||
const handler = validate(schema)(async () => new Response('ok'));
|
||||
const res = await handler(new NextRequest('https://example.com/api?foo=x&type=c'));
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe('Invalid request');
|
||||
expect(Array.isArray(body.issues)).toBe(true);
|
||||
expect(body.issues).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should omit unknown fields when omitNotShapeField enabled', async () => {
|
||||
const validate = createValidator({
|
||||
errorStatus: 422,
|
||||
stopOnFirstError: true,
|
||||
omitNotShapeField: true,
|
||||
});
|
||||
const schema = z.object({ type: z.enum(['a', 'b']) });
|
||||
|
||||
const handler = validate(schema)(async (_req: Request, _ctx: unknown, data: any) => {
|
||||
return new Response(JSON.stringify(data), { status: 200 });
|
||||
});
|
||||
|
||||
const res = await handler(new NextRequest('https://example.com/api?type=a&extra=1'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ type: 'a' });
|
||||
});
|
||||
});
|
||||
79
src/app/(backend)/middleware/validate/createValidator.ts
Normal file
79
src/app/(backend)/middleware/validate/createValidator.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface ValidatorOptions {
|
||||
errorStatus?: number;
|
||||
omitNotShapeField?: boolean;
|
||||
stopOnFirstError?: boolean;
|
||||
}
|
||||
|
||||
type InferInput<TSchema extends z.ZodTypeAny> = z.input<TSchema>;
|
||||
type InferOutput<TSchema extends z.ZodTypeAny> = z.output<TSchema>;
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
const getRequestInput = async (req: Request): Promise<Record<string, unknown>> => {
|
||||
const method = req.method?.toUpperCase?.() ?? 'GET';
|
||||
if (method === 'GET' || method === 'HEAD') {
|
||||
return Object.fromEntries(new URL(req.url).searchParams.entries());
|
||||
}
|
||||
|
||||
const contentType = req.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
return (await req.json()) as any;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return (await (req as any).json?.()) as any;
|
||||
} catch {
|
||||
return Object.fromEntries(new URL(req.url).searchParams.entries());
|
||||
}
|
||||
};
|
||||
|
||||
const applyOptionsToSchema = <TSchema extends z.ZodTypeAny>(
|
||||
schema: TSchema,
|
||||
options: ValidatorOptions,
|
||||
): z.ZodTypeAny => {
|
||||
if (!options.omitNotShapeField) return schema;
|
||||
if (schema instanceof z.ZodObject) return schema.strip();
|
||||
return schema;
|
||||
};
|
||||
|
||||
export const createValidator =
|
||||
(options: ValidatorOptions = {}) =>
|
||||
<TSchema extends z.ZodTypeAny>(schema: TSchema) => {
|
||||
const errorStatus = options.errorStatus ?? 422;
|
||||
const effectiveSchema = applyOptionsToSchema(schema, options) as z.ZodType<
|
||||
InferOutput<TSchema>
|
||||
>;
|
||||
|
||||
return <TReq extends NextRequest, TContext>(
|
||||
handler: (
|
||||
req: TReq,
|
||||
context: TContext,
|
||||
data: InferOutput<TSchema>,
|
||||
) => MaybePromise<Response>,
|
||||
) =>
|
||||
async (req: TReq, context?: TContext) => {
|
||||
const input = (await getRequestInput(req)) as InferInput<TSchema>;
|
||||
const result = effectiveSchema.safeParse(input);
|
||||
|
||||
if (!result.success) {
|
||||
const issues = options.stopOnFirstError
|
||||
? result.error.issues.slice(0, 1)
|
||||
: result.error.issues;
|
||||
return NextResponse.json({ error: 'Invalid request', issues }, { status: errorStatus });
|
||||
}
|
||||
|
||||
return handler(req, context as TContext, result.data);
|
||||
};
|
||||
};
|
||||
|
||||
export const zodValidator = createValidator({
|
||||
errorStatus: 422,
|
||||
omitNotShapeField: true,
|
||||
stopOnFirstError: true,
|
||||
});
|
||||
3
src/app/(backend)/middleware/validate/index.ts
Normal file
3
src/app/(backend)/middleware/validate/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type { ValidatorOptions } from './createValidator';
|
||||
export { createValidator } from './createValidator';
|
||||
export { zodValidator } from './createValidator';
|
||||
65
src/server/services/desktopRelease/index.test.ts
Normal file
65
src/server/services/desktopRelease/index.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type DesktopDownloadType,
|
||||
resolveDesktopDownload,
|
||||
resolveDesktopDownloadFromUrls,
|
||||
} from './index';
|
||||
|
||||
const mockRelease = {
|
||||
assets: [
|
||||
{
|
||||
browser_download_url: 'https://example.com/LobeHub-2.0.0-arm64.dmg',
|
||||
name: 'LobeHub-2.0.0-arm64.dmg',
|
||||
},
|
||||
{
|
||||
browser_download_url: 'https://example.com/LobeHub-2.0.0-x64.dmg',
|
||||
name: 'LobeHub-2.0.0-x64.dmg',
|
||||
},
|
||||
{
|
||||
browser_download_url: 'https://example.com/LobeHub-2.0.0-setup.exe',
|
||||
name: 'LobeHub-2.0.0-setup.exe',
|
||||
},
|
||||
{
|
||||
browser_download_url: 'https://example.com/LobeHub-2.0.0.AppImage',
|
||||
name: 'LobeHub-2.0.0.AppImage',
|
||||
},
|
||||
],
|
||||
published_at: '2026-01-01T00:00:00.000Z',
|
||||
tag_name: 'v2.0.0',
|
||||
};
|
||||
|
||||
describe('desktopRelease', () => {
|
||||
it.each([
|
||||
['mac-arm', 'LobeHub-2.0.0-arm64.dmg'],
|
||||
['mac-intel', 'LobeHub-2.0.0-x64.dmg'],
|
||||
['windows', 'LobeHub-2.0.0-setup.exe'],
|
||||
['linux', 'LobeHub-2.0.0.AppImage'],
|
||||
] as Array<[DesktopDownloadType, string]>)(
|
||||
'resolveDesktopDownload(%s)',
|
||||
(type, expectedAssetName) => {
|
||||
const resolved = resolveDesktopDownload(mockRelease as any, type);
|
||||
expect(resolved?.assetName).toBe(expectedAssetName);
|
||||
expect(resolved?.version).toBe('2.0.0');
|
||||
expect(resolved?.tag).toBe('v2.0.0');
|
||||
expect(resolved?.type).toBe(type);
|
||||
expect(resolved?.url).toContain(expectedAssetName);
|
||||
},
|
||||
);
|
||||
|
||||
it('resolveDesktopDownloadFromUrls should match basename', () => {
|
||||
const resolved = resolveDesktopDownloadFromUrls({
|
||||
publishedAt: '2026-01-01T00:00:00.000Z',
|
||||
tag: 'v2.0.0',
|
||||
type: 'windows',
|
||||
urls: [
|
||||
'https://releases.example.com/stable/2.0.0/LobeHub-2.0.0-setup.exe?download=1',
|
||||
'https://releases.example.com/stable/2.0.0/LobeHub-2.0.0-x64.dmg',
|
||||
],
|
||||
version: '2.0.0',
|
||||
});
|
||||
|
||||
expect(resolved?.assetName).toBe('LobeHub-2.0.0-setup.exe');
|
||||
expect(resolved?.url).toContain('setup.exe');
|
||||
});
|
||||
});
|
||||
208
src/server/services/desktopRelease/index.ts
Normal file
208
src/server/services/desktopRelease/index.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import urlJoin from 'url-join';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
import { FetchCacheTag } from '@/const/cacheControl';
|
||||
|
||||
export type DesktopDownloadType = 'linux' | 'mac-arm' | 'mac-intel' | 'windows';
|
||||
|
||||
export interface DesktopDownloadInfo {
|
||||
assetName: string;
|
||||
publishedAt?: string;
|
||||
tag: string;
|
||||
type: DesktopDownloadType;
|
||||
url: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
type GithubReleaseAsset = {
|
||||
browser_download_url: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type GithubRelease = {
|
||||
assets: GithubReleaseAsset[];
|
||||
published_at?: string;
|
||||
tag_name: string;
|
||||
};
|
||||
|
||||
type UpdateServerManifestFile = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
type UpdateServerManifest = {
|
||||
files?: UpdateServerManifestFile[];
|
||||
path?: string;
|
||||
releaseDate?: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
const getBasename = (pathname: string) => {
|
||||
const cleaned = pathname.split('?')[0] || '';
|
||||
const lastSlash = cleaned.lastIndexOf('/');
|
||||
return lastSlash >= 0 ? cleaned.slice(lastSlash + 1) : cleaned;
|
||||
};
|
||||
|
||||
const isAbsoluteUrl = (value: string) => /^https?:\/\//i.test(value);
|
||||
|
||||
const buildTypeMatchers = (type: DesktopDownloadType) => {
|
||||
switch (type) {
|
||||
case 'mac-arm': {
|
||||
return [/-arm64\.dmg$/i, /-arm64-mac\.zip$/i, /-arm64\.zip$/i, /\.dmg$/i, /\.zip$/i];
|
||||
}
|
||||
case 'mac-intel': {
|
||||
return [/-x64\.dmg$/i, /-x64-mac\.zip$/i, /-x64\.zip$/i, /\.dmg$/i, /\.zip$/i];
|
||||
}
|
||||
case 'windows': {
|
||||
return [/-setup\.exe$/i, /\.exe$/i];
|
||||
}
|
||||
case 'linux': {
|
||||
return [/\.appimage$/i, /\.deb$/i, /\.rpm$/i, /\.snap$/i, /\.tar\.gz$/i];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveDesktopDownloadFromUrls = (options: {
|
||||
publishedAt?: string;
|
||||
tag: string;
|
||||
type: DesktopDownloadType;
|
||||
urls: string[];
|
||||
version: string;
|
||||
}): DesktopDownloadInfo | null => {
|
||||
const matchers = buildTypeMatchers(options.type);
|
||||
|
||||
const matchedUrl = matchers
|
||||
.map((matcher) => options.urls.find((url) => matcher.test(getBasename(url))))
|
||||
.find(Boolean);
|
||||
|
||||
if (!matchedUrl) return null;
|
||||
|
||||
return {
|
||||
assetName: getBasename(matchedUrl),
|
||||
publishedAt: options.publishedAt,
|
||||
tag: options.tag,
|
||||
type: options.type,
|
||||
url: matchedUrl,
|
||||
version: options.version,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveDesktopDownload = (
|
||||
release: GithubRelease,
|
||||
type: DesktopDownloadType,
|
||||
): DesktopDownloadInfo | null => {
|
||||
const tag = release.tag_name;
|
||||
const version = tag.replace(/^v/i, '');
|
||||
const matchers = buildTypeMatchers(type);
|
||||
|
||||
const matchedAsset = matchers
|
||||
.map((matcher) => release.assets.find((asset) => matcher.test(asset.name)))
|
||||
.find(Boolean);
|
||||
|
||||
if (!matchedAsset) return null;
|
||||
|
||||
return {
|
||||
assetName: matchedAsset.name,
|
||||
publishedAt: release.published_at,
|
||||
tag,
|
||||
type,
|
||||
url: matchedAsset.browser_download_url,
|
||||
version,
|
||||
};
|
||||
};
|
||||
|
||||
export const getLatestDesktopReleaseFromGithub = async (options?: {
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
token?: string;
|
||||
}): Promise<GithubRelease> => {
|
||||
const owner = options?.owner || 'lobehub';
|
||||
const repo = options?.repo || 'lobe-chat';
|
||||
const token = options?.token || process.env.GITHUB_TOKEN;
|
||||
|
||||
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, {
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'User-Agent': 'lobehub-server',
|
||||
},
|
||||
next: { revalidate: 300, tags: [FetchCacheTag.DesktopRelease] },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`GitHub releases/latest request failed: ${res.status} ${text}`.trim());
|
||||
}
|
||||
|
||||
return (await res.json()) as GithubRelease;
|
||||
};
|
||||
|
||||
const fetchUpdateServerManifest = async (
|
||||
baseUrl: string,
|
||||
manifestName: string,
|
||||
): Promise<UpdateServerManifest> => {
|
||||
const res = await fetch(urlJoin(baseUrl, manifestName), {
|
||||
next: { revalidate: 300, tags: [FetchCacheTag.DesktopRelease] },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`Update server manifest request failed: ${res.status} ${text}`.trim());
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
return (parse(text) || {}) as UpdateServerManifest;
|
||||
};
|
||||
|
||||
const normalizeManifestUrls = (baseUrl: string, manifest: UpdateServerManifest) => {
|
||||
const urls: string[] = [];
|
||||
|
||||
for (const file of manifest.files || []) {
|
||||
if (!file?.url) continue;
|
||||
urls.push(isAbsoluteUrl(file.url) ? file.url : urlJoin(baseUrl, file.url));
|
||||
}
|
||||
|
||||
if (manifest.path) {
|
||||
urls.push(isAbsoluteUrl(manifest.path) ? manifest.path : urlJoin(baseUrl, manifest.path));
|
||||
}
|
||||
|
||||
return urls;
|
||||
};
|
||||
|
||||
export const getStableDesktopReleaseInfoFromUpdateServer = async (options?: {
|
||||
baseUrl?: string;
|
||||
}): Promise<{ publishedAt?: string; tag: string; urls: string[]; version: string } | null> => {
|
||||
const baseUrl =
|
||||
options?.baseUrl || process.env.DESKTOP_UPDATE_SERVER_URL || process.env.UPDATE_SERVER_URL;
|
||||
if (!baseUrl) return null;
|
||||
|
||||
const [mac, win, linux] = await Promise.all([
|
||||
fetchUpdateServerManifest(baseUrl, 'stable-mac.yml').catch(() => null),
|
||||
fetchUpdateServerManifest(baseUrl, 'stable.yml').catch(() => null),
|
||||
fetchUpdateServerManifest(baseUrl, 'stable-linux.yml').catch(() => null),
|
||||
]);
|
||||
|
||||
const manifests = [mac, win, linux].filter(Boolean) as UpdateServerManifest[];
|
||||
const version = manifests.map((m) => m.version).find(Boolean) || '';
|
||||
if (!version) return null;
|
||||
|
||||
const tag = `v${version.replace(/^v/i, '')}`;
|
||||
const publishedAt = manifests.map((m) => m.releaseDate).find(Boolean);
|
||||
|
||||
const urls = [
|
||||
...(mac ? normalizeManifestUrls(baseUrl, mac) : []),
|
||||
...(win ? normalizeManifestUrls(baseUrl, win) : []),
|
||||
...(linux ? normalizeManifestUrls(baseUrl, linux) : []),
|
||||
];
|
||||
|
||||
return { publishedAt, tag, urls, version: version.replace(/^v/i, '') };
|
||||
};
|
||||
|
||||
export const resolveDesktopDownloadFromUpdateServer = async (options: {
|
||||
baseUrl?: string;
|
||||
type: DesktopDownloadType;
|
||||
}): Promise<DesktopDownloadInfo | null> => {
|
||||
const info = await getStableDesktopReleaseInfoFromUpdateServer({ baseUrl: options.baseUrl });
|
||||
if (!info) return null;
|
||||
|
||||
return resolveDesktopDownloadFromUrls({ ...info, type: options.type });
|
||||
};
|
||||
Reference in New Issue
Block a user