diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 009ade60a..f36e68a24 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -16,6 +16,7 @@ import { shell, systemPreferences, } from "electron"; +import { strToU8, zipSync } from "fflate"; import type { NativeMacRecordingRequest } from "../../src/lib/nativeMacRecording"; import type { NativeWindowsRecordingRequest } from "../../src/lib/nativeWindowsRecording"; import { @@ -36,6 +37,7 @@ import type { ProjectPathResult, } from "../../src/native/contracts"; import { mainT } from "../i18n"; +import log from "../logger"; import { RECORDINGS_DIR } from "../main"; import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory"; import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession"; @@ -2640,17 +2642,20 @@ export function registerIpcHandlers( _, payload: { error: string; stack?: string; projectState: unknown; logs: string[] }, ) => { + const version = app.getVersion(); + const date = new Date().toISOString().slice(0, 10); + const { filePath, canceled } = await dialog.showSaveDialog({ title: "Save Diagnostic File", - defaultPath: `openscreen-diagnostic-${Date.now()}.json`, - filters: [{ name: "JSON", extensions: ["json"] }], + defaultPath: `openscreen-diagnostic-${version}-${date}.zip`, + filters: [{ name: "ZIP Archive", extensions: ["zip"] }], }); if (canceled || !filePath) return { success: false, canceled: true }; - const diagnostic = { + const info = { timestamp: new Date().toISOString(), - appVersion: app.getVersion(), + appVersion: version, platform: process.platform, arch: process.arch, osRelease: os.release(), @@ -2661,15 +2666,20 @@ export function registerIpcHandlers( chromeVersion: process.versions.chrome, error: payload.error, stack: payload.stack, - projectState: payload.projectState, - recentLogs: payload.logs, }; + const logPath = log.transports.file.getFile().path; + const logContent = await fs.readFile(logPath).catch(() => Buffer.alloc(0)); + try { - await fs.writeFile(filePath, JSON.stringify(diagnostic, null, 2), "utf-8"); + const zip = zipSync({ + "info.json": strToU8(JSON.stringify(info, null, 2)), + "main.log": new Uint8Array(logContent), + }); + await fs.writeFile(filePath, zip); return { success: true, path: filePath }; } catch (error) { - console.error("Failed to write diagnostic file:", error); + console.error("Failed to write diagnostic zip:", error); return { success: false, error: String(error) }; } }, diff --git a/electron/logger.test.ts b/electron/logger.test.ts new file mode 100644 index 000000000..3cd515993 --- /dev/null +++ b/electron/logger.test.ts @@ -0,0 +1,252 @@ +// @vitest-environment node +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// --- mocks (hoisted by vitest before any imports) --- + +vi.mock("electron-log/main", () => { + const fileTransport = { + level: undefined as unknown, + maxSize: undefined as unknown, + format: undefined as unknown, + archiveLogFn: undefined as unknown, + getFile: vi.fn().mockReturnValue({ path: "/logs/main.log" }), + }; + return { + default: { + initialize: vi.fn(), + transports: { file: fileTransport }, + info: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock("node:fs/promises", () => ({ + default: { + readdir: vi.fn(), + stat: vi.fn(), + unlink: vi.fn(), + readFile: vi.fn(), + }, +})); + +vi.mock("node:fs", () => ({ + default: { renameSync: vi.fn() }, +})); + +// --- imports (resolved against the mocks above) --- + +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import log from "electron-log/main"; +import { cleanupOldLogs, getRecentLogLines, initializeLogger } from "./logger"; + +// Convenience aliases so tests stay readable +const mockGetFile = vi.mocked(log.transports.file.getFile); +const mockReaddir = vi.mocked(fs.readdir); +const mockStat = vi.mocked(fs.stat); +const mockUnlink = vi.mocked(fs.unlink); +const mockReadFile = vi.mocked(fs.readFile); +const mockRenameSync = vi.mocked(fsSync.renameSync); + +const LOG_PATH = "/logs/main.log"; +const LOG_DIR = "/logs"; + +afterEach(() => { + vi.clearAllMocks(); + mockGetFile.mockReturnValue({ path: LOG_PATH }); +}); + +// --------------------------------------------------------------------------- +describe("initializeLogger", () => { + it("calls log.initialize()", () => { + initializeLogger(); + expect(log.initialize).toHaveBeenCalledOnce(); + }); + + it("sets the file transport level, maxSize, and format", () => { + initializeLogger(); + expect(log.transports.file.level).toBe("debug"); + expect(log.transports.file.maxSize).toBe(5 * 1024 * 1024); + expect(log.transports.file.format).toBe("[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}"); + }); + + it("assigns archiveLogFn to the file transport", () => { + initializeLogger(); + expect(typeof log.transports.file.archiveLogFn).toBe("function"); + }); + + it("returns the log instance", () => { + const result = initializeLogger(); + expect(result).toBe(log); + }); +}); + +// --------------------------------------------------------------------------- +describe("archiveLogFn (assigned by initializeLogger)", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-15T10:30:00.000Z")); + initializeLogger(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renames the log file with a timestamp suffix", () => { + const archiveLogFn = log.transports.file.archiveLogFn as (f: { path: string }) => void; + archiveLogFn({ path: LOG_PATH }); + + expect(mockRenameSync).toHaveBeenCalledWith( + LOG_PATH, + path.join(LOG_DIR, "main.2025-01-15T10-30-00-000Z.log"), + ); + }); + + it("derives the archive path from the file's own directory", () => { + const archiveLogFn = log.transports.file.archiveLogFn as (f: { path: string }) => void; + archiveLogFn({ path: "/other-dir/app.log" }); + + expect(mockRenameSync).toHaveBeenCalledWith( + "/other-dir/app.log", + path.join("/other-dir", "app.2025-01-15T10-30-00-000Z.log"), + ); + }); +}); + +// --------------------------------------------------------------------------- +describe("cleanupOldLogs", () => { + // Six archived files ordered oldest → newest + const archived = [ + "main.2025-01-01T00-00-00-000Z.log", + "main.2025-01-02T00-00-00-000Z.log", + "main.2025-01-03T00-00-00-000Z.log", + "main.2025-01-04T00-00-00-000Z.log", + "main.2025-01-05T00-00-00-000Z.log", + "main.2025-01-06T00-00-00-000Z.log", + ]; + + function mtimeFor(filename: string) { + const index = archived.indexOf(filename); + return new Date(2025, 0, index + 1); // Jan 1–6 2025 + } + + function setupStat(files: string[]) { + mockStat.mockImplementation(async (p) => { + const name = path.basename(p as string); + return { mtime: mtimeFor(name) } as Awaited>; + }); + void files; // suppress unused-var warning; files are returned by readdir + } + + it("does nothing when there are no archived files", async () => { + mockReaddir.mockResolvedValue(["main.log"] as never); + + await cleanupOldLogs(); + + expect(mockUnlink).not.toHaveBeenCalled(); + }); + + it("does nothing when archived files are within the 4-file limit", async () => { + const files = ["main.log", ...archived.slice(2)]; // 4 archived + mockReaddir.mockResolvedValue(files as never); + setupStat(archived.slice(2)); + + await cleanupOldLogs(); + + expect(mockUnlink).not.toHaveBeenCalled(); + }); + + it("deletes the oldest files when more than 4 archives exist", async () => { + mockReaddir.mockResolvedValue(["main.log", ...archived] as never); + setupStat(archived); + + await cleanupOldLogs(); + + // 6 archived → keep 4 newest (Jan 3–6), delete 2 oldest (Jan 1–2) + expect(mockUnlink).toHaveBeenCalledTimes(2); + expect(mockUnlink).toHaveBeenCalledWith(path.join(LOG_DIR, archived[0])); // Jan 1 + expect(mockUnlink).toHaveBeenCalledWith(path.join(LOG_DIR, archived[1])); // Jan 2 + }); + + it("ignores non-archived files in the log directory", async () => { + mockReaddir.mockResolvedValue([ + "main.log", + "unrelated.txt", + "debug.log", // different base name — should be ignored + archived[0], + archived[1], + archived[2], + archived[3], + ] as never); + setupStat(archived); + + await cleanupOldLogs(); + + expect(mockUnlink).not.toHaveBeenCalled(); // 4 matching archived = within limit + }); + + it("logs info when files are deleted", async () => { + mockReaddir.mockResolvedValue(["main.log", ...archived] as never); + setupStat(archived); + + await cleanupOldLogs(); + + expect(log.info).toHaveBeenCalledWith(expect.stringContaining("Removed 2 old log file(s)")); + }); + + it("calls log.warn and does not throw when readdir fails", async () => { + mockReaddir.mockRejectedValue(new Error("ENOENT")); + + await expect(cleanupOldLogs()).resolves.toBeUndefined(); + expect(log.warn).toHaveBeenCalledWith("Log cleanup failed:", expect.any(Error)); + }); +}); + +// --------------------------------------------------------------------------- +describe("getRecentLogLines", () => { + it("returns the last N lines from the log file", async () => { + const lines = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`); + mockReadFile.mockResolvedValue(lines.join("\n") as never); + + const result = await getRecentLogLines(3); + + expect(result).toEqual(["line 8", "line 9", "line 10"]); + }); + + it("filters out empty lines", async () => { + mockReadFile.mockResolvedValue("a\n\nb\n\nc\n" as never); + + const result = await getRecentLogLines(10); + + expect(result).toEqual(["a", "b", "c"]); + }); + + it("returns all lines when the file has fewer than maxLines", async () => { + mockReadFile.mockResolvedValue("a\nb\nc\n" as never); + + const result = await getRecentLogLines(100); + + expect(result).toEqual(["a", "b", "c"]); + }); + + it("returns an empty array when the file cannot be read", async () => { + mockReadFile.mockRejectedValue(new Error("ENOENT")); + + const result = await getRecentLogLines(); + + expect(result).toEqual([]); + }); + + it("reads from the path returned by getFile()", async () => { + const customPath = "/custom/path/app.log"; + mockGetFile.mockReturnValue({ path: customPath }); + mockReadFile.mockResolvedValue("entry\n" as never); + + await getRecentLogLines(); + + expect(mockReadFile).toHaveBeenCalledWith(customPath, "utf-8"); + }); +}); diff --git a/electron/logger.ts b/electron/logger.ts new file mode 100644 index 000000000..931155cb7 --- /dev/null +++ b/electron/logger.ts @@ -0,0 +1,75 @@ +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import log from "electron-log/main"; + +const MAX_LOG_FILES = 5; +const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB + +export function initializeLogger(): typeof log { + log.initialize(); + + log.transports.file.level = "debug"; + log.transports.file.maxSize = MAX_FILE_SIZE_BYTES; + log.transports.file.format = "[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}"; + + // Archive with a timestamp so multiple rotated files can coexist + log.transports.file.archiveLogFn = (file) => { + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const ext = path.extname(file.path); + const base = path.basename(file.path, ext); + const dir = path.dirname(file.path); + fsSync.renameSync(file.path, path.join(dir, `${base}.${ts}${ext}`)); + }; + + return log; +} + +export async function cleanupOldLogs(): Promise { + try { + const currentPath = log.transports.file.getFile().path; + const logDir = path.dirname(currentPath); + const ext = path.extname(currentPath); + const base = path.basename(currentPath, ext); + + const entries = await fs.readdir(logDir); + // Match archived files: base.TIMESTAMP.ext (e.g. main.2025-01-01T00-00-00-000Z.log) + const archived = entries.filter( + (f) => f !== path.basename(currentPath) && f.startsWith(`${base}.`) && f.endsWith(ext), + ); + + const withStats = await Promise.all( + archived.map(async (f) => { + const p = path.join(logDir, f); + const stat = await fs.stat(p); + return { path: p, mtime: stat.mtime }; + }), + ); + + // Newest first — keep (MAX_LOG_FILES - 1) archived plus 1 current + withStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + const toDelete = withStats.slice(MAX_LOG_FILES - 1); + await Promise.all(toDelete.map((f) => fs.unlink(f.path))); + + if (toDelete.length > 0) { + log.info( + `Removed ${toDelete.length} old log file(s) to stay within the ${MAX_LOG_FILES}-file limit`, + ); + } + } catch (error) { + log.warn("Log cleanup failed:", error); + } +} + +export async function getRecentLogLines(maxLines = 500): Promise { + try { + const logPath = log.transports.file.getFile().path; + const content = await fs.readFile(logPath, "utf-8"); + const lines = content.split("\n").filter(Boolean); + return lines.slice(-maxLines); + } catch { + return []; + } +} + +export default log; diff --git a/electron/main.ts b/electron/main.ts index 3e2258f8f..ea02c7a66 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -13,6 +13,7 @@ import { } from "electron"; import { mainT, setMainLocale } from "./i18n"; import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers"; +import { cleanupOldLogs, initializeLogger } from "./logger"; import { createCountdownOverlayWindow, createEditorWindow, @@ -20,6 +21,9 @@ import { createSourceSelectorWindow, } from "./windows"; +// Initialize disk logging before anything else so early boot messages land on disk +const log = initializeLogger(); + const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Use Screen & System Audio Recording permissions instead of CoreAudio Tap API on macOS. @@ -46,10 +50,10 @@ export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); async function ensureRecordingsDir() { try { await fs.mkdir(RECORDINGS_DIR, { recursive: true }); - console.log("RECORDINGS_DIR:", RECORDINGS_DIR); - console.log("User Data Path:", app.getPath("userData")); + log.info("RECORDINGS_DIR:", RECORDINGS_DIR); + log.info("User Data Path:", app.getPath("userData")); } catch (error) { - console.error("Failed to create recordings directory:", error); + log.error("Failed to create recordings directory:", error); } } @@ -515,8 +519,9 @@ app.whenReady().then(async () => { createTray(); updateTrayMenu(); setupApplicationMenu(); - // Ensure recordings directory exists + // Ensure recordings directory exists and clean up old log files await ensureRecordingsDir(); + await cleanupOldLogs(); function switchToHudWrapper() { if (mainWindow) { diff --git a/package-lock.json b/package-lock.json index 50ecc9d88..dd9e386ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,9 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dnd-timeline": "^2.4.0", + "electron-log": "^5.4.4", "emoji-picker-react": "^4.18.0", + "fflate": "^0.8.3", "fix-webm-duration": "^1.0.6", "gif.js": "^0.2.0", "gsap": "^3.15.0", @@ -5953,6 +5955,15 @@ "node": ">= 10.0.0" } }, + "node_modules/electron-log": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.4.tgz", + "integrity": "sha512-istWgaXjBfURBSS8LWVW9C3jsc6+ac+tY1lXrQEOTp0lVj+a4OlO1Tmqb36GgnEUDv92DGC9VI1HNXwJinWpgA==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/electron-publish": { "version": "26.8.1", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", @@ -6439,6 +6450,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", diff --git a/package.json b/package.json index fd0c4cf3d..02a1cfc45 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,9 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dnd-timeline": "^2.4.0", + "electron-log": "^5.4.4", "emoji-picker-react": "^4.18.0", + "fflate": "^0.8.3", "fix-webm-duration": "^1.0.6", "gif.js": "^0.2.0", "gsap": "^3.15.0", diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 0d2402f3d..64d70c3a2 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -1,3 +1,4 @@ +import log from "electron-log/renderer"; import GIF from "gif.js"; import type { AnnotationRegion, @@ -207,13 +208,13 @@ export class GifExporter { // Calculate frame delay in milliseconds (gif.js uses ms) const frameDelay = Math.round(1000 / this.config.frameRate); - console.log("[GifExporter] Original duration:", videoInfo.duration, "s"); - console.log("[GifExporter] Effective duration:", effectiveDuration, "s"); - console.log("[GifExporter] Total frames to export:", totalFrames); - console.log("[GifExporter] Frame rate:", this.config.frameRate, "FPS"); - console.log("[GifExporter] Frame delay:", frameDelay, "ms"); - console.log("[GifExporter] Loop:", this.config.loop ? "infinite" : "once"); - console.log("[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)"); + log.info("[GifExporter] Original duration:", videoInfo.duration, "s"); + log.info("[GifExporter] Effective duration:", effectiveDuration, "s"); + log.info("[GifExporter] Total frames to export:", totalFrames); + log.info("[GifExporter] Frame rate:", this.config.frameRate, "FPS"); + log.info("[GifExporter] Frame delay:", frameDelay, "ms"); + log.info("[GifExporter] Loop:", this.config.loop ? "infinite" : "once"); + log.info("[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)"); let frameIndex = 0; webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null; diff --git a/vitest.config.ts b/vitest.config.ts index 9108f6991..34bec8764 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ test: { globals: true, environment: "jsdom", - include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}", "electron/**/*.test.ts"], exclude: ["src/**/*.browser.test.{ts,tsx}"], }, resolve: {