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 url.searchParams.set("size", "16");
252 const value = await withSearchTimeout(async (requestSignal) => {
253 const response = await fetch(url, { signal: requestSignal });
254 if (!response.ok) throw new Error("Open VSX search is unavailable right now.");
255 const searchBytes = await readCappedResponse(
256 response,
257 MAX_SEARCH_BYTES,
258 "Open VSX returned an unexpectedly large response.",
259 );
260 try {
261 return JSON.parse(new TextDecoder().decode(searchBytes)) as unknown;
262 } catch {
263 throw new Error("Open VSX returned an unreadable response.");
264 }
265 }, signal);
266 if (!isRecord(value) || !Array.isArray(value.extensions)) {
267 throw new Error("Open VSX returned an unreadable search response.");
268 }
269 const identities = value.extensions.flatMap((candidate): Array<[string, string]> => {
270 if (!isRecord(candidate)) return [];
271 const namespace = typeof candidate.namespace === "string" ? candidate.namespace : "";
272 const name = typeof candidate.name === "string" ? candidate.name : "";
273 return namespace && name ? [[namespace, name]] : [];
274 });
275 const details = await Promise.allSettled(
276 identities.slice(0, 16).map(([namespace, name]) =>
277 withSearchTimeout(async (requestSignal) => {
278 const detailUrl = `https://open-vsx.org/api/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`;
279 const detailResponse = await fetch(detailUrl, { signal: requestSignal });
280 if (!detailResponse.ok) throw new Error("Open VSX theme details are unavailable.");
281 const detailBytes = await readCappedResponse(
282 detailResponse,
283 MAX_DETAIL_BYTES,
284 "Open VSX returned an unexpectedly large detail response.",
285 );
286 try {
287 const extension = extensionFromDetail(JSON.parse(new TextDecoder().decode(detailBytes)));
288 if (!extension) return null;
289 const [manifestResponse, packageResponse] = await Promise.all([
290 fetch(extension.manifestUrl, { signal: requestSignal }),
291 fetch(extension.vsixUrl, { method: "HEAD", signal: requestSignal }),
292 ]);
293 if (!manifestResponse.ok) throw new Error("manifest unavailable");
294 if (!packageResponse.ok) return null;
295 const packageLength = Number(packageResponse.headers.get("content-length"));
296 if (Number.isFinite(packageLength) && packageLength > MAX_VSIX_BYTES) {
297 return null;
298 }
299 const manifestBytes = await readCappedResponse(
300 manifestResponse,
301 MAX_MANIFEST_BYTES,
302 "Open VSX returned an unexpectedly large manifest.",
303 );
304 const manifest = parseJsoncObject(
305 new TextDecoder().decode(manifestBytes),
306 "Extension manifest",
307 );
308 return themeContributions(manifest).length > 0 &&
309 manifestLicenseMatches(manifest, extension.license)
310 ? extension
311 : null;
312 } catch {
313 throw new Error("Open VSX returned unreadable theme details.");
314 }
315 }, signal),
316 ),
317 );
318 if (signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
319 const completedDetails = details.filter((result) => result.status === "fulfilled");
320 if (identities.length > 0 && completedDetails.length === 0) {
321 throw new Error("Open VSX theme details are unavailable right now.");
322 }
323 return completedDetails.flatMap((result) => (result.value ? [result.value] : [])).slice(0, 8);
324}
325
326function parseJsoncObject(source: string, description: string): Record<string, unknown> {
327 const errors: ParseError[] = [];
328 const value: unknown = parse(source, errors, { allowTrailingComma: true });
329 if (errors.length > 0 || !isRecord(value)) throw new Error(`${description} is not valid JSON.`);
330 return value;
331}
332
333function sanitizeThemeObject(value: Record<string, unknown>): Record<string, unknown> {
334 const colors: Record<string, string> = {};
335 if (isRecord(value.colors)) {
336 for (const [key, color] of Object.entries(value.colors)) {
337 if (
338 USED_WORKBENCH_COLORS.has(key) &&
339 typeof color === "string" &&
340 color.length <= MAX_COLOR_VALUE_LENGTH
341 ) {
342 colors[key] = color;
343 }
344 }
345 }
346 return {
347 ...(typeof value.include === "string" ? { include: value.include } : {}),
348 colors,
349 };
350}
351
352function normalizePackagePath(path: string, relativeTo = "extension/"): string {
353 if (
354 path.length > MAX_PACKAGE_PATH_LENGTH ||
355 path.includes("\0") ||
356 path.startsWith("/") ||
357 /^[a-zA-Z]:/.test(path)
358 ) {
359 throw new Error("Theme path is not a safe relative package path.");
360 }
361 const normalizedInput = path.replaceAll("\\", "/");
362 const baseSegments = relativeTo.split("/").slice(0, -1);
363 const segments = baseSegments;
364 for (const segment of normalizedInput.split("/")) {
365 if (!segment || segment === ".") continue;
366 if (segment === "..") {
367 if (segments.length <= 1) throw new Error("Theme path escapes the extension package.");
368 segments.pop();
369 continue;
370 }
371 segments.push(segment);
372 }
373 if (segments[0] !== "extension") segments.unshift("extension");
374 return segments.join("/");
375}
376
377function contributionType(uiTheme: unknown): string | null {
378 if (uiTheme === "vs") return "light";
379 if (uiTheme === "vs-dark") return "dark";
380 if (uiTheme === "hc-black" || uiTheme === "hc-light") return uiTheme;
381 return null;
382}
383
384type ZipEntrySizes = {
385 uncompressedSize?: unknown;
386};
387
388type InspectableZipObject = JSZip.JSZipObject & {
389 _data?: ZipEntrySizes;
390 unsafeOriginalName?: string;
391 internalStream?: (type: "uint8array") => JSZip.JSZipStreamHelper<Uint8Array>;
392};
393
394function inspectZipDirectory(bytes: Uint8Array): Uint8Array {
395 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
396 const minimumOffset = Math.max(0, bytes.byteLength - 65_557);
397 let endOffset = bytes.byteLength - 22;
398 while (
399 endOffset >= minimumOffset &&
400 (view.getUint32(endOffset, true) !== 0x06054b50 ||
401 endOffset + 22 + view.getUint16(endOffset + 20, true) !== bytes.byteLength)
402 ) {
403 endOffset -= 1;
404 }
405 if (endOffset < minimumOffset) throw new Error("That extension package has no ZIP directory.");
406
407 const directorySize = view.getUint32(endOffset + 12, true);
408 const directoryOffset = view.getUint32(endOffset + 16, true);
409 const directoryEnd = directoryOffset + directorySize;
410 if (directoryEnd !== endOffset || directoryEnd > bytes.byteLength) {
411 throw new Error("That extension package has an invalid ZIP directory.");
412 }
413
414 let entryCount = 0;
415 let totalUncompressed = 0;
416 let offset = directoryOffset;
417 while (offset < directoryEnd) {
418 if (offset + 46 > directoryEnd || view.getUint32(offset, true) !== 0x02014b50) {
419 throw new Error("That extension package has an invalid ZIP directory.");
420 }
421 entryCount += 1;
422 if (entryCount > MAX_ZIP_ENTRIES) {
423 throw new Error("That extension package has too many files.");
424 }
425 const compressed = view.getUint32(offset + 20, true);
426 const uncompressed = view.getUint32(offset + 24, true);
427 if (compressed === 0xffffffff || uncompressed === 0xffffffff) {
428 throw new Error("That extension package has unsupported ZIP64 metadata.");
429 }
430 totalUncompressed += uncompressed;
431 if (totalUncompressed > MAX_UNCOMPRESSED_BYTES) {
432 throw new Error("That extension package expands beyond the safe import limit.");
433 }
434 if (
435 uncompressed > 0 &&
436 (compressed === 0 || uncompressed / compressed > MAX_COMPRESSION_RATIO)
437 ) {
438 throw new Error("That extension package has an unsafe compression ratio.");
439 }
440 const nameLength = view.getUint16(offset + 28, true);
441 const extraLength = view.getUint16(offset + 30, true);
442 const commentLength = view.getUint16(offset + 32, true);
443 offset += 46 + nameLength + extraLength + commentLength;
444 }
445 if (offset !== directoryEnd)
446 throw new Error("That extension package has an invalid ZIP directory.");
447
448 const commentLength = view.getUint16(endOffset + 20, true);
449 if (commentLength === 0) return bytes;
450
451 // Strip ZIP comments; JSZip can mistake EOCD-like comment bytes for the real record.
452 const withoutComment = bytes.slice(0, endOffset + 22);
453 withoutComment[endOffset + 20] = 0;
454 withoutComment[endOffset + 21] = 0;
455 return withoutComment;
456}
457
458function inspectZip(zip: JSZip): void {
459 const entries = Object.values(zip.files) as InspectableZipObject[];
460 if (entries.length > MAX_ZIP_ENTRIES)
461 throw new Error("That extension package has too many files.");
462
463 for (const entry of entries) {
464 if (entry.unsafeOriginalName) normalizePackagePath(entry.unsafeOriginalName);
465 }
466}
467
468async function readZipText(
469 zip: JSZip,
470 path: string,
471 description: string,
472 signal?: AbortSignal,
473): Promise<string> {
474 signal?.throwIfAborted();
475 const file = zip.file(path) as InspectableZipObject | null;
476 if (!file) throw new Error(`${description} is missing from the extension package.`);
477 if (typeof file._data?.uncompressedSize !== "number" || !file.internalStream) {
478 throw new Error(`${description} has unreadable size metadata.`);
479 }
480 if (file._data.uncompressedSize > MAX_THEME_BYTES) {
481 throw new Error(`${description} is too large.`);
482 }
483
484 return new Promise((resolve, reject) => {
485 const chunks: Uint8Array[] = [];
486 let byteLength = 0;
487 let settled = false;
488 const stream = file.internalStream!("uint8array");
489 const cleanup = () => signal?.removeEventListener("abort", handleAbort);
490 const handleAbort = () => {
491 if (settled) return;
492 settled = true;
493 stream.pause();
494 cleanup();
495 reject(signal?.reason);
496 };
497 signal?.addEventListener("abort", handleAbort, { once: true });
498 stream
499 .on("data", (chunk) => {
500 if (settled) return;
501 byteLength += chunk.byteLength;
502 if (byteLength > MAX_THEME_BYTES) {
503 settled = true;
504 stream.pause();
505 cleanup();
506 reject(new Error(`${description} is too large.`));
507 return;
508 }
509 chunks.push(chunk);
510 })
511 .on("error", (cause) => {
512 if (settled) return;
513 settled = true;
514 cleanup();
515 reject(cause);
516 })
517 .on("end", () => {
518 if (settled) return;
519 settled = true;
520 cleanup();
521 const bytes = new Uint8Array(byteLength);
522 let offset = 0;
523 for (const chunk of chunks) {
524 bytes.set(chunk, offset);
525 offset += chunk.byteLength;
526 }
527 resolve(new TextDecoder().decode(bytes));
528 })
529 .resume();
530 });
531}
532
533async function loadThemeObject(
534 zip: JSZip,
535 path: string,
536 cache: Map<string, Record<string, unknown>>,
537 budget: { files: number },
538 ancestors: ReadonlySet<string> = new Set(),
539 signal?: AbortSignal,
540): Promise<Record<string, unknown>> {
541 signal?.throwIfAborted();
542 if (ancestors.size >= MAX_INCLUDE_DEPTH) throw new Error("Theme includes are nested too deeply.");
543 if (ancestors.has(path)) throw new Error("Theme includes contain a cycle.");
544 const cached = cache.get(path);
545 if (cached) return cached;
546 budget.files += 1;
547 if (budget.files > MAX_RESOLVED_THEME_FILES) {
548 throw new Error("That extension references too many theme files.");
549 }
550
551 const value = sanitizeThemeObject(
552 parseJsoncObject(await readZipText(zip, path, path, signal), path),
553 );
554 if (typeof value.include !== "string") {
555 cache.set(path, value);
556 return value;
557 }
558
559 const includePath = normalizePackagePath(value.include, path);
560 const nextAncestors = new Set(ancestors);
561 nextAncestors.add(path);
562 const base = await loadThemeObject(zip, includePath, cache, budget, nextAncestors, signal);
563 const resolved = {
564 ...base,
565 ...value,
566 colors: {
567 ...(isRecord(base.colors) ? base.colors : {}),
568 ...(isRecord(value.colors) ? value.colors : {}),
569 },
570 };
571 cache.set(path, resolved);
572 return resolved;
573}
574
575async function readCappedResponse(
576 response: Response,
577 limit: number,
578 tooLargeMessage: string,
579): Promise<Uint8Array> {
580 const contentLength = response.headers.get("content-length");
581 if (contentLength && Number(contentLength) > limit) throw new Error(tooLargeMessage);
582 if (!response.body) {
583 const bytes = new Uint8Array(await response.arrayBuffer());
584 if (bytes.byteLength > limit) throw new Error(tooLargeMessage);
585 return bytes;
586 }
587
588 const reader = response.body.getReader();
589 const chunks: Uint8Array[] = [];
590 let byteLength = 0;
591 try {
592 while (true) {
593 const { done, value } = await reader.read();
594 if (done) break;
595 byteLength += value.byteLength;
596 if (byteLength > limit) {
597 await reader.cancel();
598 throw new Error(tooLargeMessage);
599 }
600 chunks.push(value);
601 }
602 } finally {
603 reader.releaseLock();
604 }
605
606 const result = new Uint8Array(byteLength);
607 let offset = 0;
608 for (const chunk of chunks) {
609 result.set(chunk, offset);
610 offset += chunk.byteLength;
611 }
612 return result;
613}
614
615async function fetchPackage(url: string, signal?: AbortSignal): Promise<Uint8Array> {
616 const response = await fetch(url, signal ? { signal } : {});
617 if (!response.ok) throw new Error("That Open VSX theme could not be downloaded.");
618 return readCappedResponse(
619 response,
620 MAX_VSIX_BYTES,
621 "That theme extension is too large to import safely.",
622 );
623}
624
625export async function importOpenVsxThemeExtension(
626 extension: OpenVsxThemeExtension,
627 signal?: AbortSignal,
628): Promise<ReadonlyArray<ThemeDefinition>> {
629 const manifestResponse = await fetch(extension.manifestUrl, signal ? { signal } : {});
630 if (!manifestResponse.ok) throw new Error("That Open VSX extension has no readable manifest.");
631 const manifestBytes = await readCappedResponse(
632 manifestResponse,
633 MAX_MANIFEST_BYTES,
634 "That Open VSX extension manifest is too large.",
635 );
636 const manifest = parseJsoncObject(new TextDecoder().decode(manifestBytes), "Extension manifest");
637 const advertisedContributions = themeContributions(manifest);
638 if (advertisedContributions.length === 0) {
639 throw new Error("That extension does not contain color themes.");
640 }
641 if (advertisedContributions.length > MAX_THEMES_PER_EXTENSION) {
642 throw new Error("That extension contains too many color themes to import safely.");
643 }
644
645 const packageBytes = await fetchPackage(extension.vsixUrl, signal);
646 signal?.throwIfAborted();
647 const checksumResponse = await fetch(extension.sha256Url, signal ? { signal } : {});
648 if (!checksumResponse.ok) throw new Error("That Open VSX theme has no readable checksum.");
649 const expectedChecksum = new TextDecoder()
650 .decode(
651 await readCappedResponse(
652 checksumResponse,
653 256,
654 "That Open VSX checksum response is invalid.",
655 ),
656 )
657 .trim()
658 .split(/\s+/)[0];
659 if (!expectedChecksum || !/^[a-f\d]{64}$/i.test(expectedChecksum)) {
660 throw new Error("That Open VSX theme has an invalid checksum.");
661 }
662 signal?.throwIfAborted();
663 const actualChecksum = [...sha256(packageBytes)]
664 .map((byte) => byte.toString(16).padStart(2, "0"))
665 .join("");
666 if (actualChecksum.toLowerCase() !== expectedChecksum.toLowerCase()) {
667 throw new Error("That Open VSX theme failed its integrity check.");
668 }
669 signal?.throwIfAborted();
670 let zip: JSZip;
671 try {
672 const inspectedPackageBytes = inspectZipDirectory(packageBytes);
673 zip = await JSZip.loadAsync(inspectedPackageBytes);
674 signal?.throwIfAborted();
675 inspectZip(zip);
676 } catch (cause) {
677 if (signal?.aborted) signal.throwIfAborted();
678 if (cause instanceof Error && cause.message.startsWith("That extension package")) throw cause;
679 throw new Error("That Open VSX extension package could not be opened.", { cause });
680 }
681
682 const packagedManifest = parseJsoncObject(
683 await readZipText(zip, "extension/package.json", "Extension manifest", signal),
684 "Extension manifest",
685 );
686 if (
687 typeof packagedManifest.publisher !== "string" ||
688 packagedManifest.publisher.toLowerCase() !== extension.publisher.toLowerCase() ||
689 typeof packagedManifest.name !== "string" ||
690 `${packagedManifest.publisher}.${packagedManifest.name}`.toLowerCase() !==
691 extension.id.toLowerCase() ||
692 packagedManifest.version !== extension.version
693 ) {
694 throw new Error("That extension package does not match the selected Open VSX theme.");
695 }
696 if (!manifestLicenseMatches(packagedManifest, extension.license)) {
697 throw new Error("That extension package does not match its advertised license.");
698 }
699 const contributions = themeContributions(packagedManifest);
700 if (contributions.length === 0) throw new Error("That extension does not contain color themes.");
701 if (contributions.length > MAX_THEMES_PER_EXTENSION) {
702 throw new Error("That extension contains too many color themes to import safely.");
703 }
704
705 const parsed: Array<{ theme: ThemeDefinition; sourceName: string; sourcePath: string }> = [];
706 const failures: string[] = [];
707 const themeCache = new Map<string, Record<string, unknown>>();
708 const themeBudget = { files: 0 };
709 for (const contribution of contributions) {
710 signal?.throwIfAborted();
711 if (typeof contribution.path !== "string") {
712 failures.push("theme path is missing");
713 continue;
714 }
715 try {
716 const path = normalizePackagePath(contribution.path);
717 const themeValue = await loadThemeObject(
718 zip,
719 path,
720 themeCache,
721 themeBudget,
722 new Set(),
723 signal,
724 );
725 const type = contributionType(contribution.uiTheme);
726 const label =
727 typeof contribution.label === "string" && contribution.label.trim()
728 ? contribution.label.trim()
729 : extension.name;
730 const decorated = {
731 ...themeValue,
732 displayName: label,
733 ...(type ? { type } : {}),
734 };
735 if (!isVsCodeThemeFile(decorated)) throw new Error("not a VS Code color theme");
736 parsed.push({
737 theme: parseVsCodeThemeFile(decorated),
738 sourceName: path.split("/").at(-1)!,
739 sourcePath: path,
740 });
741 } catch (cause) {
742 signal?.throwIfAborted();
743 failures.push(cause instanceof Error ? cause.message : "theme could not be read");
744 }
745 }
746 if (failures.length > 0) {
747 throw new Error("One or more color themes in that extension could not be imported safely.");
748 }
749 if (parsed.length === 0) {
750 throw new Error("That extension has no compatible color themes.");
751 }
752 const extensionId = extension.id.toLowerCase();
753 const sourcePathCounts = new Map<string, number>();
754 for (const { sourcePath } of parsed) {
755 sourcePathCounts.set(sourcePath, (sourcePathCounts.get(sourcePath) ?? 0) + 1);
756 }
757 const sourcePathOccurrences = new Map<string, number>();
758 const sourceIdentities = parsed.map(({ sourcePath }) => {
759 if (sourcePathCounts.get(sourcePath) === 1) return sourcePath;
760 const occurrence = sourcePathOccurrences.get(sourcePath) ?? 0;
761 sourcePathOccurrences.set(sourcePath, occurrence + 1);
762 return occurrence === 0 ? sourcePath : `${sourcePath}\0${occurrence}`;
763 });
764 const resolved = resolveThemeLabelCollisions(parsed).map((theme, index) => ({
765 ...theme,
766 id: openVsxThemeId(extensionId, sourceIdentities[index]!),
767 }));
768 const paired = pairVsCodeThemes(resolved, {
769 pairedId: (light, dark) => openVsxThemeId(extensionId, [light.id, dark.id].sort().join(":")),
770 });
771 const themes = resolveThemeLabelCollisions(paired.map((theme) => ({ theme })));
772 const collection = {
773 id: extension.collectionId,
774 label: extension.name.slice(0, 48),
775 };
776 return themes.map((theme) => ({ ...theme, collection }));
777}