1import { sha256 } from "@noble/hashes/sha2";
2import JSZip from "jszip";
3import { parse, type ParseError } from "jsonc-parser";
4
5import type { ThemeDefinition } from "./themePalette";
6import {
7 isVsCodeThemeFile,
8 pairVsCodeThemes,
9 parseVsCodeThemeFile,
10 resolveThemeLabelCollisions,
11} from "./vscodeThemeImport";
12
13const OPEN_VSX_SEARCH_URL = "https://open-vsx.org/api/-/search";
14const MAX_VSIX_BYTES = 20 * 1024 * 1024;
15const MAX_SEARCH_BYTES = 512 * 1024;
16const MAX_DETAIL_BYTES = 256 * 1024;
17const MAX_MANIFEST_BYTES = 256 * 1024;
18const SEARCH_REQUEST_TIMEOUT_MS = 10_000;
19const MAX_THEME_BYTES = 256 * 1024;
20const MAX_ZIP_ENTRIES = 5_000;
21const MAX_UNCOMPRESSED_BYTES = 100 * 1024 * 1024;
22const MAX_COMPRESSION_RATIO = 200;
23const MAX_THEMES_PER_EXTENSION = 40;
24const MAX_INCLUDE_DEPTH = 8;
25const MAX_PACKAGE_PATH_LENGTH = 1_024;
26const MAX_COLOR_VALUE_LENGTH = 128;
27const MAX_RESOLVED_THEME_FILES = MAX_THEMES_PER_EXTENSION * MAX_INCLUDE_DEPTH;
28const SUPPORTED_LICENSES = new Set([
29 "0BSD",
30 "Apache-2.0",
31 "BSD-2-Clause",
32 "BSD-3-Clause",
33 "CC0-1.0",
34 "ISC",
35 "MIT",
36 "MPL-2.0",
37 "Unlicense",
38]);
39const USED_WORKBENCH_COLORS = new Set([
40 "activityBar.background",
41 "activityBarBadge.background",
42 "badge.background",
43 "button.background",
44 "button.foreground",
45 "contrastBorder",
46 "descriptionForeground",
47 "disabledForeground",
48 "dropdown.background",
49 "dropdown.border",
50 "editor.background",
51 "editor.foreground",
52 "editor.findMatchBackground",
53 "editor.findMatchForeground",
54 "editor.findMatchHighlightBackground",
55 "editor.findMatchHighlightForeground",
56 "editor.selectionBackground",
57 "editorCursor.foreground",
58 "editorError.foreground",
59 "editorGroup.border",
60 "editorPane.background",
61 "editorWarning.foreground",
62 "editorWidget.background",
63 "errorForeground",
64 "focusBorder",
65 "foreground",
66 "input.border",
67 "input.placeholderForeground",
68 "list.activeSelectionBackground",
69 "list.hoverBackground",
70 "list.inactiveSelectionBackground",
71 "menu.background",
72 "panel.background",
73 "panel.border",
74 "progressBar.background",
75 "quickInput.background",
76 "scrollbarSlider.background",
77 "sideBar.background",
78 "sideBar.border",
79 "sideBar.foreground",
80 "terminal.background",
81 "terminal.foreground",
82 "terminal.selectionBackground",
83 "terminalCursor.foreground",
84 "textCodeBlock.background",
85 "textLink.foreground",
86]);
87
88export type OpenVsxThemeSort = "downloadCount" | "rating" | "timestamp" | "relevance";
89
90export type OpenVsxThemeExtension = {
91 id: string;
92 collectionId: string;
93 name: string;
94 publisher: string;
95 description: string;
96 downloadCount: number;
97 iconUrl: string | null;
98 sourceUrl: string | null;
99 manifestUrl: string;
100 sha256Url: string;
101 vsixUrl: string;
102 version: string;
103 license: string;
104};
105
106export type OpenVsxThemeSearchOptions = {
107 signal?: AbortSignal;
108 sortBy?: OpenVsxThemeSort;
109};
110
111type ThemeContribution = { label?: unknown; uiTheme?: unknown; path?: unknown };
112
113function isRecord(value: unknown): value is Record<string, unknown> {
114 return typeof value === "object" && value !== null && !Array.isArray(value);
115}
116
117function shortHash(value: string): string {
118 return [...sha256(new TextEncoder().encode(value))]
119 .slice(0, 6)
120 .map((byte) => byte.toString(16).padStart(2, "0"))
121 .join("");
122}
123
124function openVsxThemeId(extensionId: string, source: string): string {
125 return `ovx-theme-${shortHash(`${extensionId}:${source}`)}`;
126}
127
128function openVsxCollectionId(extensionId: string): string {
129 const normalized = `open-vsx:${extensionId.toLowerCase()}`;
130 return /^[a-z0-9][a-z0-9.:-]{0,127}$/.test(normalized)
131 ? normalized
132 : `open-vsx:${shortHash(extensionId)}`;
133}
134
135function trustedOpenVsxUrl(value: unknown): string | null {
136 if (typeof value !== "string") return null;
137 try {
138 const url = new URL(value);
139 return url.protocol === "https:" && url.hostname.toLowerCase() === "open-vsx.org"
140 ? url.toString()
141 : null;
142 } catch {
143 return null;
144 }
145}
146
147function publicSourceUrl(value: unknown): string | null {
148 const rawValue =
149 typeof value === "string"
150 ? value
151 : isRecord(value) && typeof value.url === "string"
152 ? value.url
153 : null;
154 if (!rawValue) return null;
155 try {
156 const url = new URL(rawValue);
157 return url.protocol === "https:" && !url.username && !url.password ? url.toString() : null;
158 } catch {
159 return null;
160 }
161}
162
163function themeContributions(manifest: Record<string, unknown>): ThemeContribution[] {
164 const contributes = isRecord(manifest.contributes) ? manifest.contributes : null;
165 return Array.isArray(contributes?.themes)
166 ? (contributes.themes.filter(isRecord) as ThemeContribution[])
167 : [];
168}
169
170function manifestLicenseMatches(manifest: Record<string, unknown>, license: string): boolean {
171 return (
172 typeof manifest.license === "string" &&
173 manifest.license.trim().toLowerCase() === license.toLowerCase()
174 );
175}
176
177function extensionFromDetail(value: unknown): OpenVsxThemeExtension | null {
178 if (!isRecord(value) || !isRecord(value.files)) {
179 throw new Error("Open VSX returned malformed theme details.");
180 }
181 const namespace = typeof value.namespace === "string" ? value.namespace.trim() : "";
182 const extensionName = typeof value.name === "string" ? value.name.trim() : "";
183 const displayName =
184 (typeof value.displayName === "string" ? value.displayName.trim() : "") || extensionName;
185 const version = typeof value.version === "string" ? value.version.trim() : "";
186 const license = typeof value.license === "string" ? value.license.trim() : "";
187 const manifestUrl = trustedOpenVsxUrl(value.files.manifest);
188 const sha256Url = trustedOpenVsxUrl(value.files.sha256);
189 const vsixUrl = trustedOpenVsxUrl(value.files.download);
190 if (!namespace || !extensionName || !version || !manifestUrl || !sha256Url || !vsixUrl) {
191 throw new Error("Open VSX returned malformed theme details.");
192 }
193 if (!SUPPORTED_LICENSES.has(license)) return null;
194 const id = `${namespace}.${extensionName}`;
195 return {
196 id,
197 collectionId: openVsxCollectionId(id),
198 name: displayName,
199 publisher: namespace,
200 description: typeof value.description === "string" ? value.description : "",
201 downloadCount:
202 typeof value.downloadCount === "number" && Number.isFinite(value.downloadCount)
203 ? value.downloadCount
204 : 0,
205 iconUrl: trustedOpenVsxUrl(value.files.icon),
206 sourceUrl:
207 publicSourceUrl(value.repository) ??
208 publicSourceUrl(value.homepage) ??
209 publicSourceUrl(value.url),
210 manifestUrl,
211 sha256Url,
212 vsixUrl,
213 version,
214 license,
215 };
216}
217
218async function withSearchTimeout<T>(
219 operation: (signal: AbortSignal) => Promise<T>,
220 parentSignal?: AbortSignal,
221): Promise<T> {
222 const controller = new AbortController();
223 const abort = () => controller.abort();
224 if (parentSignal?.aborted) abort();
225 else parentSignal?.addEventListener("abort", abort, { once: true });
226 const timeout = setTimeout(abort, SEARCH_REQUEST_TIMEOUT_MS);
227 try {
228 return await operation(controller.signal);
229 } catch (cause) {
230 if (controller.signal.aborted && !parentSignal?.aborted) {
231 throw new Error("Open VSX took too long to respond.", { cause });
232 }
233 throw cause;
234 } finally {
235 clearTimeout(timeout);
236 parentSignal?.removeEventListener("abort", abort);
237 }
238}
239
240export async function searchOpenVsxThemes(
241 query: string,
242 { signal, sortBy = "downloadCount" }: OpenVsxThemeSearchOptions = {},
243): Promise<OpenVsxThemeExtension[]> {
244 const searchText = query.trim();
245 if (!searchText) return [];
246 const url = new URL(OPEN_VSX_SEARCH_URL);
247 url.searchParams.set("query", searchText);
248 url.searchParams.set("category", "Themes");
249 url.searchParams.set("sortBy", sortBy);
250 url.searchParams.set("sortOrder", "desc");
251 // Ask for a few extras because results without a supported SPDX license
252 // are intentionally omitted.
253 url.searchParams.set("size", "16");
254 const value = await withSearchTimeout(async (requestSignal) => {
255 const response = await fetch(url, { signal: requestSignal });
256 if (!response.ok) throw new Error("Open VSX search is unavailable right now.");
257 const searchBytes = await readCappedResponse(
258 response,
259 MAX_SEARCH_BYTES,
260 "Open VSX returned an unexpectedly large response.",
261 );
262 try {
263 return JSON.parse(new TextDecoder().decode(searchBytes)) as unknown;
264 } catch {
265 throw new Error("Open VSX returned an unreadable response.");
266 }
267 }, signal);
268 if (!isRecord(value) || !Array.isArray(value.extensions)) {
269 throw new Error("Open VSX returned an unreadable search response.");
270 }
271 const identities = value.extensions.flatMap((candidate): Array<[string, string]> => {
272 if (!isRecord(candidate)) return [];
273 const namespace = typeof candidate.namespace === "string" ? candidate.namespace : "";
274 const name = typeof candidate.name === "string" ? candidate.name : "";
275 return namespace && name ? [[namespace, name]] : [];
276 });
277 const details = await Promise.allSettled(
278 identities.slice(0, 16).map(([namespace, name]) =>
279 withSearchTimeout(async (requestSignal) => {
280 const detailUrl = `https://open-vsx.org/api/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`;
281 const detailResponse = await fetch(detailUrl, { signal: requestSignal });
282 if (!detailResponse.ok) throw new Error("Open VSX theme details are unavailable.");
283 const detailBytes = await readCappedResponse(
284 detailResponse,
285 MAX_DETAIL_BYTES,
286 "Open VSX returned an unexpectedly large detail response.",
287 );
288 try {
289 const extension = extensionFromDetail(JSON.parse(new TextDecoder().decode(detailBytes)));
290 if (!extension) return null;
291 const [manifestResponse, packageResponse] = await Promise.all([
292 fetch(extension.manifestUrl, { signal: requestSignal }),
293 fetch(extension.vsixUrl, { method: "HEAD", signal: requestSignal }),
294 ]);
295 if (!manifestResponse.ok) throw new Error("manifest unavailable");
296 if (!packageResponse.ok) return null;
297 const packageLength = Number(packageResponse.headers.get("content-length"));
298 if (Number.isFinite(packageLength) && packageLength > MAX_VSIX_BYTES) {
299 return null;
300 }
301 const manifestBytes = await readCappedResponse(
302 manifestResponse,
303 MAX_MANIFEST_BYTES,
304 "Open VSX returned an unexpectedly large manifest.",
305 );
306 const manifest = parseJsoncObject(
307 new TextDecoder().decode(manifestBytes),
308 "Extension manifest",
309 );
310 return themeContributions(manifest).length > 0 &&
311 manifestLicenseMatches(manifest, extension.license)
312 ? extension
313 : null;
314 } catch {
315 throw new Error("Open VSX returned unreadable theme details.");
316 }
317 }, signal),
318 ),
319 );
320 if (signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
321 const completedDetails = details.filter((result) => result.status === "fulfilled");
322 if (identities.length > 0 && completedDetails.length === 0) {
323 throw new Error("Open VSX theme details are unavailable right now.");
324 }
325 return completedDetails.flatMap((result) => (result.value ? [result.value] : [])).slice(0, 8);
326}
327
328function parseJsoncObject(source: string, description: string): Record<string, unknown> {
329 const errors: ParseError[] = [];
330 const value: unknown = parse(source, errors, { allowTrailingComma: true });
331 if (errors.length > 0 || !isRecord(value)) throw new Error(`${description} is not valid JSON.`);
332 return value;
333}
334
335function sanitizeThemeObject(value: Record<string, unknown>): Record<string, unknown> {
336 const colors: Record<string, string> = {};
337 if (isRecord(value.colors)) {
338 for (const [key, color] of Object.entries(value.colors)) {
339 if (
340 USED_WORKBENCH_COLORS.has(key) &&
341 typeof color === "string" &&
342 color.length <= MAX_COLOR_VALUE_LENGTH
343 ) {
344 colors[key] = color;
345 }
346 }
347 }
348 return {
349 ...(typeof value.include === "string" ? { include: value.include } : {}),
350 colors,
351 };
352}
353
354function normalizePackagePath(path: string, relativeTo = "extension/"): string {
355 if (
356 path.length > MAX_PACKAGE_PATH_LENGTH ||
357 path.includes("\0") ||
358 path.startsWith("/") ||
359 /^[a-zA-Z]:/.test(path)
360 ) {
361 throw new Error("Theme path is not a safe relative package path.");
362 }
363 const normalizedInput = path.replaceAll("\\", "/");
364 const baseSegments = relativeTo.split("/").slice(0, -1);
365 const segments = baseSegments;
366 for (const segment of normalizedInput.split("/")) {
367 if (!segment || segment === ".") continue;
368 if (segment === "..") {
369 if (segments.length <= 1) throw new Error("Theme path escapes the extension package.");
370 segments.pop();
371 continue;
372 }
373 segments.push(segment);
374 }
375 if (segments[0] !== "extension") segments.unshift("extension");
376 return segments.join("/");
377}
378
379function contributionType(uiTheme: unknown): string | null {
380 if (uiTheme === "vs") return "light";
381 if (uiTheme === "vs-dark") return "dark";
382 if (uiTheme === "hc-black" || uiTheme === "hc-light") return uiTheme;
383 return null;
384}
385
386type ZipEntrySizes = {
387 uncompressedSize?: unknown;
388};
389
390type InspectableZipObject = JSZip.JSZipObject & {
391 _data?: ZipEntrySizes;
392 unsafeOriginalName?: string;
393 internalStream?: (type: "uint8array") => JSZip.JSZipStreamHelper<Uint8Array>;
394};
395
396function inspectZipDirectory(bytes: Uint8Array): Uint8Array {
397 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
398 const minimumOffset = Math.max(0, bytes.byteLength - 65_557);
399 let endOffset = bytes.byteLength - 22;
400 while (
401 endOffset >= minimumOffset &&
402 (view.getUint32(endOffset, true) !== 0x06054b50 ||
403 endOffset + 22 + view.getUint16(endOffset + 20, true) !== bytes.byteLength)
404 ) {
405 endOffset -= 1;
406 }
407 if (endOffset < minimumOffset) throw new Error("That extension package has no ZIP directory.");
408
409 const directorySize = view.getUint32(endOffset + 12, true);
410 const directoryOffset = view.getUint32(endOffset + 16, true);
411 const directoryEnd = directoryOffset + directorySize;
412 if (directoryEnd !== endOffset || directoryEnd > bytes.byteLength) {
413 throw new Error("That extension package has an invalid ZIP directory.");
414 }
415
416 let entryCount = 0;
417 let totalUncompressed = 0;
418 let offset = directoryOffset;
419 while (offset < directoryEnd) {
420 if (offset + 46 > directoryEnd || view.getUint32(offset, true) !== 0x02014b50) {
421 throw new Error("That extension package has an invalid ZIP directory.");
422 }
423 entryCount += 1;
424 if (entryCount > MAX_ZIP_ENTRIES) {
425 throw new Error("That extension package has too many files.");
426 }
427 const compressed = view.getUint32(offset + 20, true);
428 const uncompressed = view.getUint32(offset + 24, true);
429 if (compressed === 0xffffffff || uncompressed === 0xffffffff) {
430 throw new Error("That extension package has unsupported ZIP64 metadata.");
431 }
432 totalUncompressed += uncompressed;
433 if (totalUncompressed > MAX_UNCOMPRESSED_BYTES) {
434 throw new Error("That extension package expands beyond the safe import limit.");
435 }
436 if (
437 uncompressed > 0 &&
438 (compressed === 0 || uncompressed / compressed > MAX_COMPRESSION_RATIO)
439 ) {
440 throw new Error("That extension package has an unsafe compression ratio.");
441 }
442 const nameLength = view.getUint16(offset + 28, true);
443 const extraLength = view.getUint16(offset + 30, true);
444 const commentLength = view.getUint16(offset + 32, true);
445 offset += 46 + nameLength + extraLength + commentLength;
446 }
447 if (offset !== directoryEnd)
448 throw new Error("That extension package has an invalid ZIP directory.");
449
450 const commentLength = view.getUint16(endOffset + 20, true);
451 if (commentLength === 0) return bytes;
452
453 // JSZip mistakes EOCD-like bytes inside an archive comment for the real EOCD.
454 // The comment is not needed for theme import, so remove it before parsing.
455 const withoutComment = bytes.slice(0, endOffset + 22);
456 withoutComment[endOffset + 20] = 0;
457 withoutComment[endOffset + 21] = 0;
458 return withoutComment;
459}
460
461function inspectZip(zip: JSZip): void {
462 const entries = Object.values(zip.files) as InspectableZipObject[];
463 if (entries.length > MAX_ZIP_ENTRIES)
464 throw new Error("That extension package has too many files.");
465
466 for (const entry of entries) {
467 if (entry.unsafeOriginalName) normalizePackagePath(entry.unsafeOriginalName);
468 }
469}
470
471async function readZipText(
472 zip: JSZip,
473 path: string,
474 description: string,
475 signal?: AbortSignal,
476): Promise<string> {
477 signal?.throwIfAborted();
478 const file = zip.file(path) as InspectableZipObject | null;
479 if (!file) throw new Error(`${description} is missing from the extension package.`);
480 if (typeof file._data?.uncompressedSize !== "number" || !file.internalStream) {
481 throw new Error(`${description} has unreadable size metadata.`);
482 }
483 if (file._data.uncompressedSize > MAX_THEME_BYTES) {
484 throw new Error(`${description} is too large.`);
485 }
486
487 return new Promise((resolve, reject) => {
488 const chunks: Uint8Array[] = [];
489 let byteLength = 0;
490 let settled = false;
491 const stream = file.internalStream!("uint8array");
492 const cleanup = () => signal?.removeEventListener("abort", handleAbort);
493 const handleAbort = () => {
494 if (settled) return;
495 settled = true;
496 stream.pause();
497 cleanup();
498 reject(signal?.reason);
499 };
500 signal?.addEventListener("abort", handleAbort, { once: true });
501 stream
502 .on("data", (chunk) => {
503 if (settled) return;
504 byteLength += chunk.byteLength;
505 if (byteLength > MAX_THEME_BYTES) {
506 settled = true;
507 stream.pause();
508 cleanup();
509 reject(new Error(`${description} is too large.`));
510 return;
511 }
512 chunks.push(chunk);
513 })
514 .on("error", (cause) => {
515 if (settled) return;
516 settled = true;
517 cleanup();
518 reject(cause);
519 })
520 .on("end", () => {
521 if (settled) return;
522 settled = true;
523 cleanup();
524 const bytes = new Uint8Array(byteLength);
525 let offset = 0;
526 for (const chunk of chunks) {
527 bytes.set(chunk, offset);
528 offset += chunk.byteLength;
529 }
530 resolve(new TextDecoder().decode(bytes));
531 })
532 .resume();
533 });
534}
535
536async function loadThemeObject(
537 zip: JSZip,
538 path: string,
539 cache: Map<string, Record<string, unknown>>,
540 budget: { files: number },
541 ancestors: ReadonlySet<string> = new Set(),
542 signal?: AbortSignal,
543): Promise<Record<string, unknown>> {
544 signal?.throwIfAborted();
545 if (ancestors.size >= MAX_INCLUDE_DEPTH) throw new Error("Theme includes are nested too deeply.");
546 if (ancestors.has(path)) throw new Error("Theme includes contain a cycle.");
547 const cached = cache.get(path);
548 if (cached) return cached;
549 budget.files += 1;
550 if (budget.files > MAX_RESOLVED_THEME_FILES) {
551 throw new Error("That extension references too many theme files.");
552 }
553
554 const value = sanitizeThemeObject(
555 parseJsoncObject(await readZipText(zip, path, path, signal), path),
556 );
557 if (typeof value.include !== "string") {
558 cache.set(path, value);
559 return value;
560 }
561
562 const includePath = normalizePackagePath(value.include, path);
563 const nextAncestors = new Set(ancestors);
564 nextAncestors.add(path);
565 const base = await loadThemeObject(zip, includePath, cache, budget, nextAncestors, signal);
566 const resolved = {
567 ...base,
568 ...value,
569 colors: {
570 ...(isRecord(base.colors) ? base.colors : {}),
571 ...(isRecord(value.colors) ? value.colors : {}),
572 },
573 };
574 cache.set(path, resolved);
575 return resolved;
576}
577
578async function readCappedResponse(
579 response: Response,
580 limit: number,
581 tooLargeMessage: string,
582): Promise<Uint8Array> {
583 const contentLength = response.headers.get("content-length");
584 if (contentLength && Number(contentLength) > limit) throw new Error(tooLargeMessage);
585 if (!response.body) {
586 const bytes = new Uint8Array(await response.arrayBuffer());
587 if (bytes.byteLength > limit) throw new Error(tooLargeMessage);
588 return bytes;
589 }
590
591 const reader = response.body.getReader();
592 const chunks: Uint8Array[] = [];
593 let byteLength = 0;
594 try {
595 while (true) {
596 const { done, value } = await reader.read();
597 if (done) break;
598 byteLength += value.byteLength;
599 if (byteLength > limit) {
600 await reader.cancel();
601 throw new Error(tooLargeMessage);
602 }
603 chunks.push(value);
604 }
605 } finally {
606 reader.releaseLock();
607 }
608
609 const result = new Uint8Array(byteLength);
610 let offset = 0;
611 for (const chunk of chunks) {
612 result.set(chunk, offset);
613 offset += chunk.byteLength;
614 }
615 return result;
616}
617
618async function fetchPackage(url: string, signal?: AbortSignal): Promise<Uint8Array> {
619 const response = await fetch(url, signal ? { signal } : {});
620 if (!response.ok) throw new Error("That Open VSX theme could not be downloaded.");
621 return readCappedResponse(
622 response,
623 MAX_VSIX_BYTES,
624 "That theme extension is too large to import safely.",
625 );
626}
627
628export async function importOpenVsxThemeExtension(
629 extension: OpenVsxThemeExtension,
630 signal?: AbortSignal,
631): Promise<ReadonlyArray<ThemeDefinition>> {
632 const manifestResponse = await fetch(extension.manifestUrl, signal ? { signal } : {});
633 if (!manifestResponse.ok) throw new Error("That Open VSX extension has no readable manifest.");
634 const manifestBytes = await readCappedResponse(
635 manifestResponse,
636 MAX_MANIFEST_BYTES,
637 "That Open VSX extension manifest is too large.",
638 );
639 const manifest = parseJsoncObject(new TextDecoder().decode(manifestBytes), "Extension manifest");
640 const advertisedContributions = themeContributions(manifest);
641 if (advertisedContributions.length === 0) {
642 throw new Error("That extension does not contain color themes.");
643 }
644 if (advertisedContributions.length > MAX_THEMES_PER_EXTENSION) {
645 throw new Error("That extension contains too many color themes to import safely.");
646 }
647
648 const packageBytes = await fetchPackage(extension.vsixUrl, signal);
649 signal?.throwIfAborted();
650 const checksumResponse = await fetch(extension.sha256Url, signal ? { signal } : {});
651 if (!checksumResponse.ok) throw new Error("That Open VSX theme has no readable checksum.");
652 const expectedChecksum = new TextDecoder()
653 .decode(
654 await readCappedResponse(
655 checksumResponse,
656 256,
657 "That Open VSX checksum response is invalid.",
658 ),
659 )
660 .trim()
661 .split(/\s+/)[0];
662 if (!expectedChecksum || !/^[a-f\d]{64}$/i.test(expectedChecksum)) {
663 throw new Error("That Open VSX theme has an invalid checksum.");
664 }
665 signal?.throwIfAborted();
666 const actualChecksum = [...sha256(packageBytes)]
667 .map((byte) => byte.toString(16).padStart(2, "0"))
668 .join("");
669 if (actualChecksum.toLowerCase() !== expectedChecksum.toLowerCase()) {
670 throw new Error("That Open VSX theme failed its integrity check.");
671 }
672 signal?.throwIfAborted();
673 let zip: JSZip;
674 try {
675 const inspectedPackageBytes = inspectZipDirectory(packageBytes);
676 zip = await JSZip.loadAsync(inspectedPackageBytes);
677 signal?.throwIfAborted();
678 inspectZip(zip);
679 } catch (cause) {
680 if (signal?.aborted) signal.throwIfAborted();
681 if (cause instanceof Error && cause.message.startsWith("That extension package")) throw cause;
682 throw new Error("That Open VSX extension package could not be opened.", { cause });
683 }
684
685 const packagedManifest = parseJsoncObject(
686 await readZipText(zip, "extension/package.json", "Extension manifest", signal),
687 "Extension manifest",
688 );
689 if (
690 typeof packagedManifest.publisher !== "string" ||
691 packagedManifest.publisher.toLowerCase() !== extension.publisher.toLowerCase() ||
692 typeof packagedManifest.name !== "string" ||
693 `${packagedManifest.publisher}.${packagedManifest.name}`.toLowerCase() !==
694 extension.id.toLowerCase() ||
695 packagedManifest.version !== extension.version
696 ) {
697 throw new Error("That extension package does not match the selected Open VSX theme.");
698 }
699 if (!manifestLicenseMatches(packagedManifest, extension.license)) {
700 throw new Error("That extension package does not match its advertised license.");
701 }
702 const contributions = themeContributions(packagedManifest);
703 if (contributions.length === 0) throw new Error("That extension does not contain color themes.");
704 if (contributions.length > MAX_THEMES_PER_EXTENSION) {
705 throw new Error("That extension contains too many color themes to import safely.");
706 }
707
708 const parsed: Array<{ theme: ThemeDefinition; sourceName: string; sourcePath: string }> = [];
709 const failures: string[] = [];
710 const themeCache = new Map<string, Record<string, unknown>>();
711 const themeBudget = { files: 0 };
712 for (const contribution of contributions) {
713 signal?.throwIfAborted();
714 if (typeof contribution.path !== "string") {
715 failures.push("theme path is missing");
716 continue;
717 }
718 try {
719 const path = normalizePackagePath(contribution.path);
720 const themeValue = await loadThemeObject(
721 zip,
722 path,
723 themeCache,
724 themeBudget,
725 new Set(),
726 signal,
727 );
728 const type = contributionType(contribution.uiTheme);
729 const label =
730 typeof contribution.label === "string" && contribution.label.trim()
731 ? contribution.label.trim()
732 : extension.name;
733 const decorated = {
734 ...themeValue,
735 displayName: label,
736 ...(type ? { type } : {}),
737 };
738 if (!isVsCodeThemeFile(decorated)) throw new Error("not a VS Code color theme");
739 parsed.push({
740 theme: parseVsCodeThemeFile(decorated),
741 sourceName: path.split("/").at(-1)!,
742 sourcePath: path,
743 });
744 } catch (cause) {
745 signal?.throwIfAborted();
746 failures.push(cause instanceof Error ? cause.message : "theme could not be read");
747 }
748 }
749 if (failures.length > 0) {
750 throw new Error("One or more color themes in that extension could not be imported safely.");
751 }
752 if (parsed.length === 0) {
753 throw new Error("That extension has no compatible color themes.");
754 }
755 const extensionId = extension.id.toLowerCase();
756 const sourcePathCounts = new Map<string, number>();
757 for (const { sourcePath } of parsed) {
758 sourcePathCounts.set(sourcePath, (sourcePathCounts.get(sourcePath) ?? 0) + 1);
759 }
760 const sourcePathOccurrences = new Map<string, number>();
761 const sourceIdentities = parsed.map(({ sourcePath }) => {
762 if (sourcePathCounts.get(sourcePath) === 1) return sourcePath;
763 const occurrence = sourcePathOccurrences.get(sourcePath) ?? 0;
764 sourcePathOccurrences.set(sourcePath, occurrence + 1);
765 return occurrence === 0 ? sourcePath : `${sourcePath}\0${occurrence}`;
766 });
767 const resolved = resolveThemeLabelCollisions(parsed).map((theme, index) => ({
768 ...theme,
769 id: openVsxThemeId(extensionId, sourceIdentities[index]!),
770 }));
771 const paired = pairVsCodeThemes(resolved, {
772 pairedId: (light, dark) => openVsxThemeId(extensionId, [light.id, dark.id].sort().join(":")),
773 });
774 const themes = resolveThemeLabelCollisions(paired.map((theme) => ({ theme })));
775 const collection = {
776 id: extension.collectionId,
777 label: extension.name.slice(0, 48),
778 };
779 return themes.map((theme) => ({ ...theme, collection }));
780}