@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.
- package/.github/workflows/release-desktop-stable.yml +11 -0
- package/CHANGELOG.md +25 -0
- package/changelog/v1.json +5 -0
- package/package.json +1 -1
- package/packages/const/src/cacheControl.ts +1 -0
- package/src/app/(backend)/api/desktop/latest/route.ts +115 -0
- package/src/app/(backend)/middleware/validate/createValidator.test.ts +61 -0
- package/src/app/(backend)/middleware/validate/createValidator.ts +79 -0
- package/src/app/(backend)/middleware/validate/index.ts +3 -0
- package/src/server/services/desktopRelease/index.test.ts +65 -0
- package/src/server/services/desktopRelease/index.ts +208 -0
|
@@ -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
|
+
[](#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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/lobehub",
|
|
3
|
-
"version": "2.0.0-next.
|
|
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",
|
|
@@ -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,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
|
+
};
|