diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6b8d62..6fb5abb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,12 @@ npm run dev ``` electron/ Electron main process - main.ts Window, IPC handlers, folder scanning, update check + main.ts Window lifecycle, wires IPC handlers to the modules below + file-operations.ts Folder scanning, trash, file stats/reads + settings-store.ts Settings persistence + updater.ts Auto-updater orchestration, its file logger, release page + ipc-types.ts Shared IPC contract (types + channel names) + ipc-register.ts Wires IPC handlers to their channel names preload.ts contextBridge API exposed to the renderer src/ React renderer components/ UI components @@ -65,9 +70,9 @@ npm test # once npm run test:watch # while developing ``` -Coverage is partial by design — the suite currently covers file-type classification, the formatting helpers, and the update notifier. Extending it is tracked in [#9](https://github.com/killerwolf/QuickToss/issues/9); good next targets are the undo stack and settings persistence. +Coverage is partial by design — the suite currently covers file-type classification, the formatting helpers, file operations, settings persistence, and the update notifier. Extending it is tracked in [#9](https://github.com/killerwolf/QuickToss/issues/9); the undo stack (`src/App.tsx`) is next. -Logic worth testing should live outside `electron/main.ts`, which instantiates the app at import time and can't be loaded from a test. `electron/file-types.ts` is the pattern to follow: pure functions the main process calls, importable on their own. +Logic worth testing should live outside `electron/main.ts`, which instantiates the app at import time and can't be loaded from a test. `electron/file-types.ts`, `electron/file-operations.ts`, and `electron/settings-store.ts` are the pattern to follow: pure functions (or a factory taking its dependencies as parameters) the main process calls, importable on their own. Note that `tsconfig.main.json` excludes `*.test.ts` so tests never end up in the packaged app; `tsconfig.test.json` typechecks them instead. diff --git a/electron/file-operations.test.ts b/electron/file-operations.test.ts new file mode 100644 index 0000000..d75dca3 --- /dev/null +++ b/electron/file-operations.test.ts @@ -0,0 +1,97 @@ +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"; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "quicktoss-file-operations-")); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe("scanFolder", () => { + it("returns supported files, sorted newest-modified first", async () => { + await writeFile(join(dir, "older.txt"), "older"); + await utimes(join(dir, "older.txt"), new Date("2024-01-01"), new Date("2024-01-01")); + await writeFile(join(dir, "newer.png"), "newer"); + await utimes(join(dir, "newer.png"), new Date("2024-06-01"), new Date("2024-06-01")); + + const files = await scanFolder(dir); + + expect(files.map((f) => f.name)).toEqual(["newer.png", "older.txt"]); + expect(files[0].type).toBe("image"); + expect(files[1].type).toBe("document"); + }); + + it("filters out unsupported extensions", async () => { + await writeFile(join(dir, "supported.jpg"), "x"); + await writeFile(join(dir, "unsupported.exe"), "x"); + + const files = await scanFolder(dir); + + expect(files.map((f) => f.name)).toEqual(["supported.jpg"]); + }); + + it("filters out subdirectories", async () => { + await mkdir(join(dir, "a-folder.pdf")); + await writeFile(join(dir, "a-file.pdf"), "x"); + + const files = await scanFolder(dir); + + expect(files.map((f) => f.name)).toEqual(["a-file.pdf"]); + }); + + it("rejects when the folder does not exist", async () => { + await expect(scanFolder(join(dir, "does-not-exist"))).rejects.toThrow(); + }); +}); + +describe("getFileStats", () => { + it("returns size, modified, and created for an existing file", async () => { + const path = join(dir, "file.txt"); + await writeFile(path, "hello"); + + const stats = await getFileStats(path); + + expect(stats?.size).toBe(5); + expect(stats?.modified).toBeInstanceOf(Date); + expect(stats?.created).toBeInstanceOf(Date); + }); + + it("returns null for a file that does not exist", async () => { + const stats = await getFileStats(join(dir, "missing.txt")); + expect(stats).toBeNull(); + }); +}); + +describe("fileExists", () => { + it("is true for an existing file", async () => { + const path = join(dir, "file.txt"); + await writeFile(path, "hello"); + expect(await fileExists(path)).toBe(true); + }); + + it("is false for a missing file", async () => { + expect(await fileExists(join(dir, "missing.txt"))).toBe(false); + }); +}); + +describe("readFileAsBuffer", () => { + it("returns the file's bytes as an ArrayBuffer", async () => { + const path = join(dir, "file.txt"); + await writeFile(path, "hello"); + + const buffer = await readFileAsBuffer(path); + + expect(Buffer.from(buffer).toString("utf8")).toBe("hello"); + }); + + it("rejects for a file that does not exist", async () => { + await expect(readFileAsBuffer(join(dir, "missing.txt"))).rejects.toThrow(); + }); +}); diff --git a/electron/file-operations.ts b/electron/file-operations.ts new file mode 100644 index 0000000..bc3d3ba --- /dev/null +++ b/electron/file-operations.ts @@ -0,0 +1,93 @@ +import { shell } from "electron"; +import { existsSync, readFileSync } from "fs"; +import { readdir, stat } from "fs/promises"; +import { join } from "path"; +import { getFileType, isSupportedExtension } from "./file-types"; +import type { FileItem, FileStats } from "./ipc-types"; + +// 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 +// (null — the file just has no displayable stats) and returns it instead. +// fileExists can't meaningfully fail. +async function logAndRethrow(label: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + console.error(`Error ${label}:`, error); + throw error; + } +} + +// Scan a folder for files +export async function scanFolder(folderPath: string): Promise { + return logAndRethrow("scanning folder", async () => { + const files: FileItem[] = []; + const entries = await readdir(folderPath); + + for (const entry of entries) { + const fullPath = join(folderPath, entry); + const stats = await stat(fullPath); + + if (stats.isFile()) { + const extension = entry.toLowerCase().substring(entry.lastIndexOf(".")); + + if (isSupportedExtension(extension)) { + files.push({ + name: entry, + path: fullPath, + size: stats.size, + modified: stats.mtime, + extension, + type: getFileType(extension), + }); + } + } + } + + // Sort by modification date (newest first) + files.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()); + + return files; + }); +} + +// Move a file to trash. +// +// Not covered by file-operations.test.ts: shell.trashItem doesn't behave +// meaningfully outside a real Electron main process, so a temp-dir test can +// only prove a mock got called, not that a file actually moved to trash. +export async function moveToTrash(filePath: string): Promise { + return logAndRethrow("moving to trash", async () => { + await shell.trashItem(filePath); + return true; + }); +} + +// Get file stats +export async function getFileStats(filePath: string): Promise { + try { + const stats = await stat(filePath); + return { + size: stats.size, + modified: stats.mtime, + created: stats.birthtime, + }; + } catch (error) { + console.error("Error getting file stats:", error); + return null; + } +} + +// Check if file exists +export async function fileExists(filePath: string): Promise { + return existsSync(filePath); +} + +// Read file as buffer for PDF preview +export async function readFileAsBuffer(filePath: string): Promise { + return logAndRethrow("reading file as buffer", async () => { + const buffer = readFileSync(filePath); + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + }); +} diff --git a/electron/main.ts b/electron/main.ts index 2e1ec93..7683835 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,45 +1,33 @@ -import { app, BrowserWindow, dialog, shell } from "electron"; -import { autoUpdater } from "electron-updater"; -import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; -import { readdir, stat } from "fs/promises"; +import { app, BrowserWindow, dialog } from "electron"; import { join } from "path"; -import { getFileType, isSupportedExtension } from "./file-types"; -import { registerHandlers } from "./ipc-register"; import { - type AppSettings, - CHANNELS, - type FileItem, - type RequestAPI, - type UpdateStatus, -} from "./ipc-types"; + fileExists, + getFileStats, + moveToTrash, + readFileAsBuffer, + scanFolder, +} from "./file-operations"; +import { registerHandlers } from "./ipc-register"; +import { CHANNELS, type RequestAPI } from "./ipc-types"; +import { createSettingsStore } from "./settings-store"; +import { createUpdater } from "./updater"; // Handle creating/removing shortcuts on Windows when installing/uninstalling if (require("electron-squirrel-startup")) { app.quit(); } -const RELEASES_URL = "https://github.com/killerwolf/QuickToss/releases"; - -interface UpdaterLogger { - info: (...args: unknown[]) => void; - warn: (...args: unknown[]) => void; - error: (...args: unknown[]) => void; - debug: (...args: unknown[]) => void; -} - class QuickTossApp { private mainWindow: BrowserWindow | null = null; private isDev = process.env.NODE_ENV === "development"; - private settingsPath: string; - private updaterLogger: UpdaterLogger; - private availableUpdateVersion: string | null = null; + private settingsStore = createSettingsStore(join(app.getPath("userData"), "settings.json")); + private updater = createUpdater((status) => { + this.mainWindow?.webContents.send(CHANNELS.onUpdateStatus, status); + }); constructor() { - this.settingsPath = join(app.getPath("userData"), "settings.json"); - this.updaterLogger = this.createUpdaterLogger(); this.setupApp(); this.setupIPC(); - this.setupAutoUpdater(); } private setupApp() { @@ -60,63 +48,6 @@ class QuickTossApp { }); } - private setupAutoUpdater() { - // Installing an update in place needs a Developer ID signature: Squirrel.Mac - // rejects the downloaded bundle's signature on arm64, and on x64 - // electron-updater can't even read a signature for the running app. Until - // the app is signed and notarized, we only *check* for updates and point - // the user at the releases page — downloading ~90MB just to fail at the - // install step would be worse than not downloading at all. - autoUpdater.autoDownload = false; - autoUpdater.autoInstallOnAppQuit = false; - const logger = this.updaterLogger; - autoUpdater.logger = logger; - logger.info(`Update check started (version ${app.getVersion()}, ${process.arch})`); - - const sendStatus = (status: UpdateStatus) => { - this.mainWindow?.webContents.send(CHANNELS.onUpdateStatus, status); - }; - - autoUpdater.on("update-available", (info) => { - logger.info(`Update available: ${info.version}`); - this.availableUpdateVersion = info.version; - sendStatus({ state: "available", version: info.version }); - }); - - autoUpdater.on("error", (error) => { - logger.error("Update check failed", error); - sendStatus({ state: "error", message: error.message }); - }); - } - - private createUpdaterLogger() { - // app.getPath("logs") points at ~/Library/Logs/QuickToss on macOS, but the - // directory isn't guaranteed to exist yet — without this the very first - // append throws ENOENT and every log line is silently lost. - const logDir = app.getPath("logs"); - mkdirSync(logDir, { recursive: true }); - const logPath = join(logDir, "auto-updater.log"); - - const format = (value: unknown) => - value instanceof Error ? (value.stack ?? `${value.name}: ${value.message}`) : String(value); - - const write = (level: string, args: unknown[]) => { - const line = `[${new Date().toISOString()}] [${level}] ${args.map(format).join(" ")}\n`; - try { - appendFileSync(logPath, line); - } catch (error) { - console.error("Failed to write updater log:", error); - } - }; - - return { - info: (...args: unknown[]) => write("info", args), - warn: (...args: unknown[]) => write("warn", args), - error: (...args: unknown[]) => write("error", args), - debug: (...args: unknown[]) => write("debug", args), - }; - } - private createMainWindow() { this.mainWindow = new BrowserWindow({ width: 1200, @@ -154,9 +85,7 @@ class QuickTossApp { // subscribes to "update-status" would be dropped and never shown. this.mainWindow.webContents.once("did-finish-load", () => { if (this.isDev) return; - autoUpdater.checkForUpdates().catch((error) => { - this.updaterLogger.error("Update check could not start", error); - }); + this.updater.checkForUpdates(); }); this.mainWindow.on("closed", () => { @@ -166,7 +95,8 @@ class QuickTossApp { private setupIPC() { const handlers: RequestAPI = { - // Select folder dialog + // Select folder dialog. The only handler that needs mainWindow, so it + // stays here instead of in file-operations.ts, which is window-free. selectFolder: async () => { if (!this.mainWindow) return null; @@ -178,151 +108,18 @@ class QuickTossApp { return result.canceled ? null : result.filePaths[0]; }, - // Scan folder for files - scanFolder: async (folderPath: string) => { - try { - const files = await this.scanFolder(folderPath); - return files; - } catch (error) { - console.error("Error scanning folder:", error); - throw error; - } - }, - - // Move file to trash - moveToTrash: async (filePath: string) => { - try { - await shell.trashItem(filePath); - return true; - } catch (error) { - console.error("Error moving to trash:", error); - throw error; - } - }, - - // Get file stats - getFileStats: async (filePath: string) => { - try { - const stats = await stat(filePath); - return { - size: stats.size, - modified: stats.mtime, - created: stats.birthtime, - }; - } catch (error) { - console.error("Error getting file stats:", error); - return null; - } - }, - - // Check if file exists - fileExists: async (filePath: string) => { - return existsSync(filePath); - }, - - // Read file as buffer for PDF preview - readFileAsBuffer: async (filePath: string) => { - try { - const buffer = readFileSync(filePath); - return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); - } catch (error) { - console.error("Error reading file as buffer:", error); - throw error; - } - }, - - // Get app settings - getSettings: async () => { - try { - if (existsSync(this.settingsPath)) { - const settingsData = readFileSync(this.settingsPath, "utf8"); - return JSON.parse(settingsData); - } else { - // Return default settings - const defaultSettings: AppSettings = { - soundEffects: true, - videoAutoplay: false, - confirmDelete: true, - }; - return defaultSettings; - } - } catch (error) { - console.error("Error reading settings:", error); - // Return default settings on error - return { - soundEffects: true, - videoAutoplay: false, - confirmDelete: true, - }; - } - }, - - // Save app settings - saveSettings: async (settings: AppSettings) => { - try { - // Ensure userData directory exists - const userDataDir = app.getPath("userData"); - if (!existsSync(userDataDir)) { - mkdirSync(userDataDir, { recursive: true }); - } - - writeFileSync(this.settingsPath, JSON.stringify(settings, null, 2)); - } catch (error) { - console.error("Error saving settings:", error); - throw error; - } - }, - - // Open the release page so the user can download the update manually. - // The URL is built here rather than passed in from the renderer, so the - // renderer can't ask the main process to open an arbitrary link. - openReleasePage: async () => { - const url = this.availableUpdateVersion - ? `${RELEASES_URL}/tag/v${this.availableUpdateVersion}` - : `${RELEASES_URL}/latest`; - this.updaterLogger.info(`Opening release page: ${url}`); - await shell.openExternal(url); - }, + scanFolder, + moveToTrash, + getFileStats, + fileExists, + readFileAsBuffer, + getSettings: this.settingsStore.getSettings, + saveSettings: this.settingsStore.saveSettings, + openReleasePage: this.updater.openReleasePage, }; registerHandlers(handlers); } - - private async scanFolder(folderPath: string): Promise { - const files: FileItem[] = []; - - try { - const entries = await readdir(folderPath); - - for (const entry of entries) { - const fullPath = join(folderPath, entry); - const stats = await stat(fullPath); - - if (stats.isFile()) { - const extension = entry.toLowerCase().substring(entry.lastIndexOf(".")); - - if (isSupportedExtension(extension)) { - files.push({ - name: entry, - path: fullPath, - size: stats.size, - modified: stats.mtime, - extension, - type: getFileType(extension), - }); - } - } - } - - // Sort by modification date (newest first) - files.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()); - - return files; - } catch (error) { - console.error("Error scanning folder:", error); - throw error; - } - } } // Initialize the app diff --git a/electron/settings-store.test.ts b/electron/settings-store.test.ts new file mode 100644 index 0000000..1d0a329 --- /dev/null +++ b/electron/settings-store.test.ts @@ -0,0 +1,55 @@ +import { mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { AppSettings } from "./ipc-types"; +import { createSettingsStore, DEFAULT_SETTINGS } from "./settings-store"; + +let dir: string; +let settingsPath: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "quicktoss-settings-store-")); + settingsPath = join(dir, "settings.json"); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe("getSettings", () => { + it("returns the defaults when no settings file exists", async () => { + const store = createSettingsStore(settingsPath); + expect(await store.getSettings()).toEqual(DEFAULT_SETTINGS); + }); + + it("returns the defaults when the settings file is corrupted", async () => { + await writeFile(settingsPath, "{not valid json"); + const store = createSettingsStore(settingsPath); + expect(await store.getSettings()).toEqual(DEFAULT_SETTINGS); + }); +}); + +describe("saveSettings", () => { + it("round-trips through getSettings", async () => { + const store = createSettingsStore(settingsPath); + const settings: AppSettings = { + soundEffects: false, + videoAutoplay: true, + confirmDelete: false, + }; + + await store.saveSettings(settings); + + expect(await store.getSettings()).toEqual(settings); + }); + + it("creates the parent directory if it doesn't exist yet", async () => { + const nestedPath = join(dir, "nested", "settings.json"); + const store = createSettingsStore(nestedPath); + + await store.saveSettings(DEFAULT_SETTINGS); + + expect(await store.getSettings()).toEqual(DEFAULT_SETTINGS); + }); +}); diff --git a/electron/settings-store.ts b/electron/settings-store.ts new file mode 100644 index 0000000..5e9b663 --- /dev/null +++ b/electron/settings-store.ts @@ -0,0 +1,46 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { dirname } from "path"; +import type { AppSettings } from "./ipc-types"; + +export const DEFAULT_SETTINGS: AppSettings = { + soundEffects: true, + videoAutoplay: false, + confirmDelete: true, +}; + +// Takes settingsPath as a dependency rather than computing it from +// app.getPath("userData") internally, so it's testable against a temp +// directory instead of the real user data folder. +export function createSettingsStore(settingsPath: string) { + return { + // Get app settings + async getSettings(): Promise { + try { + if (!existsSync(settingsPath)) { + return DEFAULT_SETTINGS; + } + const settingsData = readFileSync(settingsPath, "utf8"); + return JSON.parse(settingsData); + } catch (error) { + console.error("Error reading settings:", error); + return DEFAULT_SETTINGS; + } + }, + + // Save app settings + async saveSettings(settings: AppSettings): Promise { + try { + // Ensure the settings directory exists + const settingsDir = dirname(settingsPath); + if (!existsSync(settingsDir)) { + mkdirSync(settingsDir, { recursive: true }); + } + + writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + } catch (error) { + console.error("Error saving settings:", error); + throw error; + } + }, + }; +} diff --git a/electron/updater.ts b/electron/updater.ts new file mode 100644 index 0000000..add2084 --- /dev/null +++ b/electron/updater.ts @@ -0,0 +1,95 @@ +import { app, shell } from "electron"; +import { autoUpdater } from "electron-updater"; +import { appendFileSync, mkdirSync } from "fs"; +import { join } from "path"; +import type { UpdateStatus } from "./ipc-types"; + +const RELEASES_URL = "https://github.com/killerwolf/QuickToss/releases"; + +interface UpdaterLogger { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + debug: (...args: unknown[]) => void; +} + +function createUpdaterLogger(): UpdaterLogger { + // app.getPath("logs") points at ~/Library/Logs/QuickToss on macOS, but the + // directory isn't guaranteed to exist yet — without this the very first + // append throws ENOENT and every log line is silently lost. + const logDir = app.getPath("logs"); + mkdirSync(logDir, { recursive: true }); + const logPath = join(logDir, "auto-updater.log"); + + const format = (value: unknown) => + value instanceof Error ? (value.stack ?? `${value.name}: ${value.message}`) : String(value); + + const write = (level: string, args: unknown[]) => { + const line = `[${new Date().toISOString()}] [${level}] ${args.map(format).join(" ")}\n`; + try { + appendFileSync(logPath, line); + } catch (error) { + console.error("Failed to write updater log:", error); + } + }; + + return { + info: (...args: unknown[]) => write("info", args), + warn: (...args: unknown[]) => write("warn", args), + error: (...args: unknown[]) => write("error", args), + debug: (...args: unknown[]) => write("debug", args), + }; +} + +// Window-free by design: takes an onStatus callback instead of a +// BrowserWindow/webContents reference, so only the coordinator ever touches +// the window. Not covered by a test file — it wraps electron-updater's real +// update-check flow, which (like moveToTrash) doesn't behave meaningfully +// outside a real Electron main process. +export function createUpdater(onStatus: (status: UpdateStatus) => void) { + const logger = createUpdaterLogger(); + let availableUpdateVersion: string | null = null; + + // Installing an update in place needs a Developer ID signature: Squirrel.Mac + // rejects the downloaded bundle's signature on arm64, and on x64 + // electron-updater can't even read a signature for the running app. Until + // the app is signed and notarized, we only *check* for updates and point + // the user at the releases page — downloading ~90MB just to fail at the + // install step would be worse than not downloading at all. + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; + autoUpdater.logger = logger; + logger.info(`Update check started (version ${app.getVersion()}, ${process.arch})`); + + autoUpdater.on("update-available", (info) => { + logger.info(`Update available: ${info.version}`); + availableUpdateVersion = info.version; + onStatus({ state: "available", version: info.version }); + }); + + autoUpdater.on("error", (error) => { + logger.error("Update check failed", error); + onStatus({ state: "error", message: error.message }); + }); + + return { + async checkForUpdates(): Promise { + try { + await autoUpdater.checkForUpdates(); + } catch (error) { + logger.error("Update check could not start", error); + } + }, + + // Open the release page so the user can download the update manually. + // The URL is built here rather than passed in from the renderer, so the + // renderer can't ask the main process to open an arbitrary link. + async openReleasePage(): Promise { + const url = availableUpdateVersion + ? `${RELEASES_URL}/tag/v${availableUpdateVersion}` + : `${RELEASES_URL}/latest`; + logger.info(`Opening release page: ${url}`); + await shell.openExternal(url); + }, + }; +}