Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f71a477
feat(web): import themes from local .vsix extension packages
ipanasenko Aug 27, 2026
b02e5cb
fix(desktop): enforce the theme package cap while reading
ipanasenko Aug 27, 2026
ce8b965
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Aug 28, 2026
ece7e0e
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Aug 29, 2026
442d4f2
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Aug 30, 2026
6984e20
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 2, 2026
a2bfc02
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 4, 2026
1acd67e
test(desktop): cover bounded theme package reads
ipanasenko Sep 4, 2026
445b932
fix(web): use standard alert styling for theme conflicts
ipanasenko Sep 4, 2026
ad18a69
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 4, 2026
3d33708
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 6, 2026
151a81a
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 7, 2026
965f8fd
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 8, 2026
61afb97
fix(desktop): return file size from theme fixture seek
ipanasenko Sep 8, 2026
2bbc21f
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 9, 2026
e09a66f
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 11, 2026
62967b4
refactor(web): share theme import caps and cover package safety limits
ipanasenko Sep 12, 2026
648c61b
Merge remote-tracking branch 'ipanasenko/t3code/add-custom-theme-from…
ipanasenko Sep 12, 2026
0b8f86f
chore(web): keep shortHash internal to the package reader
ipanasenko Sep 12, 2026
54ab5bf
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 12, 2026
56a61c9
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 12, 2026
2c90ccf
Merge remote-tracking branch 'origin/main' into t3code/add-custom-the…
ipanasenko Sep 13, 2026
df5182e
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 15, 2026
65d5e7e
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 17, 2026
552a3a6
refactor(desktop): acquire file system in capped reader
ipanasenko Sep 17, 2026
3fd32d0
fix(web): cancel stale theme package imports
ipanasenko Sep 17, 2026
99450d3
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 19, 2026
b6b25a5
Update window.test.ts
ipanasenko Sep 19, 2026
30b86c7
fix(desktop): migrate theme picker fixtures to Effect byte sizes
ipanasenko Sep 19, 2026
a07cf9d
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 22, 2026
6b20e2a
fix(web): drop className override on AlertDescription in theme import…
ipanasenko Sep 22, 2026
541db26
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 26, 2026
36e21c4
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 28, 2026
04e6dfb
Merge branch 'main' into t3code/add-custom-theme-from-vsix
ipanasenko Sep 29, 2026
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
108 changes: 108 additions & 0 deletions apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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"],
Expand Down Expand Up @@ -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))),
}),
),
),
);
});
62 changes: 56 additions & 6 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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<Option.Option<Uint8Array>, 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<Uint8Array>();
}),
);
});

export const pickThemeFiles = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PICK_THEME_FILES_CHANNEL,
Expand All @@ -396,21 +427,40 @@ 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 ? 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;
}
Comment thread
ipanasenko marked this conversation as resolved.
const text = new TextDecoder().decode(bytes.value);
return { name, size, text } satisfies PickedThemeFile;
}).pipe(
// An unreadable file degrades to an entry the renderer reports.
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