返回 DeepSeek-Reasonix
desktop_release.ts
根目录 / workers / crash-report / src / desktop_release.ts
1 const R2_BASE = "https://dl.reasonix.io";
2 const GITHUB_RELEASES_API = "https://api.github.com/repos/esengine/DeepSeek-Reasonix/releases?per_page=100";
3 const GITHUB_LATEST_RELEASE_API = "https://api.github.com/repos/esengine/DeepSeek-Reasonix/releases/latest";
4 const RELEASE_METHODS = "GET, HEAD, OPTIONS";
5 const DESKTOP_DOWNLOAD_PAGE = "https://reasonix.io/?download=desktop#start";
6 const DESKTOP_UPDATER_ASSETS = [
7 ["platforms", "darwin-arm64", "Reasonix-darwin-arm64.zip"],
8 ["platforms", "darwin-amd64", "Reasonix-darwin-amd64.zip"],
9 ["platforms", "windows-amd64", "Reasonix-windows-amd64-installer.exe"],
10 ["platforms", "windows-arm64", "Reasonix-windows-arm64-installer.exe"],
11 ["platforms", "linux-amd64", "Reasonix-linux-amd64.tar.gz"],
12 ["native_packages", "linux-amd64", "Reasonix-linux-amd64.deb"],
13 ] as const;
14 const DESKTOP_DOWNLOAD_ASSETS = [
15 ["downloads", "Reasonix-darwin-universal.dmg", "Reasonix-darwin-universal.dmg"],
16 ["downloads", "Reasonix-windows-amd64.zip", "Reasonix-windows-amd64.zip"],
17 ] as const;
18 const SHA256 = /^[0-9a-f]{64}$/;
19 const MAX_RELEASE_ASSET_SIZE = 1 << 30;
20
21 type PublicReleaseChannel = "stable" | "preview";
22 type ReleaseChannel = PublicReleaseChannel | "canary";
23
24 type GitHubAsset = {
25 name?: string;
26 browser_download_url?: string;
27 size?: unknown;
28 };
29
30 type GitHubRelease = {
31 tag_name?: string;
32 draft?: boolean;
33 prerelease?: boolean;
34 html_url?: string;
35 assets?: GitHubAsset[];
36 };
37
38 const CLI_ASSETS = [
39 "reasonix-darwin-amd64.tar.gz",
40 "reasonix-darwin-arm64.tar.gz",
41 "reasonix-linux-amd64.tar.gz",
42 "reasonix-linux-arm64.tar.gz",
43 "reasonix-windows-amd64.zip",
44 "reasonix-windows-arm64.zip",
45 "SHA256SUMS",
46 ] as const;
47 const CLI_ASSET_NAMES = new Set<string>(CLI_ASSETS);
48 const STABLE_CLI_TAG = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
49 const PREVIEW_CLI_TAG = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-preview\.(0|[1-9]\d*)$/;
50
51 function manifestPointer(channel: ReleaseChannel): string {
52 if (channel === "stable") return `${R2_BASE}/latest/latest.json`;
53 return channel === "preview" ? `${R2_BASE}/preview/latest.json` : `${R2_BASE}/canary/latest.json`;
54 }
55
56 function gatewayHeaders(source: string): Record<string, string> {
57 return {
58 "content-type": "application/json; charset=utf-8",
59 "cache-control": "public, max-age=300, stale-if-error=86400",
60 "access-control-allow-origin": "*",
61 "access-control-allow-methods": RELEASE_METHODS,
62 "x-reasonix-release-source": source,
63 };
64 }
65
66 export async function handleReleaseGatewayRequest(
67 method: string,
68 load: () => Promise<Response>,
69 ): Promise<Response> {
70 const normalized = method.toUpperCase();
71 if (normalized === "OPTIONS") {
72 return new Response(null, {
73 status: 204,
74 headers: {
75 ...gatewayHeaders("preflight"),
76 "cache-control": "public, max-age=86400",
77 "access-control-max-age": "86400",
78 allow: RELEASE_METHODS,
79 },
80 });
81 }
82 if (normalized !== "GET" && normalized !== "HEAD") {
83 return new Response(JSON.stringify({ error: "method not allowed" }) + "\n", {
84 status: 405,
85 headers: { ...gatewayHeaders("method-not-allowed"), allow: RELEASE_METHODS },
86 });
87 }
88
89 const response = await load();
90 if (normalized === "GET") return response;
91 return new Response(null, {
92 status: response.status,
93 statusText: response.statusText,
94 headers: response.headers,
95 });
96 }
97
98 function safeHTTPSURL(value: unknown): string {
99 if (typeof value !== "string") return "";
100 try {
101 const url = new URL(value);
102 return url.protocol === "https:" &&
103 Boolean(url.hostname) &&
104 !url.username &&
105 !url.password &&
106 url.href === value
107 ? url.href
108 : "";
109 } catch {
110 return "";
111 }
112 }
113
114 function expectedCLIAssetURL(value: unknown, tag: string, name: string): string {
115 const safe = safeHTTPSURL(value);
116 if (!safe) return "";
117 const url = new URL(safe);
118 const path = `/esengine/DeepSeek-Reasonix/releases/download/${tag}/${name}`;
119 return url.hostname.toLowerCase() === "github.com" &&
120 !url.port &&
121 url.pathname === path &&
122 !url.search &&
123 !url.hash
124 ? url.href
125 : "";
126 }
127
128 function cliTagOrder(tag: unknown, channel: PublicReleaseChannel): string[] | null {
129 const value = String(tag || "");
130 const match = value.match(channel === "preview" ? PREVIEW_CLI_TAG : STABLE_CLI_TAG);
131 return match ? match.slice(1) : null;
132 }
133
134 function compareDecimal(left: string, right: string): number {
135 if (left.length !== right.length) return left.length - right.length;
136 return left === right ? 0 : left > right ? 1 : -1;
137 }
138
139 function compareOrder(left: string[], right: string[]): number {
140 for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
141 const difference = compareDecimal(left[index] || "0", right[index] || "0");
142 if (difference !== 0) return difference;
143 }
144 return 0;
145 }
146
147 function normalizeCLIRelease(
148 release: GitHubRelease,
149 channel: PublicReleaseChannel,
150 ): { release: GitHubRelease; order: string[] } | null {
151 const order = cliTagOrder(release.tag_name, channel);
152 if (!order || release.draft || Boolean(release.prerelease) !== (channel === "preview")) return null;
153
154 const assetsByName = new Map<string, GitHubAsset>();
155 const seen = new Set<string>();
156 for (const asset of Array.isArray(release.assets) ? release.assets : []) {
157 const name = String(asset?.name || "");
158 if (!CLI_ASSET_NAMES.has(name)) continue;
159 if (seen.has(name)) return null;
160 seen.add(name);
161 const download = expectedCLIAssetURL(asset?.browser_download_url, String(release.tag_name || ""), name);
162 if (
163 name &&
164 download &&
165 Number.isSafeInteger(asset?.size) &&
166 (asset.size as number) > 0 &&
167 (asset.size as number) <= MAX_RELEASE_ASSET_SIZE
168 ) {
169 assetsByName.set(name, { name, browser_download_url: download, size: asset.size });
170 }
171 }
172 if (CLI_ASSETS.some((name) => !assetsByName.has(name))) return null;
173
174 const tag = String(release.tag_name);
175 const releaseURL = `https://github.com/esengine/DeepSeek-Reasonix/releases/tag/${tag}`;
176 return {
177 order,
178 release: {
179 tag_name: tag,
180 prerelease: channel === "preview",
181 html_url: releaseURL,
182 assets: CLI_ASSETS.map((name) => assetsByName.get(name) as GitHubAsset),
183 },
184 };
185 }
186
187 function selectCLIRelease(releases: GitHubRelease[], channel: PublicReleaseChannel): GitHubRelease | null {
188 let selected: { release: GitHubRelease; order: string[] } | null = null;
189 for (const release of releases) {
190 const candidate = normalizeCLIRelease(release, channel);
191 if (candidate && (!selected || compareOrder(candidate.order, selected.order) > 0)) selected = candidate;
192 }
193 return selected?.release ?? null;
194 }
195
196 async function fetchCLIRelease(url: string, channel: PublicReleaseChannel, source: string): Promise<Response | null> {
197 try {
198 const response = await fetch(url, {
199 headers: { accept: "application/json", "user-agent": "reasonix-release-gateway" },
200 });
201 if (!response.ok) return null;
202 const release = normalizeCLIRelease((await response.json()) as GitHubRelease, channel)?.release;
203 return release ? new Response(JSON.stringify(release) + "\n", { status: 200, headers: gatewayHeaders(source) }) : null;
204 } catch {
205 return null;
206 }
207 }
208
209 async function fetchLatestCLIReleaseFromGitHub(channel: PublicReleaseChannel): Promise<Response | null> {
210 try {
211 const response = await fetch(GITHUB_RELEASES_API, {
212 headers: { accept: "application/vnd.github+json", "user-agent": "reasonix-release-gateway" },
213 });
214 if (!response.ok) return null;
215 const release = selectCLIRelease((await response.json()) as GitHubRelease[], channel);
216 return release
217 ? new Response(JSON.stringify(release) + "\n", { status: 200, headers: gatewayHeaders("github-cli-releases") })
218 : null;
219 } catch {
220 return null;
221 }
222 }
223
224 function objectValue(value: unknown): Record<string, unknown> | null {
225 return value !== null && typeof value === "object" && !Array.isArray(value)
226 ? value as Record<string, unknown>
227 : null;
228 }
229
230 function desktopAssetBases(
231 version: string,
232 channel: PublicReleaseChannel,
233 allowLegacyPreview: boolean,
234 ): string[] {
235 const r2 = `${R2_BASE}/desktop-${version}/`;
236 if (channel === "preview") {
237 return allowLegacyPreview ? [r2, `${R2_BASE}/desktop-preview/`] : [r2];
238 }
239 return [r2, `https://github.com/esengine/DeepSeek-Reasonix/releases/download/desktop-${version}/`];
240 }
241
242 function normalizeDesktopManifest(
243 value: unknown,
244 channel: PublicReleaseChannel,
245 expectedVersion?: string,
246 ): Record<string, unknown> | null {
247 const manifest = objectValue(value);
248 if (!manifest) return null;
249
250 const version = manifest.version;
251 const pattern = channel === "preview" ? PREVIEW_CLI_TAG : STABLE_CLI_TAG;
252 if (
253 typeof version !== "string" ||
254 !pattern.test(version) ||
255 (expectedVersion !== undefined && version !== expectedVersion) ||
256 manifest.download_page !== DESKTOP_DOWNLOAD_PAGE
257 ) {
258 return null;
259 }
260
261 const legacyManifest = manifest.downloads === undefined || manifest.downloads === null;
262 const allowedBases = desktopAssetBases(version, channel, legacyManifest);
263 let selectedBase = "";
264 const requiredAssets = legacyManifest
265 ? DESKTOP_UPDATER_ASSETS
266 : [...DESKTOP_UPDATER_ASSETS, ...DESKTOP_DOWNLOAD_ASSETS];
267 for (const [groupName, key, fileName] of requiredAssets) {
268 const group = objectValue(manifest[groupName]);
269 const asset = objectValue(group?.[key]);
270 if (
271 !asset ||
272 !Number.isSafeInteger(asset.size) ||
273 (asset.size as number) <= 0 ||
274 (asset.size as number) > MAX_RELEASE_ASSET_SIZE ||
275 typeof asset.sha256 !== "string" ||
276 !SHA256.test(asset.sha256)
277 ) {
278 return null;
279 }
280
281 const rawURL = asset.url;
282 const safeURL = safeHTTPSURL(rawURL);
283 const base = typeof rawURL === "string"
284 ? allowedBases.find((candidate) => rawURL === candidate + fileName)
285 : undefined;
286 if (
287 !safeURL ||
288 !base ||
289 asset.sig !== `${rawURL}.minisig` ||
290 (selectedBase && selectedBase !== base)
291 ) {
292 return null;
293 }
294 selectedBase = base;
295 }
296
297 return selectedBase ? manifest : null;
298 }
299
300 function parseDesktopManifestJSON(
301 text: string,
302 channel: PublicReleaseChannel,
303 expectedVersion?: string,
304 ): Record<string, unknown> | null {
305 try {
306 return normalizeDesktopManifest(JSON.parse(text), channel, expectedVersion);
307 } catch {
308 return null;
309 }
310 }
311
312 async function fetchManifestText(
313 url: string,
314 source: string,
315 channel: PublicReleaseChannel,
316 expectedVersion?: string,
317 ): Promise<Response | null> {
318 try {
319 const safeURL = safeHTTPSURL(url);
320 if (!safeURL) return null;
321 const res = await fetch(safeURL, {
322 headers: {
323 accept: "application/json",
324 "user-agent": "reasonix-release-gateway",
325 },
326 });
327 if (!res.ok) return null;
328 const text = await res.text();
329 if (!parseDesktopManifestJSON(text, channel, expectedVersion)) return null;
330 return new Response(text, { status: 200, headers: gatewayHeaders(source) });
331 } catch {
332 return null;
333 }
334 }
335
336 async function fetchLatestDesktopManifestFromGitHub(): Promise<Response | null> {
337 try {
338 const latest = await fetch(GITHUB_LATEST_RELEASE_API, {
339 headers: {
340 accept: "application/vnd.github+json",
341 "user-agent": "reasonix-release-gateway",
342 },
343 });
344 if (!latest.ok) return null;
345
346 const release = (await latest.json()) as GitHubRelease;
347 const tag = typeof release.tag_name === "string" ? release.tag_name : "";
348 const match = tag.match(/^desktop-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/);
349 if (!match || release.draft !== false || release.prerelease !== false) return null;
350
351 const expectedManifestURL =
352 `https://github.com/esengine/DeepSeek-Reasonix/releases/download/${tag}/latest.json`;
353 const manifest = Array.isArray(release.assets)
354 ? release.assets.find((asset) =>
355 asset?.name === "latest.json" &&
356 asset.browser_download_url === expectedManifestURL &&
357 safeHTTPSURL(asset.browser_download_url) === expectedManifestURL &&
358 Number.isSafeInteger(asset.size) &&
359 (asset.size as number) > 0 &&
360 (asset.size as number) <= MAX_RELEASE_ASSET_SIZE)
361 : undefined;
362 if (!manifest?.browser_download_url) return null;
363 return fetchManifestText(
364 manifest.browser_download_url,
365 "github-desktop-release",
366 "stable",
367 match[1],
368 );
369 } catch {
370 return null;
371 }
372 }
373
374 export async function handleDesktopReleaseManifest(channel: ReleaseChannel): Promise<Response> {
375 const publicChannel = channel === "stable" ? "stable" : "preview";
376 const r2 = await fetchManifestText(manifestPointer(channel), `r2-${channel}`, publicChannel);
377 if (r2) return r2;
378
379 if (channel === "preview") {
380 const canary = await fetchManifestText(manifestPointer("canary"), "r2-canary-compat", "preview");
381 if (canary) return canary;
382 }
383
384 if (channel === "stable") {
385 const github = await fetchLatestDesktopManifestFromGitHub();
386 if (github) return github;
387 }
388
389 return new Response(JSON.stringify({ error: "desktop release manifest unavailable", channel }) + "\n", {
390 status: 502,
391 headers: gatewayHeaders("unavailable"),
392 });
393 }
394
395 export async function handleCLIRelease(channel: PublicReleaseChannel): Promise<Response> {
396 const cacheStorage = (globalThis as unknown as {
397 caches?: CacheStorage & { default?: Cache };
398 }).caches;
399 const cache = cacheStorage?.default;
400 const cacheKey = new Request(`https://crash.reasonix.io/v1/cli/releases/${channel}/latest.json`);
401 const cached = await cache?.match(cacheKey);
402 if (cached) return cached;
403
404 const pointer = await fetchCLIRelease(`${R2_BASE}/cli/${channel}/latest.json`, channel, `r2-cli-${channel}`);
405 const response = pointer ?? (await fetchLatestCLIReleaseFromGitHub(channel));
406 if (response) {
407 await cache?.put(cacheKey, response.clone());
408 return response;
409 }
410
411 return new Response(JSON.stringify({ error: "CLI release unavailable", channel }) + "\n", {
412 status: 502,
413 headers: gatewayHeaders("unavailable"),
414 });
415 }
416
417 export function desktopReleaseChannel(path: string): ReleaseChannel | null {
418 const match = path.match(/^\/v1\/desktop\/releases\/(stable|preview|canary)\/latest\.json$/);
419 return (match?.[1] as ReleaseChannel | undefined) ?? null;
420 }
421
422 export function cliReleaseChannel(path: string): PublicReleaseChannel | null {
423 const match = path.match(/^\/v1\/cli\/releases\/(stable|preview)\/latest\.json$/);
424 return (match?.[1] as PublicReleaseChannel | undefined) ?? null;
425 }
426
426 lines TYPESCRIPT