Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { isCommandAvailable } from "@t3tools/shared/shell";
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";
Expand Down Expand Up @@ -326,6 +327,41 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({
* renderer reject it by size without the contents ever crossing the bridge. */
const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024;

/** Extension packages carry icons and screenshots, so they get the same cap
* the renderer applies to a downloaded VSIX. */
const PICKED_THEME_PACKAGE_MAX_BYTES = 20 * 1024 * 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 = (
fileSystem: FileSystem.FileSystem,
filePath: string,
limit: number,
): Effect.Effect<Option.Option<Uint8Array>, PlatformError> =>
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<Uint8Array>();
}),
);

export const pickThemeFiles = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PICK_THEME_FILES_CHANNEL,
payload: Schema.Undefined,
Expand All @@ -345,20 +381,38 @@ 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) {
return null;
}
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 ? PICKED_THEME_PACKAGE_MAX_BYTES : PICKED_THEME_FILE_MAX_BYTES;
if (size > limit) {
return { name, size, 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 bytes = yield* readCappedFile(fileSystem, 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;
}
const contentBase64 = Buffer.from(bytes.value).toString("base64");
return { name, size, text: "", contentBase64 } satisfies PickedThemeFile;
}
const text = yield* fileSystem.readFileString(filePath);
return { name, size, text } satisfies PickedThemeFile;
}).pipe(
Expand Down
34 changes: 33 additions & 1 deletion apps/web/src/components/settings/ThemeImportDialog.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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);
});
});
Loading
Loading