返回 DeepSeek-Reasonix
release-channels.js
根目录 / site / src / scripts / release-channels.js
1 const STABLE_TAG = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
2 const DESKTOP_DOWNLOAD_PAGE = "https://reasonix.io/?download=desktop#start";
3 const DESKTOP_ASSETS = [
4 ["platforms", "darwin-arm64", "Reasonix-darwin-arm64.zip"],
5 ["platforms", "darwin-amd64", "Reasonix-darwin-amd64.zip"],
6 ["platforms", "windows-amd64", "Reasonix-windows-amd64-installer.exe"],
7 ["platforms", "windows-arm64", "Reasonix-windows-arm64-installer.exe"],
8 ["platforms", "linux-amd64", "Reasonix-linux-amd64.tar.gz"],
9 ["native_packages", "linux-amd64", "Reasonix-linux-amd64.deb"],
10 ["downloads", "Reasonix-darwin-universal.dmg", "Reasonix-darwin-universal.dmg"],
11 ["downloads", "Reasonix-windows-amd64.zip", "Reasonix-windows-amd64.zip"],
12 ];
13 const DESKTOP_ASSET_NAMES = new Set(DESKTOP_ASSETS.map(([, , name]) => name));
14 const OFFICIAL_DESKTOP_RELEASE_TAG = /^(?:desktop-)?(v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*))$/;
15 const SHA256 = /^[0-9a-f]{64}$/;
16 const MAX_RELEASE_ASSET_SIZE = 1 << 30;
17
18 // Keep in lockstep with workers/crash-report CLI asset gate.
19 export const CLI_RELEASE_ASSETS = [
20 "reasonix-darwin-amd64.tar.gz",
21 "reasonix-darwin-arm64.tar.gz",
22 "reasonix-linux-amd64.tar.gz",
23 "reasonix-linux-arm64.tar.gz",
24 "reasonix-windows-amd64.zip",
25 "reasonix-windows-arm64.zip",
26 "SHA256SUMS",
27 ];
28 const CLI_RELEASE_ASSET_NAMES = new Set(CLI_RELEASE_ASSETS);
29
30 export const publicReleaseChannels = new Set(["stable"]);
31
32 export function normalizePublicReleaseChannel(_value) {
33 return "stable";
34 }
35
36 export function cliUpgradeCommand(_value) {
37 return "reasonix upgrade";
38 }
39
40 export function releaseVersionLabel(model) {
41 return String(model?.version || "").trim() || "latest";
42 }
43
44 function parsePublicTag(tag) {
45 const value = typeof tag === "string" ? tag : "";
46 let match = value.match(STABLE_TAG);
47 if (match) {
48 return { tag: value, channel: "stable", order: match.slice(1) };
49 }
50 return null;
51 }
52
53 function compareDecimal(left, right) {
54 if (left.length !== right.length) return left.length - right.length;
55 return left === right ? 0 : left > right ? 1 : -1;
56 }
57
58 function compareOrder(left, right) {
59 for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
60 const delta = compareDecimal(left[i] || "0", right[i] || "0");
61 if (delta !== 0) return delta;
62 }
63 return 0;
64 }
65
66 function safeHTTPSURL(value) {
67 if (typeof value !== "string") return null;
68 try {
69 const url = new URL(value);
70 return url.protocol === "https:" &&
71 Boolean(url.hostname) &&
72 !url.username &&
73 !url.password
74 ? url
75 : null;
76 } catch {
77 return null;
78 }
79 }
80
81 function expectedCLIAssetURL(value, tag, name) {
82 if (typeof value !== "string") return null;
83 const url = safeHTTPSURL(value);
84 const path = `/esengine/DeepSeek-Reasonix/releases/download/${tag}/${name}`;
85 return url &&
86 url.href === value &&
87 url.hostname.toLowerCase() === "github.com" &&
88 !url.port &&
89 url.pathname === path &&
90 !url.search &&
91 !url.hash
92 ? url
93 : null;
94 }
95
96 // Returns the 7 required CLI asset URLs, or null when any required asset is
97 // missing. Never synthesizes download URLs — incomplete releases must be
98 // rejected so the site never advertises 404 links.
99 export function releaseAssetMap(release) {
100 const found = {};
101 const seen = new Set();
102 for (const asset of Array.isArray(release?.assets) ? release.assets : []) {
103 const name = String(asset?.name || "");
104 if (!CLI_RELEASE_ASSET_NAMES.has(name)) continue;
105 if (seen.has(name)) return null;
106 seen.add(name);
107 const url = expectedCLIAssetURL(asset?.browser_download_url, String(release?.tag_name || ""), name);
108 if (
109 name &&
110 url &&
111 Number.isSafeInteger(asset?.size) &&
112 asset.size > 0 &&
113 asset.size <= MAX_RELEASE_ASSET_SIZE
114 ) {
115 found[name] = url.href;
116 }
117 }
118 const assets = {};
119 for (const name of CLI_RELEASE_ASSETS) {
120 if (!found[name]) return null;
121 assets[name] = found[name];
122 }
123 return assets;
124 }
125
126 export function selectCLIRelease(releases, requestedChannel) {
127 const channel = normalizePublicReleaseChannel(requestedChannel);
128 let selected = null;
129 let selectedTag = null;
130 for (const release of Array.isArray(releases) ? releases : []) {
131 const parsed = parsePublicTag(release?.tag_name);
132 if (!parsed || parsed.channel !== channel) continue;
133 if (Boolean(release?.prerelease)) continue;
134 if (!releaseAssetMap(release)) continue;
135 if (!selectedTag || compareOrder(parsed.order, selectedTag.order) > 0) {
136 selected = release;
137 selectedTag = parsed;
138 }
139 }
140 return selected;
141 }
142
143 export function cliReleaseModel(releases, requestedChannel) {
144 const channel = normalizePublicReleaseChannel(requestedChannel);
145 const release = selectCLIRelease(releases, channel);
146 if (!release) return null;
147 const parsed = parsePublicTag(release.tag_name);
148 if (!parsed) return null;
149 const assets = releaseAssetMap(release);
150 if (!assets) return null;
151 const releaseURL = `https://github.com/esengine/DeepSeek-Reasonix/releases/tag/${parsed.tag}`;
152 const exactChangelogURL = `https://reasonix.io/changelog/${parsed.tag}/`;
153 const changelogURL = release.release_notes_url === exactChangelogURL
154 ? exactChangelogURL
155 : "https://reasonix.io/changelog/";
156 return {
157 channel,
158 version: parsed.tag,
159 displayVersion: parsed.tag.slice(1),
160 assets,
161 releaseURL,
162 changelogURL,
163 };
164 }
165
166 function desktopAssetBases(parsed) {
167 const tag = `desktop-${parsed.tag}`;
168 return [
169 `https://dl.reasonix.io/${tag}/`,
170 `https://github.com/esengine/DeepSeek-Reasonix/releases/download/${tag}/`,
171 `https://github.com/esengine/DeepSeek-Reasonix/releases/download/${parsed.tag}/`,
172 ];
173 }
174
175 function normalizeDesktopManifest(manifest, requestedChannel) {
176 const channel = normalizePublicReleaseChannel(requestedChannel);
177 const parsed = parsePublicTag(manifest?.version);
178 if (
179 !parsed ||
180 parsed.channel !== channel ||
181 manifest?.download_page !== DESKTOP_DOWNLOAD_PAGE
182 ) {
183 return null;
184 }
185
186 const allowedBases = desktopAssetBases(parsed);
187 let selectedBase = "";
188 for (const [group, key, name] of DESKTOP_ASSETS) {
189 const asset = manifest?.[group]?.[key];
190 if (
191 !asset ||
192 typeof asset !== "object" ||
193 Array.isArray(asset) ||
194 !Number.isSafeInteger(asset.size) ||
195 asset.size <= 0 ||
196 asset.size > MAX_RELEASE_ASSET_SIZE ||
197 typeof asset.sha256 !== "string" ||
198 !SHA256.test(asset.sha256)
199 ) {
200 return null;
201 }
202
203 const rawURL = typeof asset.url === "string" ? asset.url : "";
204 const url = safeHTTPSURL(rawURL);
205 const base = allowedBases.find((candidate) => rawURL === candidate + name);
206 if (
207 !url ||
208 !base ||
209 url.href !== rawURL ||
210 asset.sig !== `${rawURL}.minisig` ||
211 (selectedBase && selectedBase !== base)
212 ) {
213 return null;
214 }
215 selectedBase = base;
216 }
217 return selectedBase ? { parsed } : null;
218 }
219
220 export function desktopReleaseModel(manifest, requestedChannel) {
221 const normalized = normalizeDesktopManifest(manifest, requestedChannel);
222 if (!normalized) return null;
223 const { parsed } = normalized;
224 const assets = Object.fromEntries(DESKTOP_ASSETS.map(([group, key, name]) => [
225 name,
226 manifest[group][key].url,
227 ]));
228 return {
229 channel: parsed.channel,
230 version: parsed.tag,
231 displayVersion: parsed.tag.slice(1),
232 assets,
233 changelogURL: manifest.release_notes_url === `https://reasonix.io/changelog/${parsed.tag}/`
234 ? manifest.release_notes_url
235 : "https://reasonix.io/changelog/",
236 };
237 }
238
239 // Accept both historical desktop-v* releases and the combined v* release.
240 export function desktopGitHubReleaseModel(release) {
241 const match = typeof release?.tag_name === "string"
242 ? release.tag_name.match(OFFICIAL_DESKTOP_RELEASE_TAG)
243 : null;
244 if (!match || release?.draft !== false || release?.prerelease !== false) return null;
245
246 const tag = release.tag_name;
247 const found = {};
248 const seen = new Set();
249 for (const asset of Array.isArray(release.assets) ? release.assets : []) {
250 const name = String(asset?.name || "");
251 if (!DESKTOP_ASSET_NAMES.has(name)) continue;
252 if (seen.has(name)) return null;
253 seen.add(name);
254 const rawURL = typeof asset?.browser_download_url === "string" ? asset.browser_download_url : "";
255 const url = safeHTTPSURL(rawURL);
256 const expected = `https://github.com/esengine/DeepSeek-Reasonix/releases/download/${tag}/${name}`;
257 if (
258 !url ||
259 url.href !== rawURL ||
260 rawURL !== expected ||
261 !Number.isSafeInteger(asset?.size) ||
262 asset.size <= 0 ||
263 asset.size > MAX_RELEASE_ASSET_SIZE
264 ) {
265 return null;
266 }
267 found[name] = rawURL;
268 }
269 if (DESKTOP_ASSETS.some(([, , name]) => !found[name])) return null;
270
271 return {
272 channel: "stable",
273 version: match[1],
274 displayVersion: match[1].slice(1),
275 assets: Object.fromEntries(DESKTOP_ASSETS.map(([, , name]) => [name, found[name]])),
276 changelogURL: "https://reasonix.io/changelog/",
277 };
278 }
279
280 export async function fetchFirstJSON(urls, fetchImpl = fetch, accept = () => true) {
281 const failures = [];
282 for (const url of urls) {
283 try {
284 const response = await fetchImpl(url, { cache: "no-cache" });
285 if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim());
286 const payload = await response.json();
287 if (!accept(payload)) throw new Error("invalid release data");
288 return payload;
289 } catch (error) {
290 failures.push(`${url}: ${String(error?.message || error)}`);
291 }
292 }
293 throw new Error(`release data unavailable (${failures.join("; ")})`);
294 }
295
295 lines JAVASCRIPT