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