diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 0b43fa9e6ef9..3d0d9f8c64d7 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -1,11 +1,14 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as FileSystem from "effect/FileSystem"; +import * as ByteSize from "effect/ByteSize"; import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { THEME_PACKAGE_MAX_BYTES } from "@t3tools/contracts"; import { vi } from "vite-plus/test"; import type * as Electron from "electron"; @@ -30,9 +33,66 @@ import { getWindowFullscreenState, pasteAsText, pickProjectFavicon, + pickThemeFiles, probeRemoteEditors, } from "./window.ts"; +function fileInfo(size: number): FileSystem.File.Info { + return { + type: "File", + mtime: Option.none(), + atime: Option.none(), + birthtime: Option.none(), + dev: 0, + ino: Option.none(), + mode: 0, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: ByteSize.bytes(size), + blksize: Option.none(), + blocks: Option.none(), + }; +} + +function testFile(readAlloc: FileSystem.File["readAlloc"]): FileSystem.File { + return { + [FileSystem.FileTypeId]: FileSystem.FileTypeId, + stat: Effect.succeed(fileInfo(0)), + seek: () => Effect.succeed(0n), + sync: Effect.void, + read: () => Effect.succeed(0), + readAlloc, + truncate: () => Effect.void, + write: () => Effect.succeed(0), + writeAll: () => Effect.void, + }; +} + +function pickThemeFilesLayer({ + fileSize, + readAlloc, +}: { + fileSize: number; + readAlloc: FileSystem.File["readAlloc"]; +}) { + return Layer.mergeAll( + Layer.mock(ElectronDialog.ElectronDialog)({ + pickFiles: () => Effect.succeed(["/themes/aurora.vsix"]), + }), + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeed(Option.none()), + }), + FileSystem.layerNoop({ + exists: () => Effect.succeed(false), + stat: () => Effect.succeed(fileInfo(fileSize)), + open: () => Effect.succeed(testFile(readAlloc)), + }), + NodePath.layer, + ); +} + const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "wsl.exe", args: ["-d", "Ubuntu", "--", "node", "/app/bin.mjs"], @@ -296,3 +356,51 @@ it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( assert.notInclude(editors, "webstorm"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +describe("pickThemeFiles", () => { + it.effect("returns a base64-encoded extension package without text", () => { + const bytes = new TextEncoder().encode("theme package"); + let readCount = 0; + + return Effect.gen(function* () { + const result = yield* pickThemeFiles.handler(undefined); + + assert.deepEqual(result, [ + { + name: "aurora.vsix", + size: bytes.byteLength, + text: "", + contentBase64: Buffer.from(bytes).toString("base64"), + }, + ]); + }).pipe( + Effect.provide( + pickThemeFilesLayer({ + fileSize: bytes.byteLength, + readAlloc: () => Effect.succeed(readCount++ === 0 ? Option.some(bytes) : Option.none()), + }), + ), + ); + }); + + it.effect("reports a package that grows past the read cap as oversized", () => + Effect.gen(function* () { + const result = yield* pickThemeFiles.handler(undefined); + + assert.deepEqual(result, [ + { + name: "aurora.vsix", + size: THEME_PACKAGE_MAX_BYTES + 1, + text: "", + }, + ]); + }).pipe( + Effect.provide( + pickThemeFilesLayer({ + fileSize: 1, + readAlloc: () => Effect.succeed(Option.some(new Uint8Array(64 * 1024))), + }), + ), + ), + ); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 75f34ab07ab1..9e19f20d58f5 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -6,6 +6,8 @@ import { EDITORS, EditorId, PickedThemeFileSchema, + THEME_FILE_MAX_BYTES, + THEME_PACKAGE_MAX_BYTES, PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, REMOTE_CAPABLE_EDITOR_IDS, @@ -19,6 +21,7 @@ import * as HostProcess from "@t3tools/shared/hostProcess"; import * as NodeOS from "node:os"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import type { PlatformError } from "effect/PlatformError"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -373,9 +376,37 @@ export const pasteAsText = DesktopIpc.makeIpcMethod({ }), }); -/** Theme files are a few KB; anything larger returns empty text and lets the - * renderer reject it by size without the contents ever crossing the bridge. */ -const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; +/** Reads at most `limit` bytes. The cap is enforced while reading, not by a + * prior stat, so a file that grows between the size check and the read can + * never pull more than the cap into memory. Returns None past the limit. */ +const readCappedFile = Effect.fn("desktop.ipc.window.readCappedFile")(function* ( + filePath: string, + limit: number, +): Effect.fn.Return, PlatformError, FileSystem.FileSystem> { + const fileSystem = yield* FileSystem.FileSystem; + return yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fileSystem.open(filePath); + const chunks: Uint8Array[] = []; + let byteLength = 0; + while (byteLength <= limit) { + const chunk = yield* file.readAlloc(64 * 1024); + if (Option.isNone(chunk) || chunk.value.byteLength === 0) { + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const part of chunks) { + bytes.set(part, offset); + offset += part.byteLength; + } + return Option.some(bytes); + } + chunks.push(chunk.value); + byteLength += chunk.value.byteLength; + } + return Option.none(); + }), + ); +}); export const pickThemeFiles = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PICK_THEME_FILES_CHANNEL, @@ -396,7 +427,11 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ const paths = yield* dialog.pickFiles({ owner: yield* electronWindow.focusedMainOrFirst, defaultPath: defaultPath ? Option.some(extensionsDir) : Option.none(), - filters: [{ name: "JSON", extensions: ["json"] }], + filters: [ + { name: "Themes", extensions: ["json", "vsix"] }, + { name: "JSON", extensions: ["json"] }, + { name: "Extension package", extensions: ["vsix"] }, + ], multiple: true, }); if (paths.length === 0) { @@ -404,13 +439,28 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ } return yield* Effect.forEach(paths, (filePath) => { const name = path.basename(filePath); + const isPackage = name.toLowerCase().endsWith(".vsix"); return Effect.gen(function* () { const info = yield* fileSystem.stat(filePath); const size = Number(info.size); - if (size > PICKED_THEME_FILE_MAX_BYTES) { + const limit = isPackage ? THEME_PACKAGE_MAX_BYTES : THEME_FILE_MAX_BYTES; + // Oversized files never get read; the renderer rejects them by size. + if (size > limit) { return { name, size, text: "" } satisfies PickedThemeFile; } - const text = yield* fileSystem.readFileString(filePath); + const bytes = yield* readCappedFile(filePath, limit); + if (Option.isNone(bytes)) { + // Grew past the cap after stat; report a size the renderer + // rejects as oversized. + return { name, size: limit + 1, text: "" } satisfies PickedThemeFile; + } + // A package is binary, so it crosses the bridge base64-encoded; the + // renderer unzips it and never looks at `text`. + if (isPackage) { + const contentBase64 = Buffer.from(bytes.value).toString("base64"); + return { name, size, text: "", contentBase64 } satisfies PickedThemeFile; + } + const text = new TextDecoder().decode(bytes.value); return { name, size, text } satisfies PickedThemeFile; }).pipe( // An unreadable file degrades to an entry the renderer reports. diff --git a/apps/web/src/components/settings/ThemeImportDialog.test.ts b/apps/web/src/components/settings/ThemeImportDialog.test.ts index 6cd51e9b77ae..03777d73a3d8 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.test.ts +++ b/apps/web/src/components/settings/ThemeImportDialog.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { describeOversizedThemeFile, MAX_THEME_FILE_BYTES } from "./ThemeImportDialog"; +import { + describeOversizedThemeFile, + describeOversizedThemePackage, + isThemePackageName, + MAX_THEME_FILE_BYTES, +} from "./ThemeImportDialog"; +import { MAX_VSIX_BYTES } from "../../vsixThemePackage"; describe("theme import size guard", () => { it("accepts anything a theme file could plausibly be", () => { @@ -19,3 +25,29 @@ describe("theme import size guard", () => { expect(describeOversizedThemeFile(MAX_THEME_FILE_BYTES + 1)).toContain("256 KB"); }); }); + +describe("theme package size guard", () => { + it("accepts a package up to the VSIX limit", () => { + for (const bytes of [0, MAX_THEME_FILE_BYTES + 1, MAX_VSIX_BYTES]) { + expect(describeOversizedThemePackage(bytes)).toBeNull(); + } + }); + + it("rejects a package past the VSIX limit and names both sizes", () => { + const message = describeOversizedThemePackage(64 * 1024 * 1024); + expect(message).toContain("64.0 MB"); + expect(message).toContain("20.0 MB"); + }); +}); + +describe("theme package detection", () => { + it("recognizes .vsix regardless of case", () => { + expect(isThemePackageName("dracula-pro.vsix")).toBe(true); + expect(isThemePackageName("Dracula-Pro.VSIX")).toBe(true); + }); + + it("leaves theme JSON to the file importer", () => { + expect(isThemePackageName("dracula.json")).toBe(false); + expect(isThemePackageName("vsix")).toBe(false); + }); +}); diff --git a/apps/web/src/components/settings/ThemeImportDialog.tsx b/apps/web/src/components/settings/ThemeImportDialog.tsx index a4e221327d09..d335d2557dc0 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.tsx @@ -1,12 +1,15 @@ +import { THEME_FILE_MAX_BYTES } from "@t3tools/contracts"; import { DownloadIcon, PlusIcon } from "lucide-react"; -import type { ChangeEvent, DragEvent, UIEvent } from "react"; +import type { ChangeEvent, DragEvent, ReactNode, UIEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "../../lib/utils"; import { getCustomThemes, + getStoredCustomThemeCollection, installCustomTheme, parseThemeFile, removeCustomTheme, + replaceCustomThemeCollection, THEME_FILE_VERSION, updateCustomTheme, type ThemeDefinition, @@ -18,7 +21,8 @@ import { parseVsCodeThemeFile, resolveThemeLabelCollisions, } from "../../vscodeThemeImport"; -import { Alert } from "../ui/alert"; +import { importVsixThemeFile, MAX_VSIX_BYTES } from "../../vsixThemePackage"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { Button } from "../ui/button"; import { Dialog, DialogHeader, DialogPanel, DialogPopup, DialogTitle } from "../ui/dialog"; import { ThemeSearchSection } from "./ThemeSearchSection"; @@ -29,7 +33,7 @@ import { ThemeSearchSection } from "./ThemeSearchSection"; * would otherwise be pulled into memory, highlighted, and rendered, which * locks the UI for as long as that takes. */ -export const MAX_THEME_FILE_BYTES = 256 * 1024; +export const MAX_THEME_FILE_BYTES = THEME_FILE_MAX_BYTES; /** Highlighting rebuilds the whole markup on every keystroke, so oversized * pastes fall back to plain text instead of freezing the editor. */ @@ -47,6 +51,17 @@ export function describeOversizedThemeFile(bytes: number): string | null { return `That file is ${formatByteSize(bytes)}. Theme files are only a few KB, so this one was not read (limit ${formatByteSize(MAX_THEME_FILE_BYTES)}).`; } +/** Extension packages ship icons and screenshots, so they get the larger cap + * an Open VSX download uses instead of the loose-file one. */ +export function describeOversizedThemePackage(bytes: number): string | null { + if (bytes <= MAX_VSIX_BYTES) return null; + return `That extension package is ${formatByteSize(bytes)}, past the ${formatByteSize(MAX_VSIX_BYTES)} import limit, so it was not read.`; +} + +export function isThemePackageName(name: string): boolean { + return name.toLowerCase().endsWith(".vsix"); +} + function escapeJsonHtml(value: string): string { return value.replace( /[&<>"']/g, @@ -140,8 +155,46 @@ function ThemeJsonEditor({ ); } -/** What the import pipeline needs from a file; DOM File satisfies it. */ -type ImportableThemeFile = { name: string; size: number; text: () => Promise }; +/** What the import pipeline needs from a file; DOM File satisfies it. + * `bytes` is only read for extension packages, which are binary. */ +type ImportableThemeFile = { + name: string; + size: number; + text: () => Promise; + bytes?: () => Promise; +}; + +function decodeBase64(value: string): Uint8Array { + const binary = atob(value); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +function importableThemeFile(file: File): ImportableThemeFile { + return { + name: file.name, + size: file.size, + text: () => file.text(), + bytes: async () => new Uint8Array(await file.arrayBuffer()), + }; +} + +/** An extension package waiting on an update-or-cancel decision because its + * collection is already installed. */ +type PendingThemePackage = { + label: string; + collectionId: string; + themes: ReadonlyArray; + installedCollection: ReadonlyArray; +}; + +function ThemeConflictNotice({ title, children }: { title: ReactNode; children: ReactNode }) { + return ( + + {title} + {children} + + ); +} export function ThemeImportDialog({ open, @@ -164,6 +217,7 @@ export function ThemeImportDialog({ // Imports whose id is already installed wait here for an update-or-copy // decision instead of failing. const [conflicts, setConflicts] = useState | null>(null); + const [pendingPackage, setPendingPackage] = useState(null); const importRequestRef = useRef(0); useEffect(() => { @@ -177,6 +231,7 @@ export function ThemeImportDialog({ setError(null); setIsReading(false); setConflicts(null); + setPendingPackage(null); }, [open]); const readThemeFile = useCallback(async (file: ImportableThemeFile) => { @@ -263,13 +318,96 @@ export function ThemeImportDialog({ [onImportedMany, onOpenChange], ); + // A package installs as one collection so a later import of the same + // extension replaces its variants instead of piling up copies. + const installThemePackage = useCallback( + (pending: PendingThemePackage) => { + try { + const imported = replaceCustomThemeCollection(pending.collectionId, pending.themes, { + expectedCollection: pending.installedCollection, + }); + setPendingPackage(null); + onImportedMany(imported, { updated: pending.installedCollection.length > 0 }); + onOpenChange(false); + } catch (cause) { + setPendingPackage(null); + setError( + cause instanceof Error ? cause.message : "That extension package could not be installed.", + ); + } + }, + [onImportedMany, onOpenChange], + ); + + const readThemePackage = useCallback( + async (file: ImportableThemeFile) => { + // Check the size before unzipping: expanding a huge archive is what + // would lock the UI. + const oversized = describeOversizedThemePackage(file.size); + if (oversized) { + importRequestRef.current += 1; + setIsReading(false); + setError(oversized); + return; + } + + const requestId = ++importRequestRef.current; + setIsReading(true); + setError(null); + try { + const bytes = await file.bytes?.(); + if (requestId !== importRequestRef.current) return; + if (!bytes || bytes.byteLength === 0) { + setError("Could not read that extension package."); + return; + } + const themes = await importVsixThemeFile({ name: file.name, bytes }); + if (requestId !== importRequestRef.current) return; + const collection = themes[0]?.collection; + if (!collection) throw new Error("That extension has no compatible color themes."); + const installedCollection = getStoredCustomThemeCollection(collection.id); + const pending = { + label: collection.label, + collectionId: collection.id, + themes, + installedCollection, + }; + setFileName(file.name); + // An extension already installed under this collection waits for an + // explicit update: replacing it drops local edits. + if (installedCollection.length > 0) setPendingPackage(pending); + else installThemePackage(pending); + } catch (cause) { + if (requestId !== importRequestRef.current) return; + setError( + cause instanceof Error ? cause.message : "That extension package could not be imported.", + ); + } finally { + if (requestId === importRequestRef.current) setIsReading(false); + } + }, + [installThemePackage], + ); + const readThemeFiles = useCallback( (files: ReadonlyArray) => { if (files.length === 0) return; + if (files.some((file) => isThemePackageName(file.name))) { + // A package expands into a whole collection with its own update + // prompt, so it imports on its own rather than inside a batch. + if (files.length > 1) { + importRequestRef.current += 1; + setIsReading(false); + setError("Import one .vsix extension package at a time."); + return; + } + void readThemePackage(files[0]!); + return; + } if (files.length === 1) void readThemeFile(files[0]!); else void readThemeBatch(files); }, - [readThemeBatch, readThemeFile], + [readThemeBatch, readThemeFile, readThemePackage], ); // On desktop the native picker opens in ~/.vscode/extensions (when it @@ -285,6 +423,9 @@ export function ThemeImportDialog({ name: file.name, size: file.size, text: () => Promise.resolve(file.text), + ...(file.contentBase64 === undefined + ? {} + : { bytes: () => Promise.resolve(decodeBase64(file.contentBase64!)) }), })), ); }); @@ -297,7 +438,7 @@ export function ThemeImportDialog({ (event: ChangeEvent) => { const files = [...(event.currentTarget.files ?? [])]; event.currentTarget.value = ""; - readThemeFiles(files); + readThemeFiles(files.map(importableThemeFile)); }, [readThemeFiles], ); @@ -306,7 +447,7 @@ export function ThemeImportDialog({ (event: DragEvent) => { event.preventDefault(); setIsDropTarget(false); - readThemeFiles([...event.dataTransfer.files]); + readThemeFiles([...event.dataTransfer.files].map(importableThemeFile)); }, [readThemeFiles], ); @@ -464,7 +605,7 @@ export function ThemeImportDialog({ const fileInput = ( ); - if (conflicts) { + if (pendingPackage) { return (
-
-

Already installed

-

- {conflicts.map((theme) => theme.label).join(", ")} -

+ + Updating replaces its installed variants, including any local edits. Variants no + longer in the package will be removed. + +
+ + +
+
+ ); + } + if (conflicts) { + return ( +
+ + {conflicts.map((theme) => theme.label).join(", ")} +