Skip to content

Commit

Permalink
Fix(FileImageGenerator): remove extrac-file-icon dependency (#1075)
Browse files Browse the repository at this point in the history
* Use app.getFileIcon instead of extract-file-icon

* Added macos app icon extractor

* Use more accurate method name

* feat(FileImageGenerator): added generic app icon and folder icon extractor on macos

* chore: removed unused file

* feat: added WindowsApplicationIconExtractor

* Added bulk icon extraction for better performance

* Added Windows folder icon extractor
  • Loading branch information
oliverschwendener authored Mar 4, 2024
1 parent 845c2f1 commit f923aeb
Show file tree
Hide file tree
Showing 37 changed files with 583 additions and 302 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@
"dependencies": {
"@fluentui/react-components": "^9.46.3",
"@fluentui/react-icons": "^2.0.226",
"extract-file-icon": "^0.3.2",
"fuse.js": "^7.0.0",
"i18next": "^23.8.1",
"mathjs": "^12.3.1",
Expand Down
14 changes: 0 additions & 14 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion src/common/Core/ContextBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export type ContextBridge = {
on: IpcRenderer["on"];
};

resetCache: () => Promise<void>;
copyTextToClipboard: (textToCopy: string) => void;
extensionDisabled: (extensionId: string) => void;
extensionEnabled: (extensionId: string) => void;
Expand Down
5 changes: 0 additions & 5 deletions src/main/Core/ExtensionManager/ExtensionManagerModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,6 @@ export class ExtensionManagerModule {

await extensionManager.populateSearchIndex();

ipcMain.handle("resetCache", async () => {
await dependencyRegistry.get("FileImageGenerator").clearCache();
await extensionManager.populateSearchIndex();
});

ipcMain.on("getExtensionTranslations", (event) => {
event.returnValue = extensionManager.getSupportedExtensions().map((extension) => ({
extensionId: extension.id,
Expand Down
7 changes: 7 additions & 0 deletions src/main/Core/ImageGenerator/CacheFileNameGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createHash } from "crypto";

export class CacheFileNameGenerator {
public generateCacheFileName(filePath: string): string {
return createHash("sha1").update(filePath).digest("hex");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ import type { Image } from "@common/Core/Image";

export interface FileImageGenerator {
getImage(filePath: string): Promise<Image>;
clearCache: () => Promise<void>;
getImages(filePaths: string[]): Promise<Record<string, Image>>;
}
7 changes: 7 additions & 0 deletions src/main/Core/ImageGenerator/FileIconExtractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { Image } from "@common/Core/Image";

export interface FileIconExtractor {
machtes: (filePath: string) => boolean;
extractFileIcon: (filePath: string) => Promise<Image>;
extractFileIcons: (filePaths: string[]) => Promise<Record<string, Image>>;
}
167 changes: 92 additions & 75 deletions src/main/Core/ImageGenerator/FileImageGenerator.test.ts
Original file line number Diff line number Diff line change
@@ -1,89 +1,106 @@
import type { FileSystemUtility } from "@Core/FileSystemUtility";
import type { Image } from "@common/Core/Image";
import { createHash } from "crypto";
import { join } from "path";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { FileImageGenerator } from "./FileImageGenerator";

describe(FileImageGenerator, () => {
const cacheFolderPath = "cacheFolderPath";

it("should clear cache folder if it exists", async () => {
const pathExistsMock = vi.fn().mockReturnValue(true);
const clearFolderMock = vi.fn().mockReturnValue(Promise.resolve());

const fileSystemUtility = <FileSystemUtility>{
pathExists: (f) => pathExistsMock(f),
clearFolder: (f) => clearFolderMock(f),
};

const fileImageGenerator = new FileImageGenerator(cacheFolderPath, fileSystemUtility, () => null);

await fileImageGenerator.clearCache();
expect(pathExistsMock).toHaveBeenCalledWith(cacheFolderPath);
expect(clearFolderMock).toHaveBeenCalledWith(cacheFolderPath);
});

it("should do nothing if cache folder does not exist", async () => {
const pathExistsMock = vi.fn().mockReturnValue(false);
const fileSystemUtility = <FileSystemUtility>{ pathExists: (f) => pathExistsMock(f) };

const fileImageGenerator = new FileImageGenerator(cacheFolderPath, fileSystemUtility, () => null);

await fileImageGenerator.clearCache();
expect(pathExistsMock).toHaveBeenCalledWith(cacheFolderPath);
it("should return the extracted image from the first matching file icon extractor", async () => {
const fileImageGenerator = new FileImageGenerator([
{
machtes: () => false,
extractFileIcon: async () => <Image>{ url: "test url 1" },
extractFileIcons: async () => <Record<string, Image>>{},
},
{
machtes: () => false,
extractFileIcon: async () => <Image>{ url: "test url 2" },
extractFileIcons: async () => <Record<string, Image>>{},
},
{
machtes: () => true,
extractFileIcon: async () => <Image>{ url: "test url 3" },
extractFileIcons: async () => <Record<string, Image>>{},
},
]);

expect(await fileImageGenerator.getImage("my file path")).toEqual(<Image>{ url: "test url 3" });
});

it("should create the cached file if it doesn't exist and return the image", async () => {
const pathExistsMock = vi.fn().mockReturnValue(false);
const writePngMock = vi.fn().mockReturnValue(Promise.resolve());
const buffer = Buffer.from("testBuffer");

const fileSystemUtility = <FileSystemUtility>{
pathExists: (f) => pathExistsMock(f),
writePng: (b, f) => writePngMock(b, f),
};

const cachedPngFilePath = join(cacheFolderPath, `${createHash("sha1").update("my file").digest("hex")}.png`);

const actual = await new FileImageGenerator(cacheFolderPath, fileSystemUtility, () => buffer).getImage(
"my file",
it("should throw an error if all file icon extractors don't match the given file path", async () => {
const fileImageGenerator = new FileImageGenerator([
{
machtes: () => false,
extractFileIcon: async () => <Image>{ url: "test url 1" },
extractFileIcons: async () => <Record<string, Image>>{},
},
{
machtes: () => false,
extractFileIcon: async () => <Image>{ url: "test url 2" },
extractFileIcons: async () => <Record<string, Image>>{},
},
{
machtes: () => false,
extractFileIcon: async () => <Image>{ url: "test url 3" },
extractFileIcons: async () => <Record<string, Image>>{},
},
]);

await expect(fileImageGenerator.getImage("my file path")).rejects.toThrowError(
`Failed to extract file icon from path "my file path". Reason: file path did not match any file icon extractor`,
);

expect(actual).toEqual(<Image>{ url: `file://${cachedPngFilePath}` });
expect(pathExistsMock).toHaveBeenCalledWith(cachedPngFilePath);
expect(writePngMock).toHaveBeenCalledWith(buffer, cachedPngFilePath);
});

it("should not write a new cached file if it already exists and return the image", async () => {
const pathExistsMock = vi.fn().mockReturnValue(true);
const writePngMock = vi.fn().mockReturnValue(Promise.resolve());

const fileSystemUtility = <FileSystemUtility>{
pathExists: (f) => pathExistsMock(f),
writePng: (b, f) => writePngMock(b, f),
};

const cachedPngFilePath = join(cacheFolderPath, `${createHash("sha1").update("my file").digest("hex")}.png`);

const actual = await new FileImageGenerator(cacheFolderPath, fileSystemUtility, () => null).getImage("my file");

expect(actual).toEqual(<Image>{ url: `file://${cachedPngFilePath}` });
expect(pathExistsMock).toHaveBeenCalledWith(cachedPngFilePath);
expect(writePngMock).not.toHaveBeenCalled();
it("should bulk extract file icons", async () => {
const fileImageGenerator = new FileImageGenerator([
{
machtes: () => false,
extractFileIcon: async () => <Image>{},
extractFileIcons: async () => <Record<string, Image>>{},
},
{
machtes: () => false,
extractFileIcon: async () => <Image>{},
extractFileIcons: async () => <Record<string, Image>>{},
},
{
machtes: () => true,
extractFileIcon: async () => <Image>{},
extractFileIcons: async () =>
<Record<string, Image>>{
path1: <Image>{ url: "test url 1" },
path2: <Image>{ url: "test url 2" },
path3: <Image>{ url: "test url 3" },
},
},
]);

expect(await fileImageGenerator.getImages(["path1", "path2", "path3"])).toEqual({
path1: <Image>{ url: "test url 1" },
path2: <Image>{ url: "test url 2" },
path3: <Image>{ url: "test url 3" },
});
});

it("should throw an error if getFileIcon returns buffer with empty buffer length", async () => {
const pathExistsMock = vi.fn().mockReturnValue(false);
const fileSystemUtility = <FileSystemUtility>{ pathExists: (f) => pathExistsMock(f) };

const cachedPngFilePath = join(cacheFolderPath, `${createHash("sha1").update("my file").digest("hex")}.png`);

const fileImageGenerator = new FileImageGenerator(cacheFolderPath, fileSystemUtility, () => Buffer.alloc(0));

expect(() => fileImageGenerator.getImage("my file")).rejects.toThrow(
"getFileIcon returned Buffer with length 0",
it("should throw an error when no file image extractor matches all given file paths", async () => {
const fileImageGenerator = new FileImageGenerator([
{
machtes: () => false,
extractFileIcon: async () => <Image>{},
extractFileIcons: async () => <Record<string, Image>>{},
},
{
machtes: () => false,
extractFileIcon: async () => <Image>{},
extractFileIcons: async () => <Record<string, Image>>{},
},
{
machtes: () => false,
extractFileIcon: async () => <Image>{},
extractFileIcons: async () => <Record<string, Image>>{},
},
]);

await expect(fileImageGenerator.getImages(["path1", "path2", "path3"])).rejects.toThrowError(
"Failed to extract file icons. Reason: file paths did not match any file icon extractor",
);
expect(pathExistsMock).toHaveBeenCalledWith(cachedPngFilePath);
});
});
50 changes: 15 additions & 35 deletions src/main/Core/ImageGenerator/FileImageGenerator.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,29 @@
import type { FileSystemUtility } from "@Core/FileSystemUtility";
import type { Image } from "@common/Core/Image";
import { createHash } from "crypto";
import { join } from "path";
import type { FileImageGenerator as FileImageGeneratorInterface } from "./Contract";
import type { FileIconExtractor } from "./FileIconExtractor";

export class FileImageGenerator implements FileImageGeneratorInterface {
public constructor(
private readonly cacheFolderPath: string,
private readonly fileSystemUtility: FileSystemUtility,
private readonly getFileIcon: (filePath: string) => Buffer,
) {}

public async clearCache(): Promise<void> {
const exists = await this.fileSystemUtility.pathExists(this.cacheFolderPath);

if (exists) {
await this.fileSystemUtility.clearFolder(this.cacheFolderPath);
}
}
public constructor(private readonly fileIconExtractors: FileIconExtractor[]) {}

public async getImage(filePath: string): Promise<Image> {
const cachedPngFilePath = await this.ensureCachedPngFileExists(filePath);
for (const fileIconExtractor of this.fileIconExtractors) {
if (fileIconExtractor.machtes(filePath)) {
return await fileIconExtractor.extractFileIcon(filePath);
}
}

return { url: `file://${cachedPngFilePath}` };
throw new Error(
`Failed to extract file icon from path "${filePath}". Reason: file path did not match any file icon extractor`,
);
}

private async ensureCachedPngFileExists(filePath: string): Promise<string> {
const cachedPngFilePath = join(this.cacheFolderPath, `${this.generateCacheFileName(filePath)}.png`);

const exists = await this.fileSystemUtility.pathExists(cachedPngFilePath);

if (!exists) {
const buffer = this.getFileIcon(filePath);

if (!buffer.byteLength) {
throw new Error("getFileIcon returned Buffer with length 0");
public async getImages(filePaths: string[]): Promise<Record<string, Image>> {
for (const fileIconExtractor of this.fileIconExtractors) {
if (filePaths.every((filePath) => fileIconExtractor.machtes(filePath))) {
return await fileIconExtractor.extractFileIcons(filePaths);
}

await this.fileSystemUtility.writePng(buffer, cachedPngFilePath);
}

return cachedPngFilePath;
}

private generateCacheFileName(filePath: string): string {
return createHash("sha1").update(filePath).digest("hex");
throw new Error(`Failed to extract file icons. Reason: file paths did not match any file icon extractor`);
}
}
33 changes: 33 additions & 0 deletions src/main/Core/ImageGenerator/GenericFileIconExtractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Image } from "@common/Core/Image";
import type { App } from "electron";
import type { FileIconExtractor } from "./FileIconExtractor";

export class GenericFileIconExtractor implements FileIconExtractor {
public constructor(private readonly app: App) {}

public machtes() {
return true;
}

public async extractFileIcon(filePath: string) {
const nativeImage = await this.app.getFileIcon(filePath);
return { url: nativeImage.toDataURL() };
}

public async extractFileIcons(filePaths: string[]) {
const result: Record<string, Image> = {};

const promiseResults = await Promise.allSettled(filePaths.map((f) => this.extractFileIcon(f)));

for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
const promiseResult = promiseResults[i];

if (promiseResult.status === "fulfilled") {
result[filePath] = promiseResult.value;
}
}

return result;
}
}
Loading

0 comments on commit f923aeb

Please sign in to comment.