fix(browser): limit retained action screenshots

This commit is contained in:
ZacharyZhang-NY committed 2026-05-15 02:21:16 -04:00
1 parent b5a6a1a9ea
commit 9d4af15d4b
4 files changed
+67 -6

No files matched your search

+21 -1
View File
@@ -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 {
+12 -5
View File
@@ -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<void> {
): Promise<string | null> {
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<void> {
@@ -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;
}
+27
View File
@@ -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<string, unknown[]>): Pick<Session, "cookies"> {
@@ -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);
});
});