From 405713580d998c499fe20228d21d33f3b35e9821 Mon Sep 17 00:00:00 2001 From: Hamdi LAADHARI Date: Wed, 2 Sep 2026 16:04:04 +0200 Subject: [PATCH] refactor(electron): split main.ts into deep FileOperations/SettingsStore/Updater modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.ts's QuickTossApp mixed five concerns — window lifecycle, IPC handler logic, folder-scan business logic, settings persistence, and auto-updater orchestration — none of it unit-testable without an Electron process. Extract three modules: - file-operations.ts: scanFolder/moveToTrash/getFileStats/fileExists/ readFileAsBuffer as plain, window-free async functions - settings-store.ts: createSettingsStore(settingsPath) factory, taking the path as a parameter instead of computing it from app.getPath("userData") internally, so it's testable against a temp directory - updater.ts: auto-updater orchestration, its file logger, and openReleasePage, all window-free — it takes an onStatus callback instead of touching BrowserWindow/webContents directly main.ts keeps only window lifecycle and selectFolder (the one handler that needs mainWindow), and wires the extracted modules' functions directly into the IPC handlers object. Adds real tests for file-operations.ts and settings-store.ts against temp directories (no fs mocking, matching file-types.test.ts's style). moveToTrash and updater.ts are left untested — shell.trashItem and electron-updater's real update flow don't behave meaningfully outside a real Electron main process. Co-Authored-By: Claude Sonnet 5 --- CONTRIBUTING.md | 11 +- electron/file-operations.test.ts | 97 ++++++++++++ electron/file-operations.ts | 93 +++++++++++ electron/main.ts | 255 ++++--------------------------- electron/settings-store.test.ts | 55 +++++++ electron/settings-store.ts | 46 ++++++ electron/updater.ts | 95 ++++++++++++ 7 files changed, 420 insertions(+), 232 deletions(-) create mode 100644 electron/file-operations.test.ts create mode 100644 electron/file-operations.ts create mode 100644 electron/settings-store.test.ts create mode 100644 electron/settings-store.ts create mode 100644 electron/updater.ts 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); + }, + }; +}