Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions src/main/capture/compositor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { BrowserWindow, nativeImage } from 'electron';
import { readFile, writeFile } from 'node:fs/promises';
import logger from '@main/logger';

export interface CompositeBackground {
type: 'image' | 'color';
/** When `type === 'image'`, absolute path. When `type === 'color'`, hex like `#0f172a`. */
value: string;
}

export interface CompositeOptions {
/** Path to the raw window screenshot from `screencapture -W -o`. */
inputPath: string;
/** Path the composited PNG should be written to. */
outputPath: string;
/** Background to render around the window. */
background: CompositeBackground;
/** DIPs of empty space between the window edge and the canvas edge. */
paddingPx: number;
}

const COMPOSITE_TIMEOUT_MS = 8_000;
const SHADOW_PX = 30; // dropped behind the window — visually closer to CleanShot's default

/**
* Composite a captured window onto a chosen background using a hidden
* `BrowserWindow` + `webContents.capturePage()`. The page is sized to
* `windowSize + 2 * padding` so capturePage returns the full canvas.
*
* Why not pure-Node compositing (sharp / jimp / nativeImage bitmap math)?
* Doing CSS-quality drop shadows in pure pixel code is annoying and adds
* native-build complexity. We already have Electron — using it as a
* compositor keeps the dep surface flat.
*/
export async function compositeWindowOnBackground(opts: CompositeOptions): Promise<void> {
const inputImg = nativeImage.createFromPath(opts.inputPath);
if (inputImg.isEmpty()) {
throw new Error(`compositor: empty image at ${opts.inputPath}`);
}
const winSize = inputImg.getSize();
const outW = winSize.width + 2 * opts.paddingPx;
const outH = winSize.height + 2 * opts.paddingPx;

// Load both images as data URLs so the renderer can show them without
// worrying about file:// + CSP.
const winDataUrl = `data:image/png;base64,${(await readFile(opts.inputPath)).toString('base64')}`;
let bgCss: string;
if (opts.background.type === 'color') {
bgCss = opts.background.value;
} else {
const bgBuf = await readFile(opts.background.value);
const bgB64 = bgBuf.toString('base64');
// Trust file extension — these come from the OS file dialog.
const ext = opts.background.value.split('.').pop()?.toLowerCase() ?? 'png';
const mime = ext === 'jpg' || ext === 'jpeg' ? 'jpeg' : ext === 'heic' ? 'heic' : 'png';
bgCss = `center / cover no-repeat url("data:image/${mime};base64,${bgB64}")`;
}

const html = buildHtml({
bgCss,
paddingPx: opts.paddingPx,
winSize,
outSize: { w: outW, h: outH },
winDataUrl,
});

const win = new BrowserWindow({
width: outW,
height: outH,
frame: false,
transparent: false,
resizable: false,
show: false,
skipTaskbar: true,
paintWhenInitiallyHidden: true,
useContentSize: true,
webPreferences: {
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
// Trusted local content (our own HTML + already-validated images).
// We need this off so the data: URL renders the embedded images
// without CSP fights.
webSecurity: false,

Check warning

Code scanning / CodeQL

Disabling Electron webSecurity Medium

Disabling webSecurity is strongly discouraged.
},
});

try {
await loadWithTimeout(win, html);
// Give the layout one more tick so the embedded image decodes + paints.
await new Promise((r) => setTimeout(r, 80));
const captured = await win.webContents.capturePage();
if (captured.isEmpty()) {
throw new Error('compositor: capturePage returned empty image');
}
await writeFile(opts.outputPath, captured.toPNG());
logger.info('compositor: composed', {
input: opts.inputPath,
output: opts.outputPath,
bgType: opts.background.type,
paddingPx: opts.paddingPx,
outSize: `${outW}x${outH}`,
});
} finally {
if (!win.isDestroyed()) win.destroy();
}
}

function loadWithTimeout(win: BrowserWindow, html: string): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error('compositor: load timed out')),
COMPOSITE_TIMEOUT_MS,
);
win.webContents.once('did-finish-load', () => {
clearTimeout(timer);
resolve();
});
win.webContents.once('did-fail-load', (_e, code, desc) => {
clearTimeout(timer);
reject(new Error(`compositor: did-fail-load ${code} ${desc}`));
});
void win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
});
}

function buildHtml(args: {
bgCss: string;
paddingPx: number;
winSize: { width: number; height: number };
outSize: { w: number; h: number };
winDataUrl: string;
}): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body {
margin: 0; padding: 0; width: 100%; height: 100%;
overflow: hidden;
}
.canvas {
width: ${args.outSize.w}px;
height: ${args.outSize.h}px;
background: ${args.bgCss};
display: flex;
align-items: center;
justify-content: center;
}
/*
* The captured PNG already has the window's native rounded corners
* (transparent outside the rounding). We use filter:drop-shadow so the
* shadow follows the real alpha shape — box-shadow on a rectangular
* wrapper would draw a square shadow ignoring the corners.
*/
img {
display: block;
width: ${args.winSize.width}px;
height: ${args.winSize.height}px;
filter: drop-shadow(0 ${Math.round(SHADOW_PX / 2)}px ${SHADOW_PX}px rgba(0, 0, 0, 0.45));
}
</style>
</head>
<body>
<div class="canvas">
<img src="${args.winDataUrl}">
</div>
</body>
</html>`;
}
78 changes: 73 additions & 5 deletions src/main/capture/screenshot.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { spawn } from 'node:child_process';
import { mkdir, stat } from 'node:fs/promises';
import { mkdir, rename, stat, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { app, clipboard, nativeImage } from 'electron';
import logger from '@main/logger';
import { compositeWindowOnBackground } from '@main/capture/compositor';
import { pickRegion } from '@main/selection/overlay';
import { getPreferences } from '@main/storage/prefs';
import { insertCapture } from '@main/storage/db';
import type { CaptureMode, CaptureOptions, CaptureResult, SelectionRect } from '@shared/types';
import { setDesktopIconsHidden } from '@main/system/desktopIcons';
import type {
AppPreferences,
CaptureMode,
CaptureOptions,
CaptureResult,
SelectionRect,
} from '@shared/types';

const SCREENCAPTURE_BIN = '/usr/sbin/screencapture';

Expand Down Expand Up @@ -71,10 +80,33 @@ function timestampedFilename(format: 'png' | 'jpg'): string {
}

export async function takeScreenshot(options: CaptureOptions): Promise<CaptureResult> {
const prefs = getPreferences();

// Temp icon-hide: only when the during-captures flag is on and the user
// hasn't already permanently hidden them (then we'd flicker for nothing).
const tempHideIcons = prefs.hideDesktopIconsDuringCaptures && !prefs.hideDesktopIcons;
if (tempHideIcons) {
await setDesktopIconsHidden(true).catch((err) =>
logger.warn('capture: temp icon hide failed', err),
);
}

try {
return await runCapture(options, prefs);
} finally {
if (tempHideIcons) {
await setDesktopIconsHidden(false).catch(() => {
/* logged inside */
});
}
}
}

async function runCapture(options: CaptureOptions, prefs: AppPreferences): Promise<CaptureResult> {
// For "area" mode, route through our homegrown overlay when the pref is on.
// The overlay returns a global-DIP rect that we hand to `screencapture -R`.
let region = options.region;
if (!region && options.mode === 'area' && getPreferences().useCustomSelectionOverlay) {
if (!region && options.mode === 'area' && prefs.useCustomSelectionOverlay) {
const result = await pickRegion();
if (result.cancelled || !result.rect) {
return {
Expand All @@ -94,10 +126,17 @@ export async function takeScreenshot(options: CaptureOptions): Promise<CaptureRe
const outFile = join(saveDir, timestampedFilename(format));
await mkdir(dirname(outFile), { recursive: true });

// For window-mode captures with a custom background, screencapture writes
// to a temp file first and the compositor produces the final image.
const willComposite = options.mode === 'window' && prefs.wallpaperMode !== 'system';
const screencaptureTarget = willComposite
? join(tmpdir(), `snapora-window-${Date.now()}.png`)
: outFile;

const delaySeconds = Math.max(0, Math.round((options.delayMs ?? 0) / 1000));
// Default silent unless the user explicitly opts in via prefs (handled by callers).
const silent = options.silent !== false;
const args = buildArgs(options.mode, format, delaySeconds, outFile, silent, region);
const args = buildArgs(options.mode, format, delaySeconds, screencaptureTarget, silent, region);

logger.info('capture: spawning screencapture', { args });

Expand All @@ -113,11 +152,40 @@ export async function takeScreenshot(options: CaptureOptions): Promise<CaptureRe
let height: number | null = null;

try {
await stat(outFile);
await stat(screencaptureTarget);
} catch {
cancelled = true;
}

if (!cancelled && exitCode === 0 && willComposite) {
try {
await compositeWindowOnBackground({
inputPath: screencaptureTarget,
outputPath: outFile,
background:
prefs.wallpaperMode === 'customImage'
? { type: 'image', value: prefs.customWallpaperImagePath ?? '' }
: { type: 'color', value: prefs.customWallpaperColor },
paddingPx: prefs.windowBackgroundPaddingPx,
});
} catch (err) {
// Fall back to the raw window screenshot — better than nothing.
logger.warn('capture: composite failed, falling back to raw window image', err);
try {
await rename(screencaptureTarget, outFile);
} catch (renameErr) {
logger.error('capture: fallback rename failed', renameErr);
cancelled = true;
}
} finally {
// The temp file is no longer needed after a successful composite. If
// composite failed and we renamed it to outFile, the unlink no-ops.
void unlink(screencaptureTarget).catch(() => {
/* file already moved or never existed */
});
}
}

if (!cancelled && exitCode === 0) {
if (options.copyToClipboard !== false) {
const img = nativeImage.createFromPath(outFile);
Expand Down
3 changes: 2 additions & 1 deletion src/main/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { setDesktopIconsHidden } from '@main/system/desktopIcons';
import { getCurrentEditorImageUrl } from '@main/windows/editor';
import { showHudWithImage } from '@main/windows/hud';
import { markFirstRunDone, relaunchApp } from '@main/windows/firstRun';
import { chooseSaveDirectory } from '@main/windows/settings';
import { chooseSaveDirectory, chooseWallpaperImage } from '@main/windows/settings';
import { registerHistoryHandlers } from '@main/ipc/historyHandlers';
import { registerSelectionHandlers } from '@main/selection/overlay';
import { registerGlobalShortcuts } from '@main/shortcuts/index';
Expand Down Expand Up @@ -42,6 +42,7 @@ export function registerIpcHandlers(): void {
);

ipcMain.handle(IPC.preferences.chooseSaveDirectory, () => chooseSaveDirectory());
ipcMain.handle(IPC.wallpaper.chooseImage, () => chooseWallpaperImage());
ipcMain.handle(IPC.preferences.get, () => getPreferences());
ipcMain.handle(IPC.preferences.set, async (_evt, patch: Partial<AppPreferences>) => {
const next = setPreferences(patch);
Expand Down
15 changes: 15 additions & 0 deletions src/main/windows/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,18 @@ export async function chooseSaveDirectory(): Promise<string | null> {
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0] ?? null;
}

/**
* IPC helper for the Wallpaper settings page — opens an image file picker
* filtered to common formats Snapora can load as a window background.
*/
export async function chooseWallpaperImage(): Promise<string | null> {
const focused = BrowserWindow.getFocusedWindow();
const result = await dialog.showOpenDialog(focused ?? new BrowserWindow({ show: false }), {
properties: ['openFile'],
title: 'Choose a custom background image',
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'heic', 'tif', 'tiff'] }],
});
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0] ?? null;
}
3 changes: 3 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ const api: SnaporaApi = {
chooseSaveDirectory: (): Promise<string | null> =>
ipcRenderer.invoke(IPC.preferences.chooseSaveDirectory),
},
wallpaper: {
chooseImage: (): Promise<string | null> => ipcRenderer.invoke(IPC.wallpaper.chooseImage),
},
editor: {
onImageReady: (handler: (snapUrl: string) => void) => {
const listener = (_evt: unknown, snapUrl: string): void => handler(snapUrl);
Expand Down
10 changes: 5 additions & 5 deletions src/renderer/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AboutSettings } from './settings/About';
import { GeneralSettings } from './settings/General';
import { ShortcutsSettings } from './settings/Shortcuts';
import { StubSection } from './settings/Stub';
import { WallpaperSettings } from './settings/Wallpaper';

type SectionKey = 'general' | 'shortcuts' | 'screenshot' | 'recording' | 'wallpaper' | 'about';

Expand All @@ -31,7 +32,6 @@ const NAV: NavItem[] = [
key: 'wallpaper',
label: 'Wallpaper',
icon: <Wallpaper className="h-4 w-4" />,
milestone: 'v0.5',
},
{ key: 'about', label: 'About', icon: <Info className="h-4 w-4" /> },
];
Expand Down Expand Up @@ -70,11 +70,11 @@ export function Settings() {
<main className="flex-1 overflow-y-auto p-6">
{active === 'general' && <GeneralSettings />}
{active === 'shortcuts' && <ShortcutsSettings />}
{active === 'wallpaper' && <WallpaperSettings />}
{active === 'about' && <AboutSettings />}
{activeItem?.milestone &&
(active === 'screenshot' || active === 'recording' || active === 'wallpaper') && (
<StubSection title={activeItem.label} milestone={activeItem.milestone} />
)}
{activeItem?.milestone && (active === 'screenshot' || active === 'recording') && (
<StubSection title={activeItem.label} milestone={activeItem.milestone} />
)}
</main>
</div>
</WindowShell>
Expand Down
8 changes: 4 additions & 4 deletions src/renderer/components/settings/General.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,12 @@ export function GeneralSettings() {
}
/>
<Row
label="Hide desktop icons"
description="Toggles macOS desktop icons via Finder. Restored automatically when Snapora quits."
label="Hide desktop icons while capturing"
description="Auto-hide your desktop icons just for the duration of each capture. The persistent toggle lives in the menu bar."
control={
<Switch
checked={prefs.hideDesktopIcons}
onCheckedChange={(v) => void update('hideDesktopIcons', v)}
checked={prefs.hideDesktopIconsDuringCaptures}
onCheckedChange={(v) => void update('hideDesktopIconsDuringCaptures', v)}
/>
}
/>
Expand Down
Loading
Loading