diff --git a/page/package-lock.json b/page/package-lock.json
index 9ebe7d75..68b1b88e 100644
--- a/page/package-lock.json
+++ b/page/package-lock.json
@@ -28,6 +28,7 @@
"flatbuffers": "^25.2.10",
"jszip": "^3.10.1",
"lucide-react": "^0.503.0",
+ "pe-library": "^1.0.1",
"react": "^19.0.0",
"react-bootstrap-icons": "^1.11.5",
"react-dom": "^19.0.0",
@@ -4515,6 +4516,20 @@
"node": ">=8"
}
},
+ "node_modules/pe-library": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-1.0.1.tgz",
+ "integrity": "sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14",
+ "npm": ">=7"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/jet2jet"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
diff --git a/page/package.json b/page/package.json
index 2d5911ea..fc0fcad2 100644
--- a/page/package.json
+++ b/page/package.json
@@ -30,6 +30,7 @@
"flatbuffers": "^25.2.10",
"jszip": "^3.10.1",
"lucide-react": "^0.503.0",
+ "pe-library": "^1.0.1",
"react": "^19.0.0",
"react-bootstrap-icons": "^1.11.5",
"react-dom": "^19.0.0",
diff --git a/page/src/components/folder.tsx b/page/src/components/folder.tsx
index 3c92d8ed..bc1e1a90 100644
--- a/page/src/components/folder.tsx
+++ b/page/src/components/folder.tsx
@@ -36,9 +36,11 @@ type CreateFolderHandler = () => void;
type RemoveElementHandler = (element: FolderElement) => void;
type RenameElementHandler = (element: FolderElement) => void;
type AddFilesHandler = () => void;
+type IconReader = (element: FolderElement) => string | null;
export interface FolderProps {
elements: FolderElement[];
+ iconReader: IconReader;
clickHandler: ClickHandler;
createFolderHandler: CreateFolderHandler;
removeElementHandler: RemoveElementHandler;
@@ -54,7 +56,22 @@ function elementComparator(e1: FolderElement, e2: FolderElement) {
return e1.name.localeCompare(e2.name);
}
-function getIcon(element: FolderElement, className: string = "") {
+function getIcon(
+ element: FolderElement,
+ iconReader: IconReader,
+ className: string = "",
+) {
+ const icon = iconReader(element);
+ if (icon) {
+ return (
+
+
+

+
+
+ );
+ }
+
switch (element.type) {
case FolderElementType.File:
if (element.name.endsWith(".dll")) {
@@ -75,18 +92,18 @@ function getIcon(element: FolderElement, className: string = "") {
}
}
-function renderIcon(element: FolderElement) {
+function renderIcon(element: FolderElement, iconReader: IconReader) {
let className = "w-6 h-6 flex-1";
- return getIcon(element, className);
+ return getIcon(element, iconReader, className);
}
-function renderElement(element: FolderElement, clickHandler: ClickHandler) {
+function renderElement(element: FolderElement, props: FolderProps) {
return (
clickHandler(element)}
+ onClick={() => props.clickHandler(element)}
className="folder-element select-none flex flex-col gap-2 items-center text-center text-xs p-2 m-2 w-25 h-18 rounded-lg border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50"
>
- {renderIcon(element)}
+ {renderIcon(element, props.iconReader)}
{element.name}
@@ -96,7 +113,7 @@ function renderElement(element: FolderElement, clickHandler: ClickHandler) {
function renderElementWithContext(element: FolderElement, props: FolderProps) {
if (element.name == "..") {
- return renderElement(element, props.clickHandler);
+ return renderElement(element, props);
}
return (
@@ -104,7 +121,7 @@ function renderElementWithContext(element: FolderElement, props: FolderProps) {
- {renderElement(element, props.clickHandler)}
+ {renderElement(element, props)}
{element.name}
diff --git a/page/src/filesystem-explorer.tsx b/page/src/filesystem-explorer.tsx
index 2a5adf18..8ea92827 100644
--- a/page/src/filesystem-explorer.tsx
+++ b/page/src/filesystem-explorer.tsx
@@ -25,6 +25,7 @@ import {
} from "@/components/ui/breadcrumb";
import { HouseFill } from "react-bootstrap-icons";
+import { parsePeIcon } from "./pe-icon-parser";
export interface FilesystemExplorerProps {
filesystem: Filesystem;
@@ -156,6 +157,32 @@ function selectFiles(): Promise {
});
}
+interface IconCache {
+ get: (file: string) => string | null;
+ set: (file: string, data: string | null) => void;
+}
+
+function getPeIcon(
+ filesystem: Filesystem,
+ file: string,
+ cache: Map,
+) {
+ if (!file || !file.endsWith(".exe")) {
+ return null;
+ }
+
+ const cachedValue = cache.get(file);
+ if (cachedValue) {
+ return cachedValue;
+ }
+
+ const data = filesystem.readFile(file);
+ const icon = parsePeIcon(data);
+ cache.set(file, icon);
+
+ return icon;
+}
+
interface BreadcrumbElement {
node: React.ReactNode;
targetPath: string[];
@@ -182,6 +209,8 @@ export class FilesystemExplorer extends React.Component<
FilesystemExplorerProps,
FilesystemExplorerState
> {
+ private iconCache: Map;
+
constructor(props: FilesystemExplorerProps) {
super(props);
@@ -189,6 +218,8 @@ export class FilesystemExplorer extends React.Component<
this._uploadFiles = this._uploadFiles.bind(this);
this._onElementSelect = this._onElementSelect.bind(this);
+ this.iconCache = new Map();
+
this.state = {
path: this.props.path,
createFolder: false,
@@ -556,6 +587,13 @@ export class FilesystemExplorer extends React.Component<
this.setState({ renameFile: e.name })
}
addFilesHandler={this._onAddFiles}
+ iconReader={(e) =>
+ getPeIcon(
+ this.props.filesystem,
+ makeFullPathWithState(this.state, e.name),
+ this.iconCache,
+ )
+ }
/>
)}
diff --git a/page/src/filesystem.ts b/page/src/filesystem.ts
index 357e3f31..100a7a8f 100644
--- a/page/src/filesystem.ts
+++ b/page/src/filesystem.ts
@@ -82,6 +82,10 @@ export class Filesystem {
this.idbfs.FS.writeFile(file.name, buffer);
}
+ readFile(file: string): Uint8Array {
+ return this.idbfs.FS.readFile(file);
+ }
+
async storeFiles(files: FileWithData[]) {
files.forEach((f) => {
this._storeFile(f);
diff --git a/page/src/pe-icon-parser.tsx b/page/src/pe-icon-parser.tsx
new file mode 100644
index 00000000..27a7e6b3
--- /dev/null
+++ b/page/src/pe-icon-parser.tsx
@@ -0,0 +1,232 @@
+import * as PE from "pe-library";
+
+function patchExeFile(exe: PE.NtExecutable) {
+ // The PE library doesn't support parsing resources if other sections follow
+ // This might make sense, as the library will have issues rewriting the PE file.
+ // As we only care about parsing though, just kill the other sections.
+ const rsrc = exe.getSectionByEntry(PE.Format.ImageDirectoryEntry.Resource);
+ const orig = exe.getAllSections.bind(exe);
+ exe.getAllSections = function () {
+ let x = { skip: false };
+ return orig().filter((s) => {
+ if (x.skip) {
+ return false;
+ }
+ if (s == rsrc) {
+ x.skip = true;
+ }
+
+ return true;
+ });
+ };
+}
+
+function arrayBufferToBase64(bytes: Uint8Array) {
+ let binary = "";
+ const len = bytes.byteLength;
+ for (let i = 0; i < len; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+}
+
+function isPng(buffer: Uint8Array) {
+ if (buffer.length < 4) {
+ return false;
+ }
+
+ return buffer[1] === 80 && buffer[2] === 78 && buffer[3] === 71;
+}
+
+function generateDataURL(arrayBuffer: Uint8Array, contentType: string) {
+ const base64 = arrayBufferToBase64(arrayBuffer);
+ return `data:${contentType};base64,${base64}`;
+}
+
+interface IconEntry {
+ width: number;
+ height: number;
+ colorCount: number;
+ reserved: number;
+ planes: number;
+ bitCount: number;
+ bytesInRes: number;
+ id: number;
+}
+
+interface IconGroup {
+ reserved: number;
+ type: number;
+ icons: IconEntry[];
+}
+
+function writeUint8(buffer: Uint8Array, offset: number, value: number) {
+ buffer[offset] = value;
+}
+
+function writeUint16(buffer: Uint8Array, offset: number, value: number) {
+ writeUint8(buffer, offset + 0, value & 0xff);
+ writeUint8(buffer, offset + 1, (value >> 8) & 0xff);
+}
+
+function writeUint32(buffer: Uint8Array, offset: number, value: number) {
+ writeUint16(buffer, offset + 0, value & 0xffff);
+ writeUint16(buffer, offset + 2, (value >> 16) & 0xffff);
+}
+
+function readUInt8(buffer: Uint8Array, offset: number) {
+ return buffer[offset];
+}
+
+function readUInt16(buffer: Uint8Array, offset: number) {
+ return readUInt8(buffer, offset) | (readUInt8(buffer, offset + 1) << 8);
+}
+
+function readUInt32(buffer: Uint8Array, offset: number) {
+ return readUInt16(buffer, offset) | (readUInt16(buffer, offset + 2) << 16);
+}
+
+function parseIconGroup(buffer: Uint8Array): IconGroup {
+ const reserved = readUInt16(buffer, 0);
+ const type = readUInt16(buffer, 2);
+ const count = readUInt16(buffer, 4);
+
+ const icons: IconEntry[] = [];
+
+ for (let i = 0; i < count; ++i) {
+ const start = 6 + i * 14;
+ const width = readUInt8(buffer, start + 0);
+ const height = readUInt8(buffer, start + 1);
+ const colorCount = readUInt8(buffer, start + 2);
+ const reserved2 = readUInt8(buffer, start + 3);
+ const planes = readUInt16(buffer, start + 4);
+ const bitCount = readUInt16(buffer, start + 6);
+ const bytesInRes = readUInt32(buffer, start + 8);
+ const id = readUInt16(buffer, start + 12);
+
+ icons.push({
+ width,
+ height,
+ colorCount,
+ reserved: reserved2,
+ planes,
+ bitCount,
+ bytesInRes,
+ id,
+ });
+ }
+
+ return {
+ reserved,
+ type,
+ icons,
+ };
+}
+
+function mergeArrayBuffers(
+ buffer1: ArrayBuffer,
+ buffer2: ArrayBuffer,
+): ArrayBuffer {
+ const mergedBuffer = new ArrayBuffer(buffer1.byteLength + buffer2.byteLength);
+
+ const view1 = new Uint8Array(buffer1);
+ const view2 = new Uint8Array(buffer2);
+ const mergedView = new Uint8Array(mergedBuffer);
+
+ mergedView.set(view1, 0);
+ mergedView.set(view2, buffer1.byteLength);
+
+ return mergedBuffer;
+}
+
+function generateIcoHeader(icon: IconEntry) {
+ const headerSize = 0x16;
+ const header = new Uint8Array(headerSize);
+ writeUint8(header, 2, 1); // Image type -> ico
+ writeUint8(header, 4, 1); // Image count
+
+ const start = 6;
+
+ writeUint8(header, start + 0, icon.width);
+ writeUint8(header, start + 1, icon.height);
+ writeUint8(header, start + 2, icon.colorCount);
+
+ writeUint16(header, start + 4, icon.planes);
+ writeUint16(header, start + 6, icon.bitCount);
+ writeUint32(header, start + 8, icon.bytesInRes);
+ writeUint32(header, start + 12, headerSize);
+
+ return header;
+}
+
+function isMaxResIcon(icon: IconEntry) {
+ return icon.width == 0 && icon.height == 0;
+}
+
+function getBiggestIcon(group: IconGroup) {
+ if (group.icons.length == 0) {
+ return null;
+ }
+
+ var biggest = group.icons[0];
+ if (isMaxResIcon(biggest)) {
+ return biggest;
+ }
+
+ for (let i = 1; i < group.icons.length; ++i) {
+ let current = group.icons[i];
+ if (isMaxResIcon(current)) {
+ return current;
+ }
+
+ if (current.width * current.height > biggest.width * biggest.height) {
+ biggest = current;
+ }
+ }
+
+ return biggest;
+}
+
+function getPeResources(data: Uint8Array) {
+ const exe = PE.NtExecutable.from(data, { ignoreCert: true });
+ patchExeFile(exe);
+ return PE.NtExecutableResource.from(exe, true);
+}
+
+function getIconDataUrl(iconEntry: IconEntry, iconData: ArrayBuffer) {
+ let contentType = "image/png";
+
+ if (!isPng(new Uint8Array(iconData))) {
+ contentType = "image/x-icon";
+
+ const header = generateIcoHeader(iconEntry);
+ iconData = mergeArrayBuffers(header, iconData);
+ }
+
+ return generateDataURL(new Uint8Array(iconData), contentType);
+}
+
+export function parsePeIcon(data: Uint8Array) {
+ const res = getPeResources(data);
+ const icons = res.entries.filter((e) => e.type == 3);
+ const iconGroups = res.entries.filter((e) => e.type == 14);
+
+ if (iconGroups.length == 0 || icons.length == 0) {
+ return null;
+ }
+
+ const groupData = new Uint8Array(iconGroups[0].bin);
+ const group = parseIconGroup(groupData);
+ const iconEntry = getBiggestIcon(group);
+
+ if (!iconEntry) {
+ return null;
+ }
+
+ const icon = icons.find((i) => i.id == iconEntry.id);
+ if (!icon) {
+ return null;
+ }
+
+ return getIconDataUrl(iconEntry, icon.bin);
+}