Skip to content
Merged
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
15 changes: 14 additions & 1 deletion electron/file-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { mkdir, mkdtemp, rm, utimes, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { fileExists, getFileStats, readFileAsBuffer, scanFolder } from "./file-operations";
import {
fileExists,
getFileStats,
getQuickLookThumbnail,
readFileAsBuffer,
scanFolder,
} from "./file-operations";

let dir: string;

Expand Down Expand Up @@ -91,6 +97,13 @@ describe("readFileAsBuffer", () => {
expect(Buffer.from(buffer).toString("utf8")).toBe("hello");
});

describe("getQuickLookThumbnail", () => {
it("returns null when Quick Look is unavailable", async () => {
if (process.platform === "darwin") return;
expect(await getQuickLookThumbnail(join(dir, "file.pptx"))).toBeNull();
});
});

it("rejects for a file that does not exist", async () => {
await expect(readFileAsBuffer(join(dir, "missing.txt"))).rejects.toThrow();
});
Expand Down
34 changes: 32 additions & 2 deletions electron/file-operations.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { execFile } from "child_process";
import { shell } from "electron";
import { existsSync, readFileSync } from "fs";
import { readdir, stat } from "fs/promises";
import { join } from "path";
import { mkdtemp, readdir, readFile, rm, stat } from "fs/promises";
import { tmpdir } from "os";
import { basename, join } from "path";
import { promisify } from "util";
import { getFileType, isSupportedExtension } from "./file-types";
import type { FileItem, FileStats } from "./ipc-types";

const execFileAsync = promisify(execFile);

// Error-handling policy: operations with no safe fallback value (scanFolder,
// moveToTrash, readFileAsBuffer) log and rethrow, via logAndRethrow below, so
// a failure never gets silently swallowed. getFileStats has a safe fallback
Expand Down Expand Up @@ -91,3 +96,28 @@ export async function readFileAsBuffer(filePath: string): Promise<ArrayBuffer> {
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
});
}

// Ask macOS Quick Look for a rendered thumbnail. A null result is intentional:
// unsupported platforms and files use the renderer's format-specific fallback.
export async function getQuickLookThumbnail(filePath: string): Promise<ArrayBuffer | null> {
if (process.platform !== "darwin") return null;

const outputDirectory = await mkdtemp(join(tmpdir(), "quicktoss-quicklook-"));
try {
await execFileAsync("/usr/bin/qlmanage", ["-t", "-s", "1600", "-o", outputDirectory, filePath]);
const outputFiles = await readdir(outputDirectory);
const thumbnailName = outputFiles.find((name) => name.toLowerCase().endsWith(".png"));
if (!thumbnailName) {
console.warn(`Quick Look did not generate a thumbnail for ${basename(filePath)}`);
return null;
}

const buffer = await readFile(join(outputDirectory, thumbnailName));
return new Uint8Array(buffer).slice().buffer;
} catch (error) {
console.warn(`Quick Look preview unavailable for ${basename(filePath)}:`, error);
return null;
} finally {
await rm(outputDirectory, { recursive: true, force: true });
}
}
4 changes: 4 additions & 0 deletions electron/file-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ describe("getFileType", () => {
it("classifies documents", () => {
expect(getFileType(".pdf")).toBe("document");
expect(getFileType(".docx")).toBe("document");
expect(getFileType(".pptx")).toBe("document");
expect(getFileType(".xlsx")).toBe("document");
});

it("classifies video", () => {
Expand All @@ -40,6 +42,8 @@ describe("getFileType", () => {
describe("isSupportedExtension", () => {
it("accepts a supported extension", () => {
expect(isSupportedExtension(".jpg")).toBe(true);
expect(isSupportedExtension(".pptx")).toBe(true);
expect(isSupportedExtension(".xlsx")).toBe(true);
});

it("rejects an unsupported one", () => {
Expand Down
2 changes: 2 additions & 0 deletions electron/file-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export const DOCUMENT_EXTENSIONS = [
".yml",
".doc",
".docx",
".pptx",
".xlsx",
] as const;

export const VIDEO_EXTENSIONS = [".mp4", ".mov", ".avi"] as const;
Expand Down
2 changes: 2 additions & 0 deletions electron/ipc-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface ElectronAPI {
getFileStats: (filePath: string) => Promise<FileStats | null>;
fileExists: (filePath: string) => Promise<boolean>;
readFileAsBuffer: (filePath: string) => Promise<ArrayBuffer>;
getQuickLookThumbnail: (filePath: string) => Promise<ArrayBuffer | null>;
getSettings: () => Promise<AppSettings>;
saveSettings: (settings: AppSettings) => Promise<void>;
onUpdateStatus: (callback: (status: UpdateStatus) => void) => () => void;
Expand All @@ -56,6 +57,7 @@ export const CHANNELS = {
getFileStats: "get-file-stats",
fileExists: "file-exists",
readFileAsBuffer: "read-file-as-buffer",
getQuickLookThumbnail: "get-quick-look-thumbnail",
getSettings: "get-settings",
saveSettings: "save-settings",
onUpdateStatus: "update-status",
Expand Down
2 changes: 2 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { join } from "path";
import {
fileExists,
getFileStats,
getQuickLookThumbnail,
moveToTrash,
readFileAsBuffer,
scanFolder,
Expand Down Expand Up @@ -113,6 +114,7 @@ class QuickTossApp {
getFileStats,
fileExists,
readFileAsBuffer,
getQuickLookThumbnail,
getSettings: this.settingsStore.getSettings,
saveSettings: this.settingsStore.saveSettings,
openReleasePage: this.updater.openReleasePage,
Expand Down
2 changes: 2 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
getFileStats: (filePath: string) => ipcRenderer.invoke(CHANNELS.getFileStats, filePath),
fileExists: (filePath: string) => ipcRenderer.invoke(CHANNELS.fileExists, filePath),
readFileAsBuffer: (filePath: string) => ipcRenderer.invoke(CHANNELS.readFileAsBuffer, filePath),
getQuickLookThumbnail: (filePath: string) =>
ipcRenderer.invoke(CHANNELS.getQuickLookThumbnail, filePath),
getSettings: () => ipcRenderer.invoke(CHANNELS.getSettings),
saveSettings: (settings: AppSettings) => ipcRenderer.invoke(CHANNELS.saveSettings, settings),
onUpdateStatus: (callback: (status: UpdateStatus) => void) => {
Expand Down
Loading