From 34d9641f4c326024b0e16f189243a3436efa1e98 Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Sun, 18 Oct 2026 09:45:02 -0400 Subject: [PATCH 1/4] feat(diagnostics): write main-process logs to the OS log folder --- electron/cli/cliMain.ts | 2 +- electron/diagnostics/main-log-buffer.test.ts | 97 ------ electron/diagnostics/main-log-buffer.ts | 107 ------- electron/diagnostics/main-log-file.test.ts | 280 ++++++++++++++++++ electron/diagnostics/main-log-file.ts | 274 +++++++++++++++++ electron/ipc/handlers.ts | 11 +- electron/main.ts | 21 +- electron/stt/index.ts | 4 +- .../testing/manual-e2e-checklist.md | 2 +- 9 files changed, 576 insertions(+), 222 deletions(-) delete mode 100644 electron/diagnostics/main-log-buffer.test.ts delete mode 100644 electron/diagnostics/main-log-buffer.ts create mode 100644 electron/diagnostics/main-log-file.test.ts create mode 100644 electron/diagnostics/main-log-file.ts diff --git a/electron/cli/cliMain.ts b/electron/cli/cliMain.ts index 410ba97458..0638ea5237 100644 --- a/electron/cli/cliMain.ts +++ b/electron/cli/cliMain.ts @@ -8,7 +8,7 @@ import readline from "node:readline"; import { app, BrowserWindow, ipcMain, Menu, session, systemPreferences } from "electron"; import type { CliProgressEvent, CliSourcesResult } from "../../src/lib/cliContracts"; import { renameWithRetry } from "../atomic-rename"; -import { isDiagnosticModeEnabled } from "../diagnostics/main-log-buffer"; +import { isDiagnosticModeEnabled } from "../diagnostics/main-log-file"; import { getInstallChannel } from "../install-channel"; import { getSelectedDesktopSource, registerIpcHandlers } from "../ipc/handlers"; import { openVideoInHiddenWindow } from "../mcp/hidden-video"; diff --git a/electron/diagnostics/main-log-buffer.test.ts b/electron/diagnostics/main-log-buffer.test.ts deleted file mode 100644 index 488d4cb32c..0000000000 --- a/electron/diagnostics/main-log-buffer.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { MainLogBuffer } from "./main-log-buffer"; - -describe("MainLogBuffer", () => { - const buffers: MainLogBuffer[] = []; - - function make(capacity: number) { - const b = new MainLogBuffer(capacity); - buffers.push(b); - return b; - } - - afterEach(() => { - for (const b of buffers) b.uninstall(); - buffers.length = 0; - }); - - it("captures every console level and routes to original", () => { - const buf = make(10); - const captured: string[] = []; - const original = { - log: (...args: unknown[]) => captured.push(`log:${args.join(",")}`), - info: (...args: unknown[]) => captured.push(`info:${args.join(",")}`), - warn: (...args: unknown[]) => captured.push(`warn:${args.join(",")}`), - error: (...args: unknown[]) => captured.push(`error:${args.join(",")}`), - }; - Object.assign(console, original); - buf.install(); - console.log("hello"); - console.info("world"); - console.warn("watch"); - console.error("bad"); - const snap = buf.snapshot(); - expect(snap.map((e) => e.level)).toEqual(["log", "info", "warn", "error"]); - expect(snap.map((e) => e.text)).toEqual(["hello", "world", "watch", "bad"]); - expect(captured).toEqual(["log:hello", "info:world", "warn:watch", "error:bad"]); - }); - - it("stringifies non-string args", () => { - const buf = make(5); - buf.install(); - console.info({ a: 1 }); - const snap = buf.snapshot(); - expect(snap[0].text).toBe('{"a":1}'); - }); - - it("keeps an Error's message and stack rather than JSON's {}", () => { - const buf = make(5); - Object.assign(console, { - error: () => { - // The stack this logs would only clutter the test output. - }, - }); - buf.install(); - const error = new Error("helper exited (code=1)"); - console.error("Failed to stop native Linux recording:", error); - const [entry] = buf.snapshot(); - expect(entry.text).toBe(`Failed to stop native Linux recording: ${error.stack}`); - expect(entry.text).toContain("Error: helper exited (code=1)"); - }); - - it("drops oldest entries past capacity", () => { - const buf = make(3); - buf.install(); - for (let i = 0; i < 5; i += 1) console.info(`line ${i}`); - const snap = buf.snapshot(); - expect(snap.map((e) => e.text)).toEqual(["line 2", "line 3", "line 4"]); - }); - - it("uninstall restores originals", () => { - const buf = make(5); - buf.install(); - console.info("captured"); - buf.uninstall(); - console.info("after-uninstall"); - const snap = buf.snapshot(); - expect(snap.map((e) => e.text)).toEqual(["captured"]); - }); - - it("clear empties the buffer", () => { - const buf = make(5); - buf.install(); - console.info("one"); - console.info("two"); - buf.clear(); - expect(buf.snapshot()).toEqual([]); - }); - - it("install is idempotent", () => { - const buf = make(5); - buf.install(); - buf.install(); - console.info("once"); - const snap = buf.snapshot(); - expect(snap.map((e) => e.text)).toEqual(["once"]); - }); -}); diff --git a/electron/diagnostics/main-log-buffer.ts b/electron/diagnostics/main-log-buffer.ts deleted file mode 100644 index 8a9c8b5d64..0000000000 --- a/electron/diagnostics/main-log-buffer.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Ring buffer for main-process console output. - * - * Captures the last `capacity` lines written via console.info / console.warn / - * console.error / console.log into a single in-memory buffer, which "Save - * Diagnostics" writes as `mainProcessLogs`. main.ts installs it on every app - * launch: the users who save a bundle start the app from a desktop entry, the - * Dock or the Start menu, where no OPENSCREEN_DIAGNOSTIC can be set, and gating - * it on that flag left every one of their bundles with no log at all. - * - * Cost when installed: one array.push + occasional shift per console call, - * negligible against the rest of the app. Cost when not installed (the CLI): - * zero, the original console methods are kept untouched. - */ - -const DEFAULT_CAPACITY = 500; - -export interface MainLogEntry { - timestampMs: number; - level: "info" | "warn" | "error" | "log"; - text: string; -} - -export class MainLogBuffer { - private readonly capacity: number; - private readonly entries: MainLogEntry[] = []; - private installed = false; - private readonly originals: Partial< - Record<"log" | "info" | "warn" | "error", (...args: unknown[]) => void> - > = {}; - - constructor(capacity = DEFAULT_CAPACITY) { - this.capacity = Math.max(1, capacity); - } - - install(): void { - if (this.installed) return; - this.installed = true; - const console_ = console as unknown as Record< - "log" | "info" | "warn" | "error", - (...args: unknown[]) => void - >; - for (const level of ["log", "info", "warn", "error"] as const) { - this.originals[level] = console_[level].bind(console); - console_[level] = (...args: unknown[]) => { - this.push(level, args); - this.originals[level]?.(...args); - }; - } - } - - uninstall(): void { - if (!this.installed) return; - this.installed = false; - const console_ = console as unknown as Record< - "log" | "info" | "warn" | "error", - (...args: unknown[]) => void - >; - for (const level of ["log", "info", "warn", "error"] as const) { - if (this.originals[level]) { - console_[level] = this.originals[level] as (...args: unknown[]) => void; - } - } - this.originals.log = undefined; - this.originals.info = undefined; - this.originals.warn = undefined; - this.originals.error = undefined; - } - - snapshot(): MainLogEntry[] { - return this.entries.slice(); - } - - clear(): void { - this.entries.length = 0; - } - - private push(level: MainLogEntry["level"], args: unknown[]): void { - const text = args - .map((arg) => { - if (typeof arg === "string") return arg; - // An Error's message and stack are not enumerable, so JSON.stringify - // writes `{}`: every `console.error("…failed:", error)` lost its cause. - // The stack is what the terminal shows for it. - if (arg instanceof Error) return arg.stack ?? `${arg.name}: ${arg.message}`; - try { - return JSON.stringify(arg); - } catch { - return String(arg); - } - }) - .join(" "); - this.entries.push({ timestampMs: Date.now(), level, text }); - if (this.entries.length > this.capacity) { - this.entries.splice(0, this.entries.length - this.capacity); - } - } -} - -export const mainLogBuffer = new MainLogBuffer(); - -export function isDiagnosticModeEnabled(): boolean { - const raw = process.env.OPENSCREEN_DIAGNOSTIC; - if (!raw) return false; - const lowered = raw.trim().toLowerCase(); - return lowered === "1" || lowered === "true" || lowered === "yes"; -} diff --git a/electron/diagnostics/main-log-file.test.ts b/electron/diagnostics/main-log-file.test.ts new file mode 100644 index 0000000000..1321318d4a --- /dev/null +++ b/electron/diagnostics/main-log-file.test.ts @@ -0,0 +1,280 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MainLogFile } from "./main-log-file"; + +describe("MainLogFile", () => { + const logs: MainLogFile[] = []; + let dir: string; + + function make(maxFileBytes?: number) { + const log = new MainLogFile(maxFileBytes); + logs.push(log); + return log; + } + + function silenceConsole(captured: string[] = []) { + Object.assign(console, { + log: (...args: unknown[]) => captured.push(`log:${args.join(",")}`), + info: (...args: unknown[]) => captured.push(`info:${args.join(",")}`), + warn: (...args: unknown[]) => captured.push(`warn:${args.join(",")}`), + error: (...args: unknown[]) => captured.push(`error:${args.join(",")}`), + }); + return captured; + } + + function lines(file = "main.log") { + return fs.readFileSync(path.join(dir, file), "utf-8").trimEnd().split("\n"); + } + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-main-log-")); + }); + + afterEach(() => { + for (const log of logs) log.uninstall(); + logs.length = 0; + vi.restoreAllMocks(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("writes a launch header, then every console level, and still calls the console", () => { + const captured = silenceConsole(); + make().install(dir, "launch 1.2.3 pid 42"); + console.log("hello"); + console.info("world"); + console.warn("watch"); + console.error("bad"); + + const written = lines(); + expect(written[0]).toMatch(/^\S+ --- launch 1\.2\.3 pid 42 ---$/); + expect(written.slice(1).map((line) => line.split(" ").slice(1).join(" "))).toEqual([ + "log hello", + "info world", + "warn watch", + "error bad", + ]); + expect(captured).toEqual(["log:hello", "info:world", "warn:watch", "error:bad"]); + }); + + it("appends to the previous launch's file", () => { + silenceConsole(); + const first = make(); + first.install(dir, "launch one"); + console.info("from one"); + first.uninstall(); + make().install(dir, "launch two"); + + expect(lines().map((line) => line.split(" ").slice(1).join(" "))).toEqual([ + "--- launch one ---", + "info from one", + "--- launch two ---", + ]); + }); + + it("writes an Error as its stack and an object as JSON", () => { + silenceConsole(); + make().install(dir, "launch"); + const error = new Error("helper exited (code=1)"); + console.error("Failed to stop:", error); + console.info({ a: 1 }); + + const text = fs.readFileSync(path.join(dir, "main.log"), "utf-8"); + expect(text).toContain(`error Failed to stop: ${error.stack}`); + expect(text).toContain('info {"a":1}'); + }); + + it("moves a full file to main.old.log and never keeps more than two files", () => { + silenceConsole(); + make(200).install(dir, "launch"); + for (let i = 0; i < 30; i += 1) console.info(`line ${String(i).padStart(2, "0")}`); + + expect(fs.readdirSync(dir).sort()).toEqual(["main.log", "main.old.log"]); + expect(fs.statSync(path.join(dir, "main.log")).size).toBeLessThanOrEqual(200); + expect(fs.statSync(path.join(dir, "main.old.log")).size).toBeLessThanOrEqual(200); + expect(lines().at(-1)).toMatch(/info line 29$/); + }); + + it("rotates a file already over the cap when it is installed", () => { + silenceConsole(); + fs.writeFileSync(path.join(dir, "main.log"), "x".repeat(300)); + make(200).install(dir, "launch"); + + expect(fs.readFileSync(path.join(dir, "main.old.log"), "utf-8")).toBe("x".repeat(300)); + expect(lines()).toHaveLength(1); + }); + + it("reads the tail across main.old.log and main.log, from a line start", () => { + fs.writeFileSync(path.join(dir, "main.old.log"), "old 1\nold 2\nold 3\n"); + fs.writeFileSync(path.join(dir, "main.log"), "new 1\nnew 2\n"); + silenceConsole(); + const log = make(); + log.install(dir, "launch"); + const header = lines().at(-1) as string; + const currentBytes = Buffer.byteLength(`new 1\nnew 2\n${header}\n`); + + expect(log.readTail(10_000)).toBe(`old 1\nold 2\nold 3\nnew 1\nnew 2\n${header}\n`); + // Eight bytes into the old file land mid-line: that partial line is dropped. + expect(log.readTail(currentBytes + 8)).toBe(`old 3\nnew 1\nnew 2\n${header}\n`); + expect(log.readTail(Buffer.byteLength(`${header}\n`) + 3)).toBe(`${header}\n`); + }); + + it("keeps the console working and reports why when the folder cannot be created", () => { + const captured = silenceConsole(); + const blocker = path.join(dir, "not-a-folder"); + fs.writeFileSync(blocker, ""); + const log = make(); + log.install(path.join(blocker, "logs"), "launch"); + console.info("still here"); + + expect(captured).toEqual(["info:still here"]); + expect(log.lastError).toMatch(/^(EEXIST|ENOTDIR)/); + expect(log.readTail(1000)).toBe(""); + }); + + it("stops writing after a failed write and keeps the error", () => { + const captured = silenceConsole(); + const log = make(); + log.install(dir, "launch"); + vi.spyOn(fs, "writeSync").mockImplementationOnce(() => { + throw Object.assign(new Error("ENOSPC: no space left on device, write"), { + code: "ENOSPC", + }); + }); + console.info("lost"); + console.info("also lost"); + + expect(captured).toEqual(["info:lost", "info:also lost"]); + expect(log.lastError).toMatch(/^ENOSPC/); + expect(lines()).toHaveLength(1); + }); + + it("uninstall restores the console methods", () => { + const captured = silenceConsole(); + const original = console.info; + const log = make(); + log.install(dir, "launch"); + log.uninstall(); + console.info("after"); + + expect(console.info).toBe(original); + expect(captured).toEqual(["info:after"]); + expect(lines()).toHaveLength(1); + }); + + describe("flood guard", () => { + function texts() { + return lines().map((line) => line.split(" ").slice(1).join(" ")); + } + + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-18T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("counts an identical line in a row instead of writing it", () => { + const captured = silenceConsole(); + make().install(dir, "launch"); + for (let i = 0; i < 1000; i += 1) console.warn("[live] readback_direct: lost"); + console.info("next"); + + expect(texts()).toEqual([ + "--- launch ---", + "warn [live] readback_direct: lost", + "warn last message repeated 999 times", + "info next", + ]); + expect(captured).toHaveLength(1001); + }); + + it("does not merge the same text at different levels", () => { + silenceConsole(); + make().install(dir, "launch"); + console.info("same"); + console.warn("same"); + + expect(texts()).toEqual(["--- launch ---", "info same", "warn same"]); + }); + + it("writes the repeat count while a flood lasts", () => { + silenceConsole(); + make().install(dir, "launch"); + console.warn("flood"); + console.warn("flood"); + vi.advanceTimersByTime(30_000); + console.warn("flood"); + for (let i = 0; i < 10_000; i += 1) console.warn("flood"); + + expect(texts()).toEqual([ + "--- launch ---", + "warn flood", + "warn last message repeated 2 times", + "warn last message repeated 10000 times", + ]); + }); + + it("rate-limits lines that differ and says how many were dropped", () => { + const captured = silenceConsole(); + make().install(dir, "launch"); + for (let i = 0; i < 300; i += 1) console.warn(i % 2 ? "[annotation] a" : "[annotation] b"); + vi.advanceTimersByTime(1000); + console.info("after"); + + const written = texts(); + expect(written).toHaveLength(1 + 200 + 2); + expect(written.at(-2)).toBe("warn 100 lines suppressed (more than 10 lines/s)"); + expect(written.at(-1)).toBe("info after"); + expect(captured).toHaveLength(301); + }); + + it("writes the suppressed count once lines are let through again", () => { + silenceConsole(); + make().install(dir, "launch"); + for (let i = 0; i < 250; i += 1) console.warn(`frame ${i}`); + vi.advanceTimersByTime(30_000); + // 300 tokens came back over 30 s, capped at 200: the first line of this batch + // is written along with the summary. + console.warn("frame 250"); + + expect(texts().slice(-2)).toEqual([ + "warn 50 lines suppressed (more than 10 lines/s)", + "warn frame 250", + ]); + }); + + it("writes pending counts before the tail is read", () => { + silenceConsole(); + const log = make(); + log.install(dir, "launch"); + console.warn("flood"); + console.warn("flood"); + + expect(log.readTail(10_000)).toMatch(/warn last message repeated 1 time\n$/); + }); + + it("caps a single line", () => { + silenceConsole(); + make().install(dir, "launch"); + console.info("x".repeat(70 * 1024)); + + const written = lines()[1]; + expect(written).toMatch(/…\[truncated 6144 chars\]$/); + expect(written.length).toBeLessThan(65 * 1024); + }); + }); + + it("install is idempotent", () => { + silenceConsole(); + const log = make(); + log.install(dir, "launch"); + log.install(dir, "launch"); + console.info("once"); + + expect(lines()).toHaveLength(2); + }); +}); diff --git a/electron/diagnostics/main-log-file.ts b/electron/diagnostics/main-log-file.ts new file mode 100644 index 0000000000..cd3941a390 --- /dev/null +++ b/electron/diagnostics/main-log-file.ts @@ -0,0 +1,274 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Main-process console output, appended to `main.log` in the OS log folder + * (`app.getPath("logs")`), which "Save Diagnostics" reads back as `mainProcessLogs`. + * On disk rather than in memory so a bundle saved after a crash still holds the + * run that crashed. The folder stays small by itself: past `maxFileBytes`, + * `main.log` becomes `main.old.log` (replacing the previous one) and starts over, + * so there are never more than two files. + * + * Writes are synchronous so the last lines before a native crash reach the disk. + * Logging never breaks the app: a failed open or write turns the file off, keeps + * the error as `lastError`, and the console carries on. + * + * A flood cannot push the useful lines out of the folder: an identical line in a row + * is counted instead of written, any line is capped at `MAX_LINE_CHARS`, and the + * rest is rate-limited (`BURST_LINES`, then `LINES_PER_SECOND`). Both counts are + * written as one summary line, at least every `SUMMARY_INTERVAL_MS` while a flood + * lasts. Only the file is affected: the console still gets every line. + */ + +const LOG_FILE = "main.log"; +const OLD_LOG_FILE = "main.old.log"; +const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024; +const MAX_LINE_CHARS = 64 * 1024; +const BURST_LINES = 200; +const LINES_PER_SECOND = 10; +const SUMMARY_INTERVAL_MS = 30_000; +const MAX_REPEATS_PER_SUMMARY = 10_000; + +type Level = "log" | "info" | "warn" | "error"; +const LEVELS = ["log", "info", "warn", "error"] as const; + +export class MainLogFile { + private dir: string | null = null; + private fd: number | null = null; + private bytes = 0; + private error: string | null = null; + private readonly originals: Partial void>> = {}; + + private lastKey: string | null = null; + private lastLevel: Level = "log"; + private repeats = 0; + private repeatsSince = 0; + private tokens = BURST_LINES; + private tokensAt = 0; + private suppressed = 0; + private suppressedSince = 0; + + constructor(private readonly maxFileBytes = DEFAULT_MAX_FILE_BYTES) {} + + get filePath(): string | null { + return this.dir ? path.join(this.dir, LOG_FILE) : null; + } + + get lastError(): string | null { + return this.error; + } + + install(dir: string, header: string): void { + if (this.dir) return; + this.dir = dir; + try { + fs.mkdirSync(dir, { recursive: true }); + this.open(); + } catch (error) { + this.fail(error); + } + this.tokensAt = Date.now(); + this.write(`${new Date().toISOString()} --- ${header} ---\n`); + + const console_ = console as unknown as Record void>; + for (const level of LEVELS) { + const original = console_[level]; + this.originals[level] = original; + console_[level] = (...args: unknown[]) => { + this.record(level, formatArgs(args)); + original.apply(console, args); + }; + } + } + + /** Writes the pending repeat and suppression counts. */ + flush(): void { + const now = Date.now(); + this.flushRepeats(now); + this.flushSuppressed(now); + } + + uninstall(): void { + if (!this.dir) return; + this.flush(); + const console_ = console as unknown as Record void>; + for (const level of LEVELS) { + const original = this.originals[level]; + if (original) console_[level] = original; + this.originals[level] = undefined; + } + this.close(); + this.dir = null; + this.error = null; + this.lastKey = null; + this.repeats = 0; + this.suppressed = 0; + this.tokens = BURST_LINES; + } + + /** The last `maxBytes` of `main.old.log` + `main.log`, starting on a whole line. */ + readTail(maxBytes: number): string { + if (!this.dir) return ""; + this.flush(); + const current = readLastBytes(path.join(this.dir, LOG_FILE), maxBytes); + let data = current.data; + let whole = current.whole; + if (current.whole && data.length < maxBytes) { + const old = readLastBytes(path.join(this.dir, OLD_LOG_FILE), maxBytes - data.length); + data = Buffer.concat([old.data, data]); + whole = old.whole; + } + const text = data.toString("utf-8"); + return whole ? text : text.slice(text.indexOf("\n") + 1); + } + + private record(level: Level, rawText: string): void { + if (this.fd === null) return; + const now = Date.now(); + const text = + rawText.length > MAX_LINE_CHARS + ? `${rawText.slice(0, MAX_LINE_CHARS)}…[truncated ${rawText.length - MAX_LINE_CHARS} chars]` + : rawText; + const key = `${level} ${text}`; + + if (key === this.lastKey) { + if (this.repeats === 0) this.repeatsSince = now; + this.repeats += 1; + if ( + this.repeats >= MAX_REPEATS_PER_SUMMARY || + now - this.repeatsSince >= SUMMARY_INTERVAL_MS + ) { + this.flushRepeats(now); + } + return; + } + this.flushRepeats(now); + this.lastKey = key; + this.lastLevel = level; + + this.tokens = Math.min( + BURST_LINES, + this.tokens + ((now - this.tokensAt) / 1000) * LINES_PER_SECOND, + ); + this.tokensAt = now; + if (this.tokens < 1) { + // A line that was not written cannot be "repeated": its copies are suppressed too. + this.lastKey = null; + if (this.suppressed === 0) this.suppressedSince = now; + this.suppressed += 1; + if (now - this.suppressedSince >= SUMMARY_INTERVAL_MS) this.flushSuppressed(now); + return; + } + this.tokens -= 1; + this.flushSuppressed(now); + this.write(`${new Date(now).toISOString()} ${level} ${text}\n`); + } + + private flushRepeats(now: number): void { + if (this.repeats === 0) return; + const count = this.repeats; + this.repeats = 0; + this.repeatsSince = now; + this.write( + `${new Date(now).toISOString()} ${this.lastLevel} last message repeated ${count} time${count === 1 ? "" : "s"}\n`, + ); + } + + private flushSuppressed(now: number): void { + if (this.suppressed === 0) return; + const count = this.suppressed; + this.suppressed = 0; + this.suppressedSince = now; + this.write( + `${new Date(now).toISOString()} warn ${count} lines suppressed (more than ${LINES_PER_SECOND} lines/s)\n`, + ); + } + + private open(): void { + if (!this.dir) return; + const file = path.join(this.dir, LOG_FILE); + if (fs.existsSync(file) && fs.statSync(file).size >= this.maxFileBytes) { + fs.renameSync(file, path.join(this.dir, OLD_LOG_FILE)); + } + this.fd = fs.openSync(file, "a"); + this.bytes = fs.fstatSync(this.fd).size; + } + + private rotate(): void { + if (!this.dir) return; + this.close(); + fs.renameSync(path.join(this.dir, LOG_FILE), path.join(this.dir, OLD_LOG_FILE)); + this.open(); + } + + private write(line: string): void { + if (this.fd === null) return; + try { + const data = Buffer.from(line, "utf-8"); + if (this.bytes > 0 && this.bytes + data.length > this.maxFileBytes) this.rotate(); + if (this.fd === null) return; + fs.writeSync(this.fd, data); + this.bytes += data.length; + } catch (error) { + this.fail(error); + } + } + + private fail(error: unknown): void { + this.close(); + this.error = error instanceof Error ? error.message : String(error); + } + + private close(): void { + if (this.fd === null) return; + try { + fs.closeSync(this.fd); + } catch { + // Already gone; nothing left to flush. + } + this.fd = null; + } +} + +function formatArgs(args: unknown[]): string { + return args + .map((arg) => { + if (typeof arg === "string") return arg; + // An Error's message and stack are not enumerable, so JSON.stringify writes `{}`. + if (arg instanceof Error) return arg.stack ?? `${arg.name}: ${arg.message}`; + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } + }) + .join(" "); +} + +/** `whole` is true when the read reached the start of the file (or it does not exist). */ +function readLastBytes(file: string, maxBytes: number): { data: Buffer; whole: boolean } { + let fd: number; + try { + fd = fs.openSync(file, "r"); + } catch { + return { data: Buffer.alloc(0), whole: true }; + } + try { + const size = fs.fstatSync(fd).size; + const length = Math.min(size, maxBytes); + const data = Buffer.alloc(length); + fs.readSync(fd, data, 0, length, size - length); + return { data, whole: length === size }; + } finally { + fs.closeSync(fd); + } +} + +export const mainLogFile = new MainLogFile(); + +export function isDiagnosticModeEnabled(): boolean { + const raw = process.env.OPENSCREEN_DIAGNOSTIC; + if (!raw) return false; + const lowered = raw.trim().toLowerCase(); + return lowered === "1" || lowered === "true" || lowered === "yes"; +} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index ee36e105f4..50bd0a5cb5 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -71,7 +71,7 @@ import { StylePresetService } from "../ai-edition/style-preset-service"; import { AppSettingsStore } from "../app-settings"; import { createRunnerHost } from "../cli/runnerHost"; import { hideCountdownOverlay } from "../countdownOverlay"; -import { isDiagnosticModeEnabled, mainLogBuffer } from "../diagnostics/main-log-buffer"; +import { isDiagnosticModeEnabled, mainLogFile } from "../diagnostics/main-log-file"; import { mainT } from "../i18n"; import { getInstallChannel } from "../install-channel"; import { @@ -1892,8 +1892,8 @@ async function resolveMediaLinksForVideo(videoPath: string): Promise<{ /** * Writes the diagnostic bundle a bug report needs: app/OS facts, the native * helpers' raw stdout/stderr (which is where `[stop-timing]` and - * `encoder-selection` land — see nativeWindowsCaptureStop.ts), and the main - * process's own recent console output. Shared by the renderer's IPC call and + * `encoder-selection` land — see nativeWindowsCaptureStop.ts), and the tail of the + * main process's log file, which reaches back into the previous launch. Shared by the renderer's IPC call and * the menu/tray "Save Diagnostics" entry point in main.ts, which has no * renderer-side `projectState`/`logs` to offer and does not need to. */ @@ -1912,6 +1912,7 @@ export async function exportDiagnosticFile(payload: { if (canceled || !filePath) return { success: false, canceled: true }; const HELPER_OUTPUT_MAX_BYTES = 64 * 1024; + const MAIN_PROCESS_LOGS_MAX_BYTES = 256 * 1024; const tail = (s: string, max: number) => (s.length <= max ? s : s.slice(s.length - max)); const diagnostic = { @@ -1938,7 +1939,9 @@ export async function exportDiagnosticFile(payload: { mac: tail(nativeMacCaptureOutput, HELPER_OUTPUT_MAX_BYTES), linux: tail(lastLinuxCaptureSession?.helperOutput ?? "", HELPER_OUTPUT_MAX_BYTES), }, - mainProcessLogs: mainLogBuffer.snapshot(), + mainProcessLogs: mainLogFile.readTail(MAIN_PROCESS_LOGS_MAX_BYTES), + logFile: mainLogFile.filePath, + logFileError: mainLogFile.lastError, }; try { diff --git a/electron/main.ts b/electron/main.ts index 43249d65ae..f42ce76b78 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -39,7 +39,7 @@ import { import { parseCliArgs } from "./cli/args"; import { runCli } from "./cli/cliMain"; import { disableHttpCacheForDevServer } from "./dev-http-cache"; -import { mainLogBuffer } from "./diagnostics/main-log-buffer"; +import { mainLogFile } from "./diagnostics/main-log-file"; import { buildEditMenuSubmenu, type EditorUndoRedoChannel, routeEditorUndoRedo } from "./edit-menu"; import { loadAndRegisterGlobalShortcut, @@ -96,15 +96,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // HUD/tray/menu. Parsed before any GUI side effects; see electron/cli/. const cliCommand = parseCliArgs(process.argv, app.isPackaged ? 1 : 2); -// On every app launch, not only under OPENSCREEN_DIAGNOSTIC: these lines are the -// `mainProcessLogs` of "Save Diagnostics", and a user who saves a bundle from the tray -// started the app where no such variable can be set. Installed here, before anything -// below logs, rather than once the app is ready. Never in a CLI run, whose stdout is -// its own protocol. -if (!cliCommand) { - mainLogBuffer.install(); -} - // Use Screen & System Audio Recording permissions instead of the CoreAudio Tap API on macOS. // Tap needs NSAudioCaptureUsageDescription in the parent app's Info.plist, which breaks when // running from a terminal/IDE during dev. @@ -214,6 +205,15 @@ function showMainWindow() { // profile: runCli gives each run its own sessionData (see cli/sessionData.ts). const hasSingleInstanceLock = cliCommand ? false : app.requestSingleInstanceLock(); +// On every app launch, not only under OPENSCREEN_DIAGNOSTIC: a user who saves a +// bundle from the tray started the app where no such variable can be set. Only the +// instance holding the lock writes, so a second launch never rotates the file under +// the running one, and never in a CLI run, whose stdout is its own protocol. +if (hasSingleInstanceLock) { + app.setAppLogsPath(); + mainLogFile.install(app.getPath("logs"), `launch ${app.getVersion()} pid ${process.pid}`); +} + if (cliCommand) { runCli(cliCommand); } else if (hasSingleInstanceLock) { @@ -1234,6 +1234,7 @@ app.on("before-quit", (event) => { app.on("will-quit", () => { unregisterAllGlobalShortcuts(); + mainLogFile.flush(); }); const appReady = !cliCommand && hasSingleInstanceLock ? app.whenReady() : null; diff --git a/electron/stt/index.ts b/electron/stt/index.ts index 347d67ca52..36fea8ec79 100644 --- a/electron/stt/index.ts +++ b/electron/stt/index.ts @@ -457,8 +457,8 @@ export class SttManager { untimedChunks++; } const runRtf = audioSec > 0 ? elapsedSec / audioSec : undefined; - // Through `console.*` on purpose: that is what the main-process ring - // buffer wraps (electron/diagnostics/main-log-buffer.ts), so these lines + // Through `console.*` on purpose: that is what the main-process log file + // wraps (electron/diagnostics/main-log-file.ts), so these lines // reach "Save Diagnostics". The helper's own stderr is forwarded with // `process.stderr.write` in whisperServer.ts and never lands there. console.info( diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 340df14803..a1ecc64e4c 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -97,7 +97,7 @@ gh run download -R getopenscreen/openscreen -n openscreen-windows-x64 - AppImage: `chmod +x Openscreen-Linux-.AppImage`, then run it; add `--no-sandbox` if Chromium's sandbox cannot start. A package: install it with the distribution's tool (`sudo apt install ./Openscreen-Linux-.deb`, `sudo dnf install ./Openscreen-Linux-.rpm`, `sudo pacman -U Openscreen-Linux-.pacman`), then run `openscreen`. - No flag is needed. Which name to grant is **to confirm on the first run**: a package installs a desktop entry, an AppImage has none until it is integrated. -**Logs of a packaged build.** OpenScreen writes no log file. The main process logs to its standard output, so a launch from a terminal shows it there, `[content-protection] OFF for the HUD window` included; whether the Windows executable prints into the PowerShell that started it is **to confirm on the first run**. However it was launched, the app keeps the last 500 main-process lines, and tray → *Save Diagnostics* writes them to a JSON file of your choice (`mainProcessLogs`), with the capture helper's recent output (`helperOutput.windows`, `.mac` or `.linux`). It leaves the open project out; a project file is in `userData/projects`. The renderer's `console` calls are stripped from packaged builds. Recordings and their `.cursor.json` and `.session.json` sidecars are in the `recordings` folder of `%APPDATA%\Openscreen`, `~/Library/Application Support/Openscreen` or `~/.config/Openscreen`. +**Logs of a packaged build.** The main process logs to its standard output, so a launch from a terminal shows it there, `[content-protection] OFF for the HUD window` included; whether the Windows executable prints into the PowerShell that started it is **to confirm on the first run**. However it was launched, the same lines are appended to `main.log` in `~/Library/Logs/Openscreen`, `%APPDATA%\Openscreen\logs` or `~/.config/Openscreen/logs`, one `--- launch pid ---` header per launch. Past 5 MiB it becomes `main.old.log` and starts over, so the folder never holds more than two files. Tray → *Save Diagnostics* writes the last 256 KiB of them to a JSON file of your choice (`mainProcessLogs`, with `logFile` and, if the file could not be written, `logFileError`), with the capture helper's recent output (`helperOutput.windows`, `.mac` or `.linux`). It leaves the open project out; a project file is in `userData/projects`. The renderer's `console` calls are stripped from packaged builds. Recordings and their `.cursor.json` and `.session.json` sidecars are in the `recordings` folder of `%APPDATA%\Openscreen`, `~/Library/Application Support/Openscreen` or `~/.config/Openscreen`. ## Launch and HUD From aff58781185150eae8200fd71d8913eb781d557f Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Thu, 22 Oct 2026 10:46:51 -0400 Subject: [PATCH 2/4] fix(diagnostics): install the log file before module-load logging Take the single-instance lock right after parsing the CLI args so the log file catches the lines logged while main.ts loads. Give an instance on a custom userData its own logs folder on macOS. Report a failed tail read as logFileError instead of failing Save Diagnostics, and use the lowercase Linux config folder in the checklist. --- electron/diagnostics/main-log-file.test.ts | 14 ++++++++ electron/diagnostics/main-log-file.ts | 35 ++++++++++++------ electron/main.ts | 36 +++++++++++-------- .../testing/manual-e2e-checklist.md | 2 +- 4 files changed, 62 insertions(+), 25 deletions(-) diff --git a/electron/diagnostics/main-log-file.test.ts b/electron/diagnostics/main-log-file.test.ts index 1321318d4a..b714863c40 100644 --- a/electron/diagnostics/main-log-file.test.ts +++ b/electron/diagnostics/main-log-file.test.ts @@ -150,6 +150,20 @@ describe("MainLogFile", () => { expect(lines()).toHaveLength(1); }); + it("reports a failed read and keeps writing", () => { + silenceConsole(); + const log = make(); + log.install(dir, "launch"); + vi.spyOn(fs, "readSync").mockImplementationOnce(() => { + throw Object.assign(new Error("EIO: i/o error, read"), { code: "EIO" }); + }); + + expect(log.readTail(1000)).toBe(""); + expect(log.lastError).toMatch(/^EIO/); + console.info("after"); + expect(lines()).toHaveLength(2); + }); + it("uninstall restores the console methods", () => { const captured = silenceConsole(); const original = console.info; diff --git a/electron/diagnostics/main-log-file.ts b/electron/diagnostics/main-log-file.ts index cd3941a390..926f02f3c9 100644 --- a/electron/diagnostics/main-log-file.ts +++ b/electron/diagnostics/main-log-file.ts @@ -110,13 +110,21 @@ export class MainLogFile { readTail(maxBytes: number): string { if (!this.dir) return ""; this.flush(); - const current = readLastBytes(path.join(this.dir, LOG_FILE), maxBytes); - let data = current.data; - let whole = current.whole; - if (current.whole && data.length < maxBytes) { - const old = readLastBytes(path.join(this.dir, OLD_LOG_FILE), maxBytes - data.length); - data = Buffer.concat([old.data, data]); - whole = old.whole; + let data: Buffer; + let whole: boolean; + try { + const current = readLastBytes(path.join(this.dir, LOG_FILE), maxBytes); + data = current.data; + whole = current.whole; + if (current.whole && data.length < maxBytes) { + const old = readLastBytes(path.join(this.dir, OLD_LOG_FILE), maxBytes - data.length); + data = Buffer.concat([old.data, data]); + whole = old.whole; + } + } catch (error) { + // Reported in the bundle as `logFileError`; writing carries on. + this.error = describeError(error); + return ""; } const text = data.toString("utf-8"); return whole ? text : text.slice(text.indexOf("\n") + 1); @@ -216,7 +224,7 @@ export class MainLogFile { private fail(error: unknown): void { this.close(); - this.error = error instanceof Error ? error.message : String(error); + this.error = describeError(error); } private close(): void { @@ -230,6 +238,10 @@ export class MainLogFile { } } +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function formatArgs(args: unknown[]): string { return args .map((arg) => { @@ -250,8 +262,11 @@ function readLastBytes(file: string, maxBytes: number): { data: Buffer; whole: b let fd: number; try { fd = fs.openSync(file, "r"); - } catch { - return { data: Buffer.alloc(0), whole: true }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { data: Buffer.alloc(0), whole: true }; + } + throw error; } try { const size = fs.fstatSync(fd).size; diff --git a/electron/main.ts b/electron/main.ts index f42ce76b78..3932f47fb6 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -96,6 +96,28 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // HUD/tray/menu. Parsed before any GUI side effects; see electron/cli/. const cliCommand = parseCliArgs(process.argv, app.isPackaged ? 1 : 2); +// CLI runs skip the single-instance lock so `openscreen export/record` works +// while the GUI app is open. They share its userData, but not its Chromium +// profile: runCli gives each run its own sessionData (see cli/sessionData.ts). +const hasSingleInstanceLock = cliCommand ? false : app.requestSingleInstanceLock(); + +// On every app launch, not only under OPENSCREEN_DIAGNOSTIC: a user who saves a +// bundle from the tray started the app where no such variable can be set. Installed +// here, before anything below logs. Only the instance holding the lock writes, so a +// second launch never rotates the file under the running one, and never in a CLI run, +// whose stdout is its own protocol. +if (hasSingleInstanceLock) { + // The lock is per userData, but macOS's default logs folder is per app name: an + // instance on another userData gets its own folder, or two would share one file. + const userData = app.getPath("userData"); + if (userData === path.join(app.getPath("appData"), app.getName())) { + app.setAppLogsPath(); + } else { + app.setAppLogsPath(path.join(userData, "logs")); + } + mainLogFile.install(app.getPath("logs"), `launch ${app.getVersion()} pid ${process.pid}`); +} + // Use Screen & System Audio Recording permissions instead of the CoreAudio Tap API on macOS. // Tap needs NSAudioCaptureUsageDescription in the parent app's Info.plist, which breaks when // running from a terminal/IDE during dev. @@ -200,20 +222,6 @@ function showMainWindow() { createWindow(); } -// CLI runs skip the single-instance lock so `openscreen export/record` works -// while the GUI app is open. They share its userData, but not its Chromium -// profile: runCli gives each run its own sessionData (see cli/sessionData.ts). -const hasSingleInstanceLock = cliCommand ? false : app.requestSingleInstanceLock(); - -// On every app launch, not only under OPENSCREEN_DIAGNOSTIC: a user who saves a -// bundle from the tray started the app where no such variable can be set. Only the -// instance holding the lock writes, so a second launch never rotates the file under -// the running one, and never in a CLI run, whose stdout is its own protocol. -if (hasSingleInstanceLock) { - app.setAppLogsPath(); - mainLogFile.install(app.getPath("logs"), `launch ${app.getVersion()} pid ${process.pid}`); -} - if (cliCommand) { runCli(cliCommand); } else if (hasSingleInstanceLock) { diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index a1ecc64e4c..c90978e209 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -97,7 +97,7 @@ gh run download -R getopenscreen/openscreen -n openscreen-windows-x64 - AppImage: `chmod +x Openscreen-Linux-.AppImage`, then run it; add `--no-sandbox` if Chromium's sandbox cannot start. A package: install it with the distribution's tool (`sudo apt install ./Openscreen-Linux-.deb`, `sudo dnf install ./Openscreen-Linux-.rpm`, `sudo pacman -U Openscreen-Linux-.pacman`), then run `openscreen`. - No flag is needed. Which name to grant is **to confirm on the first run**: a package installs a desktop entry, an AppImage has none until it is integrated. -**Logs of a packaged build.** The main process logs to its standard output, so a launch from a terminal shows it there, `[content-protection] OFF for the HUD window` included; whether the Windows executable prints into the PowerShell that started it is **to confirm on the first run**. However it was launched, the same lines are appended to `main.log` in `~/Library/Logs/Openscreen`, `%APPDATA%\Openscreen\logs` or `~/.config/Openscreen/logs`, one `--- launch pid ---` header per launch. Past 5 MiB it becomes `main.old.log` and starts over, so the folder never holds more than two files. Tray → *Save Diagnostics* writes the last 256 KiB of them to a JSON file of your choice (`mainProcessLogs`, with `logFile` and, if the file could not be written, `logFileError`), with the capture helper's recent output (`helperOutput.windows`, `.mac` or `.linux`). It leaves the open project out; a project file is in `userData/projects`. The renderer's `console` calls are stripped from packaged builds. Recordings and their `.cursor.json` and `.session.json` sidecars are in the `recordings` folder of `%APPDATA%\Openscreen`, `~/Library/Application Support/Openscreen` or `~/.config/Openscreen`. +**Logs of a packaged build.** The main process logs to its standard output, so a launch from a terminal shows it there, `[content-protection] OFF for the HUD window` included; whether the Windows executable prints into the PowerShell that started it is **to confirm on the first run**. However it was launched, the same lines are appended to `main.log` in `~/Library/Logs/Openscreen`, `%APPDATA%\Openscreen\logs` or `~/.config/openscreen/logs`, one `--- launch pid ---` header per launch. Past 5 MiB it becomes `main.old.log` and starts over, so the folder never holds more than two files. Tray → *Save Diagnostics* writes the last 256 KiB of them to a JSON file of your choice (`mainProcessLogs`, with `logFile` and, if the file could not be written, `logFileError`), with the capture helper's recent output (`helperOutput.windows`, `.mac` or `.linux`). It leaves the open project out; a project file is in `userData/projects`. The renderer's `console` calls are stripped from packaged builds. Recordings and their `.cursor.json` and `.session.json` sidecars are in the `recordings` folder of `%APPDATA%\Openscreen`, `~/Library/Application Support/Openscreen` or `~/.config/openscreen`. ## Launch and HUD From bb461ff590de03c19fdaae54f4db0cc631934f57 Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Thu, 22 Oct 2026 21:22:06 -0400 Subject: [PATCH 3/4] feat(diagnostics): save the bundle as a zip with the whole log files Save Diagnostics now writes a .zip holding diagnostic.json and main.log (plus main.old.log once it has rotated), instead of only the JSON with a 256 KiB tail. The zip is written with node:zlib, so no new dependency. --- electron/diagnostics/main-log-file.test.ts | 23 ++++++ electron/diagnostics/main-log-file.ts | 17 +++++ electron/diagnostics/zip.test.ts | 72 +++++++++++++++++++ electron/diagnostics/zip.ts | 63 ++++++++++++++++ electron/ipc/handlers.ts | 19 +++-- .../testing/manual-e2e-checklist.md | 2 +- 6 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 electron/diagnostics/zip.test.ts create mode 100644 electron/diagnostics/zip.ts diff --git a/electron/diagnostics/main-log-file.test.ts b/electron/diagnostics/main-log-file.test.ts index b714863c40..1ffe326c7b 100644 --- a/electron/diagnostics/main-log-file.test.ts +++ b/electron/diagnostics/main-log-file.test.ts @@ -150,6 +150,29 @@ describe("MainLogFile", () => { expect(lines()).toHaveLength(1); }); + it("readFiles returns both files whole, oldest first", () => { + silenceConsole(); + const log = make(); + fs.writeFileSync(path.join(dir, "main.old.log"), "old\n"); + log.install(dir, "launch"); + console.info("new"); + + const files = log.readFiles(); + expect(files.map((f) => f.name)).toEqual(["main.old.log", "main.log"]); + expect(files[0].data.toString()).toBe("old\n"); + expect(files[1].data.toString()).toMatch(/--- launch ---\n.* info new\n$/); + expect(log.lastError).toBeNull(); + }); + + it("readFiles leaves out a missing file without an error", () => { + silenceConsole(); + const log = make(); + log.install(dir, "launch"); + + expect(log.readFiles().map((f) => f.name)).toEqual(["main.log"]); + expect(log.lastError).toBeNull(); + }); + it("reports a failed read and keeps writing", () => { silenceConsole(); const log = make(); diff --git a/electron/diagnostics/main-log-file.ts b/electron/diagnostics/main-log-file.ts index 926f02f3c9..d366ba27c7 100644 --- a/electron/diagnostics/main-log-file.ts +++ b/electron/diagnostics/main-log-file.ts @@ -130,6 +130,23 @@ export class MainLogFile { return whole ? text : text.slice(text.indexOf("\n") + 1); } + /** `main.old.log` and `main.log` whole, for the zipped bundle; a missing one is left out. */ + readFiles(): { name: string; data: Buffer }[] { + if (!this.dir) return []; + this.flush(); + const files: { name: string; data: Buffer }[] = []; + for (const name of [OLD_LOG_FILE, LOG_FILE]) { + try { + files.push({ name, data: fs.readFileSync(path.join(this.dir, name)) }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + this.error = describeError(error); + } + } + } + return files; + } + private record(level: Level, rawText: string): void { if (this.fd === null) return; const now = Date.now(); diff --git a/electron/diagnostics/zip.test.ts b/electron/diagnostics/zip.test.ts new file mode 100644 index 0000000000..65ca6324e9 --- /dev/null +++ b/electron/diagnostics/zip.test.ts @@ -0,0 +1,72 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import zlib from "node:zlib"; +import { describe, expect, it } from "vitest"; +import { createZip } from "./zip"; + +/** Reads the archive back through its central directory, the way an unzip tool does. */ +function readZip(zip: Buffer) { + const end = zip.length - 22; + expect(zip.readUInt32LE(end)).toBe(0x06054b50); + const count = zip.readUInt16LE(end + 10); + let at = zip.readUInt32LE(end + 16); + const files: Record = {}; + for (let i = 0; i < count; i++) { + expect(zip.readUInt32LE(at)).toBe(0x02014b50); + const crc = zip.readUInt32LE(at + 16); + const size = zip.readUInt32LE(at + 20); + const nameLength = zip.readUInt16LE(at + 28); + const local = zip.readUInt32LE(at + 42); + const name = zip.toString("utf-8", at + 46, at + 46 + nameLength); + expect(zip.readUInt32LE(local)).toBe(0x04034b50); + const start = local + 30 + zip.readUInt16LE(local + 26); + const data = zlib.inflateRawSync(zip.subarray(start, start + size)); + expect(zlib.crc32(data)).toBe(crc); + files[name] = data.toString("utf-8"); + at += 46 + nameLength; + } + return files; +} + +describe("createZip", () => { + it("round-trips every entry", () => { + const big = "line\n".repeat(100_000); + const zip = createZip([ + { name: "diagnostic.json", data: Buffer.from('{"a":1}') }, + { name: "main.log", data: Buffer.from(big) }, + { name: "empty.log", data: Buffer.alloc(0) }, + { name: "é.txt", data: Buffer.from("ü") }, + ]); + + expect(readZip(zip)).toEqual({ + "diagnostic.json": '{"a":1}', + "main.log": big, + "empty.log": "", + "é.txt": "ü", + }); + expect(zip.length).toBeLessThan(big.length / 10); + }); + + it("opens with the system unzip, where there is one", () => { + let unzip = true; + try { + execFileSync("unzip", ["-v"], { stdio: "ignore" }); + } catch { + unzip = false; + } + if (!unzip) return; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-zip-")); + try { + const file = path.join(dir, "bundle.zip"); + fs.writeFileSync(file, createZip([{ name: "main.log", data: Buffer.from("hello\n") }])); + execFileSync("unzip", ["-tq", file], { stdio: "ignore" }); + expect(execFileSync("unzip", ["-p", file, "main.log"], { encoding: "utf-8" })).toBe( + "hello\n", + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/electron/diagnostics/zip.ts b/electron/diagnostics/zip.ts new file mode 100644 index 0000000000..b1ab4ac07c --- /dev/null +++ b/electron/diagnostics/zip.ts @@ -0,0 +1,63 @@ +import zlib from "node:zlib"; + +export interface ZipEntry { + name: string; + data: Buffer; +} + +/** + * A deflated `.zip` of `entries`, for the diagnostic bundle. Only what that needs: no + * folders, no ZIP64 (every entry and the whole archive stay under 4 GiB), UTF-8 names. + */ +export function createZip(entries: ZipEntry[], date = new Date()): Buffer { + const time = (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1); + const day = ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); + const locals: Buffer[] = []; + const centrals: Buffer[] = []; + let offset = 0; + + for (const entry of entries) { + const name = Buffer.from(entry.name, "utf-8"); + const compressed = zlib.deflateRawSync(entry.data); + const crc = zlib.crc32(entry.data); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); // version needed: deflate + local.writeUInt16LE(0x0800, 6); // UTF-8 name + local.writeUInt16LE(8, 8); // deflate + local.writeUInt16LE(time, 10); + local.writeUInt16LE(day, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(compressed.length, 18); + local.writeUInt32LE(entry.data.length, 22); + local.writeUInt16LE(name.length, 26); + locals.push(local, name, compressed); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); // version made by + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(8, 10); + central.writeUInt16LE(time, 12); + central.writeUInt16LE(day, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(compressed.length, 20); + central.writeUInt32LE(entry.data.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt32LE(offset, 42); + centrals.push(central, name); + + offset += local.length + name.length + compressed.length; + } + + const directory = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, directory, end]); +} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 50bd0a5cb5..0dc546dd86 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -72,6 +72,7 @@ import { AppSettingsStore } from "../app-settings"; import { createRunnerHost } from "../cli/runnerHost"; import { hideCountdownOverlay } from "../countdownOverlay"; import { isDiagnosticModeEnabled, mainLogFile } from "../diagnostics/main-log-file"; +import { createZip } from "../diagnostics/zip"; import { mainT } from "../i18n"; import { getInstallChannel } from "../install-channel"; import { @@ -1890,10 +1891,11 @@ async function resolveMediaLinksForVideo(videoPath: string): Promise<{ } /** - * Writes the diagnostic bundle a bug report needs: app/OS facts, the native - * helpers' raw stdout/stderr (which is where `[stop-timing]` and - * `encoder-selection` land — see nativeWindowsCaptureStop.ts), and the tail of the - * main process's log file, which reaches back into the previous launch. Shared by the renderer's IPC call and + * Writes the diagnostic bundle a bug report needs, as a `.zip`: `diagnostic.json` with + * app/OS facts, the native helpers' raw stdout/stderr (which is where `[stop-timing]` and + * `encoder-selection` land — see nativeWindowsCaptureStop.ts) and the tail of the main + * process's log, next to that log's files whole (`main.log`, and `main.old.log` once it + * has rotated), which reach back into earlier launches. Shared by the renderer's IPC call and * the menu/tray "Save Diagnostics" entry point in main.ts, which has no * renderer-side `projectState`/`logs` to offer and does not need to. */ @@ -1905,8 +1907,8 @@ export async function exportDiagnosticFile(payload: { }) { const { filePath, canceled } = await dialog.showSaveDialog({ title: "Save Diagnostic File", - defaultPath: `openscreen-diagnostic-${Date.now()}.json`, - filters: [{ name: "JSON", extensions: ["json"] }], + defaultPath: `openscreen-diagnostic-${Date.now()}.zip`, + filters: [{ name: "ZIP", extensions: ["zip"] }], }); if (canceled || !filePath) return { success: false, canceled: true }; @@ -1914,6 +1916,8 @@ export async function exportDiagnosticFile(payload: { const HELPER_OUTPUT_MAX_BYTES = 64 * 1024; const MAIN_PROCESS_LOGS_MAX_BYTES = 256 * 1024; const tail = (s: string, max: number) => (s.length <= max ? s : s.slice(s.length - max)); + // Read before `logFileError` below, so a failed read is reported in the same bundle. + const logFiles = mainLogFile.readFiles(); const diagnostic = { timestamp: new Date().toISOString(), @@ -1945,7 +1949,8 @@ export async function exportDiagnosticFile(payload: { }; try { - await fs.writeFile(filePath, JSON.stringify(diagnostic, null, 2), "utf-8"); + const json = Buffer.from(JSON.stringify(diagnostic, null, 2), "utf-8"); + await fs.writeFile(filePath, createZip([{ name: "diagnostic.json", data: json }, ...logFiles])); return { success: true, path: filePath }; } catch (error) { console.error("Failed to write diagnostic file:", error); diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index c90978e209..c9d0829b8f 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -97,7 +97,7 @@ gh run download -R getopenscreen/openscreen -n openscreen-windows-x64 - AppImage: `chmod +x Openscreen-Linux-.AppImage`, then run it; add `--no-sandbox` if Chromium's sandbox cannot start. A package: install it with the distribution's tool (`sudo apt install ./Openscreen-Linux-.deb`, `sudo dnf install ./Openscreen-Linux-.rpm`, `sudo pacman -U Openscreen-Linux-.pacman`), then run `openscreen`. - No flag is needed. Which name to grant is **to confirm on the first run**: a package installs a desktop entry, an AppImage has none until it is integrated. -**Logs of a packaged build.** The main process logs to its standard output, so a launch from a terminal shows it there, `[content-protection] OFF for the HUD window` included; whether the Windows executable prints into the PowerShell that started it is **to confirm on the first run**. However it was launched, the same lines are appended to `main.log` in `~/Library/Logs/Openscreen`, `%APPDATA%\Openscreen\logs` or `~/.config/openscreen/logs`, one `--- launch pid ---` header per launch. Past 5 MiB it becomes `main.old.log` and starts over, so the folder never holds more than two files. Tray → *Save Diagnostics* writes the last 256 KiB of them to a JSON file of your choice (`mainProcessLogs`, with `logFile` and, if the file could not be written, `logFileError`), with the capture helper's recent output (`helperOutput.windows`, `.mac` or `.linux`). It leaves the open project out; a project file is in `userData/projects`. The renderer's `console` calls are stripped from packaged builds. Recordings and their `.cursor.json` and `.session.json` sidecars are in the `recordings` folder of `%APPDATA%\Openscreen`, `~/Library/Application Support/Openscreen` or `~/.config/openscreen`. +**Logs of a packaged build.** The main process logs to its standard output, so a launch from a terminal shows it there, `[content-protection] OFF for the HUD window` included; whether the Windows executable prints into the PowerShell that started it is **to confirm on the first run**. However it was launched, the same lines are appended to `main.log` in `~/Library/Logs/Openscreen`, `%APPDATA%\Openscreen\logs` or `~/.config/openscreen/logs`, one `--- launch pid ---` header per launch. Past 5 MiB it becomes `main.old.log` and starts over, so the folder never holds more than two files. Tray → *Save Diagnostics* writes a `.zip` of your choice: both log files whole, and `diagnostic.json` with the last 256 KiB of them (`mainProcessLogs`, with `logFile` and, if the file could not be written or read, `logFileError`) and the capture helper's recent output (`helperOutput.windows`, `.mac` or `.linux`). It leaves the open project out; a project file is in `userData/projects`. The renderer's `console` calls are stripped from packaged builds. Recordings and their `.cursor.json` and `.session.json` sidecars are in the `recordings` folder of `%APPDATA%\Openscreen`, `~/Library/Application Support/Openscreen` or `~/.config/openscreen`. ## Launch and HUD From ac92fafd42ea1fb79a3bd008489bb506e6444926 Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Thu, 22 Oct 2026 21:30:00 -0400 Subject: [PATCH 4/4] docs(diagnostics): list where main.log is written per platform and build --- .../architecture/transcription-and-captions.md | 2 +- .../testing/manual-e2e-checklist.md | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/technical-documentation/architecture/transcription-and-captions.md b/technical-documentation/architecture/transcription-and-captions.md index 00c425d65d..9eed8e93b3 100644 --- a/technical-documentation/architecture/transcription-and-captions.md +++ b/technical-documentation/architecture/transcription-and-captions.md @@ -627,7 +627,7 @@ is what lets the media surfaces render "Transcribing 45% · CPU · 0.9×" while the work is still going. `SttManager` logs the same pair per chunk through `console.*` — the channel -the main-process ring buffer wraps, so the lines reach "Save Diagnostics", +the main-process log file records, so the lines reach "Save Diagnostics", which the helper's own stderr does not — and warns once per run when the backend is `whispercpp-cpu`. That warning exists because the CPU path costs roughly half the throughput (median 2.07×, see the POC report) and is only diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index c9d0829b8f..ff8667bc52 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -97,7 +97,17 @@ gh run download -R getopenscreen/openscreen -n openscreen-windows-x64 - AppImage: `chmod +x Openscreen-Linux-.AppImage`, then run it; add `--no-sandbox` if Chromium's sandbox cannot start. A package: install it with the distribution's tool (`sudo apt install ./Openscreen-Linux-.deb`, `sudo dnf install ./Openscreen-Linux-.rpm`, `sudo pacman -U Openscreen-Linux-.pacman`), then run `openscreen`. - No flag is needed. Which name to grant is **to confirm on the first run**: a package installs a desktop entry, an AppImage has none until it is integrated. -**Logs of a packaged build.** The main process logs to its standard output, so a launch from a terminal shows it there, `[content-protection] OFF for the HUD window` included; whether the Windows executable prints into the PowerShell that started it is **to confirm on the first run**. However it was launched, the same lines are appended to `main.log` in `~/Library/Logs/Openscreen`, `%APPDATA%\Openscreen\logs` or `~/.config/openscreen/logs`, one `--- launch pid ---` header per launch. Past 5 MiB it becomes `main.old.log` and starts over, so the folder never holds more than two files. Tray → *Save Diagnostics* writes a `.zip` of your choice: both log files whole, and `diagnostic.json` with the last 256 KiB of them (`mainProcessLogs`, with `logFile` and, if the file could not be written or read, `logFileError`) and the capture helper's recent output (`helperOutput.windows`, `.mac` or `.linux`). It leaves the open project out; a project file is in `userData/projects`. The renderer's `console` calls are stripped from packaged builds. Recordings and their `.cursor.json` and `.session.json` sidecars are in the `recordings` folder of `%APPDATA%\Openscreen`, `~/Library/Application Support/Openscreen` or `~/.config/openscreen`. +**Logs of a packaged build.** The main process logs to its standard output, so a launch from a terminal shows it there, `[content-protection] OFF for the HUD window` included; whether the Windows executable prints into the PowerShell that started it is **to confirm on the first run**. However it was launched, the same lines are appended to `main.log` in the folder below, one `--- launch pid ---` header per launch. Past 5 MiB it becomes `main.old.log` and starts over, so the folder never holds more than two files. Tray → *Save Diagnostics* writes a `.zip` of your choice: both log files whole, and `diagnostic.json` with the last 256 KiB of them (`mainProcessLogs`, with `logFile` and, if the file could not be written or read, `logFileError`) and the capture helper's recent output (`helperOutput.windows`, `.mac` or `.linux`). It leaves the open project out; a project file is in `userData/projects`. The renderer's `console` calls are stripped from packaged builds. Recordings and their `.cursor.json` and `.session.json` sidecars are in the `recordings` folder of `%APPDATA%\Openscreen`, `~/Library/Application Support/Openscreen` or `~/.config/openscreen`. + +Where `main.log` is written (a diagnostic bundle's `logFile` names the exact path): + +| Platform | Packaged build | Dev build (`npm run dev`) | +|---|---|---| +| macOS | `~/Library/Logs/Openscreen` | `~/Library/Logs/Electron`: macOS names the folder after the app bundle, `Electron.app` in dev | +| Windows | `%APPDATA%\Openscreen\logs` | `%APPDATA%\openscreen\logs` | +| Linux | `~/.config/openscreen/logs` (case to confirm on a real install) | `~/.config/openscreen/logs` | + +An instance started on another userData folder (`--user-data-dir`) writes to `/logs` instead, on every platform. ## Launch and HUD