diff --git a/src/main/browser-manager.ts b/src/main/browser-manager.ts index 44aa1bf6..5514ff73 100644 --- a/src/main/browser-manager.ts +++ b/src/main/browser-manager.ts @@ -1,6 +1,6 @@ import { BrowserWindow, session } from "electron"; import { existsSync, mkdirSync } from "node:fs"; -import { rm } from "node:fs/promises"; +import { readdir, rm, stat } from "node:fs/promises"; import { join, resolve, sep } from "node:path"; import log from "electron-log/main.js"; import type { BrowserAction, BrowserActionResult, QueuedAction } from "./browser/browser-types"; @@ -17,6 +17,7 @@ export type { BrowserAction, BrowserActionResult } from "./browser/browser-types * legitimate flow (first-time OAuth with 2FA). */ const ACTION_TIMEOUT_MS = 5 * 60 * 1000; +const SCREENSHOT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; export class BrowserManager { private screenshotDir: string; @@ -30,6 +31,25 @@ export class BrowserManager { constructor(profileDir: string) { this.screenshotDir = join(profileDir, "browser-screenshots"); if (!existsSync(this.screenshotDir)) mkdirSync(this.screenshotDir, { recursive: true }); + void this.pruneExpiredScreenshots(); + } + + private async pruneExpiredScreenshots() { + const cutoff = Date.now() - SCREENSHOT_RETENTION_MS; + try { + const entries = await readdir(this.screenshotDir, { withFileTypes: true }); + await Promise.all(entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".png")) + .map(async (entry) => { + const screenshotPath = join(this.screenshotDir, entry.name); + const fileStat = await stat(screenshotPath); + if (fileStat.mtimeMs < cutoff) { + await rm(screenshotPath, { force: true }); + } + })); + } catch (error) { + log.warn(`[browser] Failed to prune expired screenshots: ${error instanceof Error ? error.message : String(error)}`); + } } private getSession(socialAccountId: string): Electron.Session { diff --git a/src/main/browser/browser-action-executor.ts b/src/main/browser/browser-action-executor.ts index 24914756..c7686e9b 100644 --- a/src/main/browser/browser-action-executor.ts +++ b/src/main/browser/browser-action-executor.ts @@ -16,6 +16,7 @@ import { assertAuthenticatedPage, assertAuthenticatedSession } from "./browser-a import { getPlatformSelectors } from "./platform-selectors"; import type { PlatformSelectors } from "./platform-selectors"; import { browserVerificationFailed } from "./browser-action-errors"; +import { shouldCaptureSuccessScreenshot } from "./browser-screenshot-policy"; import { isAppError } from "../errors/index.js"; export { getPlatformRules, assertAllowedUrl, attachWindowSecurity } from "./browser-url-helpers"; @@ -226,7 +227,9 @@ export async function runAction( } assertNotCancelled(); - await captureScreenshot(win, screenshotPath, action.id, "success"); + const successScreenshotPath = shouldCaptureSuccessScreenshot(action) + ? await captureScreenshot(win, screenshotPath, action.id, "success") + : null; await closeWindow(win); logActionTelemetry({ platform, @@ -241,13 +244,15 @@ export async function runAction( success: true, cancelled: false, summary, - screenshotPath: existsSync(screenshotPath) ? screenshotPath : null, + screenshotPath: successScreenshotPath, error: null, }; } catch (error) { const wasCancelled = cancelledActionIds.has(action.id) || (error instanceof Error && error.message === "BROWSER_ACTION_CANCELLED"); - await captureScreenshot(win, screenshotPath, action.id, "failure"); + const failureScreenshotPath = wasCancelled + ? null + : await captureScreenshot(win, screenshotPath, action.id, "failure"); await closeWindow(win); const msg = wasCancelled ? "Action cancelled." : describeBrowserActionError(error); @@ -268,7 +273,7 @@ export async function runAction( success: false, cancelled: wasCancelled, summary: wasCancelled ? "Action cancelled" : "Action failed", - screenshotPath: existsSync(screenshotPath) ? screenshotPath : null, + screenshotPath: failureScreenshotPath, error: msg, }; } finally { @@ -316,15 +321,17 @@ async function captureScreenshot( screenshotPath: string, actionId: string, phase: "success" | "failure", -): Promise { +): Promise { try { const image: NativeImage = await win.webContents.capturePage(); if (!image.isEmpty()) { writeFileSync(screenshotPath, image.toPNG()); + return existsSync(screenshotPath) ? screenshotPath : null; } } catch (err) { log.warn(`[browser] ${phase} screenshot failed for action ${actionId}: ${err instanceof Error ? err.message : String(err)}`); } + return null; } async function closeWindow(win: BrowserWindow): Promise { diff --git a/src/main/browser/browser-screenshot-policy.ts b/src/main/browser/browser-screenshot-policy.ts new file mode 100644 index 00000000..ffc34b5b --- /dev/null +++ b/src/main/browser/browser-screenshot-policy.ts @@ -0,0 +1,7 @@ +import type { BrowserAction } from "./browser-types"; + +export function shouldCaptureSuccessScreenshot(action: BrowserAction): boolean { + return action.actionType === "screenshot" + || action.payload.captureSuccessScreenshot === true + || action.payload.retainSuccessScreenshot === true; +} diff --git a/tests/unit/browser-action-runtime.test.ts b/tests/unit/browser-action-runtime.test.ts index c9904a08..b16b559a 100644 --- a/tests/unit/browser-action-runtime.test.ts +++ b/tests/unit/browser-action-runtime.test.ts @@ -4,6 +4,7 @@ import { assertAuthenticatedPage, assertAuthenticatedSession, } from "../../src/main/browser/browser-action-auth"; +import { shouldCaptureSuccessScreenshot } from "../../src/main/browser/browser-screenshot-policy"; import { getPlatformSelectors } from "../../src/main/browser/platform-selectors"; function fakeSession(cookiesByOrigin: Record): Pick { @@ -62,4 +63,30 @@ describe("browser action runtime guards", () => { expect(linkedin.composerOpen).toContain("button.share-box-feed-entry__trigger"); expect(linkedin.editor).toContain(".ql-editor[contenteditable='true']"); }); + + it("captures successful screenshots only for explicit screenshot requests", () => { + expect(shouldCaptureSuccessScreenshot({ + id: "post-1", + socialAccountId: "social-1", + platform: "twitter", + actionType: "post", + payload: { content: "hello" }, + })).toBe(false); + + expect(shouldCaptureSuccessScreenshot({ + id: "screenshot-1", + socialAccountId: "social-1", + platform: "twitter", + actionType: "screenshot", + payload: {}, + })).toBe(true); + + expect(shouldCaptureSuccessScreenshot({ + id: "post-2", + socialAccountId: "social-1", + platform: "twitter", + actionType: "post", + payload: { content: "hello", captureSuccessScreenshot: true }, + })).toBe(true); + }); });