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:
Innei
2026-01-15 20:30:44 +08:00
committed by GitHub
parent 33088ee0c7
commit e3dc5bede9
8 changed files with 543 additions and 0 deletions

View File

@@ -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

View File

@@ -1,3 +1,4 @@
export enum FetchCacheTag {
Changelog = 'changelog',
DesktopRelease = 'desktop-release',
}

View 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 },
);
}
});

View File

@@ -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' });
});
});

View 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,
});

View File

@@ -0,0 +1,3 @@
export type { ValidatorOptions } from './createValidator';
export { createValidator } from './createValidator';
export { zodValidator } from './createValidator';

View 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');
});
});

View 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 });
};