diff --git a/src/main/capture/compositor.ts b/src/main/capture/compositor.ts new file mode 100644 index 0000000..63e1f86 --- /dev/null +++ b/src/main/capture/compositor.ts @@ -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 { + 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, + }, + }); + + 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 { + 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 ` + + + + + + +
+ +
+ +`; +} diff --git a/src/main/capture/screenshot.ts b/src/main/capture/screenshot.ts index 2bb8b44..a18d59b 100644 --- a/src/main/capture/screenshot.ts +++ b/src/main/capture/screenshot.ts @@ -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'; @@ -71,10 +80,33 @@ function timestampedFilename(format: 'png' | 'jpg'): string { } export async function takeScreenshot(options: CaptureOptions): Promise { + 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 { // 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 { @@ -94,10 +126,17 @@ export async function takeScreenshot(options: CaptureOptions): Promise { + /* file already moved or never existed */ + }); + } + } + if (!cancelled && exitCode === 0) { if (options.copyToClipboard !== false) { const img = nativeImage.createFromPath(outFile); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index da14afe..ff07031 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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'; @@ -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) => { const next = setPreferences(patch); diff --git a/src/main/windows/settings.ts b/src/main/windows/settings.ts index 2999749..b95da84 100644 --- a/src/main/windows/settings.ts +++ b/src/main/windows/settings.ts @@ -70,3 +70,18 @@ export async function chooseSaveDirectory(): Promise { 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 { + 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; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index dd02339..5cbb0be 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -44,6 +44,9 @@ const api: SnaporaApi = { chooseSaveDirectory: (): Promise => ipcRenderer.invoke(IPC.preferences.chooseSaveDirectory), }, + wallpaper: { + chooseImage: (): Promise => ipcRenderer.invoke(IPC.wallpaper.chooseImage), + }, editor: { onImageReady: (handler: (snapUrl: string) => void) => { const listener = (_evt: unknown, snapUrl: string): void => handler(snapUrl); diff --git a/src/renderer/components/Settings.tsx b/src/renderer/components/Settings.tsx index 09d38e0..e6a9f13 100644 --- a/src/renderer/components/Settings.tsx +++ b/src/renderer/components/Settings.tsx @@ -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'; @@ -31,7 +32,6 @@ const NAV: NavItem[] = [ key: 'wallpaper', label: 'Wallpaper', icon: , - milestone: 'v0.5', }, { key: 'about', label: 'About', icon: }, ]; @@ -70,11 +70,11 @@ export function Settings() {
{active === 'general' && } {active === 'shortcuts' && } + {active === 'wallpaper' && } {active === 'about' && } - {activeItem?.milestone && - (active === 'screenshot' || active === 'recording' || active === 'wallpaper') && ( - - )} + {activeItem?.milestone && (active === 'screenshot' || active === 'recording') && ( + + )}
diff --git a/src/renderer/components/settings/General.tsx b/src/renderer/components/settings/General.tsx index 58b57e1..a757bb9 100644 --- a/src/renderer/components/settings/General.tsx +++ b/src/renderer/components/settings/General.tsx @@ -60,12 +60,12 @@ export function GeneralSettings() { } /> void update('hideDesktopIcons', v)} + checked={prefs.hideDesktopIconsDuringCaptures} + onCheckedChange={(v) => void update('hideDesktopIconsDuringCaptures', v)} /> } /> diff --git a/src/renderer/components/settings/Wallpaper.tsx b/src/renderer/components/settings/Wallpaper.tsx new file mode 100644 index 0000000..b1c05a7 --- /dev/null +++ b/src/renderer/components/settings/Wallpaper.tsx @@ -0,0 +1,142 @@ +import { FolderOpen, ImageIcon, Trash2 } from 'lucide-react'; +import { Button } from '../ui/button'; +import { Select } from '../ui/select'; +import { Row, Section } from './SettingsLayout'; +import { usePreferences } from './usePreferences'; +import type { AppPreferences } from '@shared/types'; + +const WALLPAPER_OPTIONS: { value: AppPreferences['wallpaperMode']; label: string }[] = [ + { value: 'system', label: 'No background (raw window)' }, + { value: 'customImage', label: 'Custom image' }, + { value: 'customColor', label: 'Solid color' }, +]; + +const PADDING_MIN = 0; +const PADDING_MAX = 200; + +function basename(p: string | null): string { + if (!p) return ''; + const i = p.lastIndexOf('/'); + return i === -1 ? p : p.slice(i + 1); +} + +export function WallpaperSettings() { + const { prefs, update } = usePreferences(); + + if (!prefs) return null; + + const pickImage = async () => { + const path = await window.snapora.wallpaper.chooseImage(); + if (path) await update('customWallpaperImagePath', path); + }; + + const clearImage = () => void update('customWallpaperImagePath', null); + + const showsBackground = prefs.wallpaperMode !== 'system'; + + return ( +
+
+ + value={prefs.wallpaperMode} + onChange={(v) => void update('wallpaperMode', v)} + options={WALLPAPER_OPTIONS} + /> + } + /> + {showsBackground ? ( + void update('windowBackgroundPaddingPx', Number(e.target.value))} + className="w-40 accent-amber-400" + /> + } + /> + ) : null} +
+ + {prefs.wallpaperMode === 'customImage' ? ( +
+ + {prefs.customWallpaperImagePath ? ( + + ) : null} + +
+ } + /> + + {prefs.customWallpaperImagePath ? ( + + ) : ( + + )} + + } + /> + + ) : null} + + {prefs.wallpaperMode === 'customColor' ? ( +
+ + void update('customWallpaperColor', e.target.value)} + className="h-8 w-10 cursor-pointer rounded border border-white/10 bg-transparent" + /> + void update('customWallpaperColor', e.target.value)} + spellCheck={false} + className="h-8 w-24 rounded-md border border-white/10 bg-white/[0.04] px-2 font-mono text-sm text-white/95 outline-none focus:border-amber-400/50" + /> + + } + /> +
+ ) : null} + + ); +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index b8f375e..3a23d15 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -59,6 +59,10 @@ export const IPC = { set: 'preferences:set', chooseSaveDirectory: 'preferences:choose-save-directory', }, + wallpaper: { + /** Open a file dialog → returns the chosen image's absolute path or null. */ + chooseImage: 'wallpaper:choose-image', + }, editor: { onImageReady: 'editor:image-ready', requestCurrent: 'editor:request-current', @@ -114,6 +118,9 @@ export interface SnaporaApi { set(patch: Partial): Promise; chooseSaveDirectory(): Promise; }; + wallpaper: { + chooseImage(): Promise; + }; editor: { onImageReady(handler: (snapUrl: string) => void): () => void; /** Returns the most recent image URL the main process has shown, or null. */ diff --git a/src/shared/types.ts b/src/shared/types.ts index e2322db..d35829e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -71,8 +71,33 @@ export interface AppPreferences { defaultFormat: CaptureFormat; /** Copy to clipboard automatically after capture. */ autoCopyToClipboard: boolean; - /** Hide the desktop icons (system-wide). Restored when Snapora quits. */ + /** + * Persistent "always hide desktop icons" toggle (mirrors CleanShot's + * menu-bar item). Lives across Snapora's lifetime; restored on quit. + */ hideDesktopIcons: boolean; + /** + * Auto-hide desktop icons just for the duration of each capture + * (mirrors CleanShot's General → "Hide while capturing" checkbox). + * No-op when `hideDesktopIcons` is already true. + */ + hideDesktopIconsDuringCaptures: boolean; + /** + * Window-mode background. Snapora composites the captured window onto + * this background instead of the original macOS desktop. Only applies + * to `window` mode captures — `area` and `fullscreen` capture the real + * desktop unchanged. + * - `system` no compositing, capture the window as-is with macOS shadow + * - `customImage` composite onto `customWallpaperImagePath` + * - `customColor` composite onto a flat `customWallpaperColor` background + */ + wallpaperMode: 'system' | 'customImage' | 'customColor'; + /** Absolute path to the user's chosen background image, or null. */ + customWallpaperImagePath: string | null; + /** Hex color used by `customColor`, e.g. `#0f172a`. */ + customWallpaperColor: string; + /** Padding (DIPs) between the captured window and the background edge. */ + windowBackgroundPaddingPx: number; /** Delay before a full-screen capture fires (gives you time to set up). 0 = no timer. */ selfTimerSeconds: 0 | 3 | 5 | 10; /** @@ -98,6 +123,11 @@ export const DEFAULT_PREFERENCES: AppPreferences = { defaultFormat: 'png', autoCopyToClipboard: true, hideDesktopIcons: false, + hideDesktopIconsDuringCaptures: false, + wallpaperMode: 'system', + customWallpaperImagePath: null, + customWallpaperColor: '#0f172a', + windowBackgroundPaddingPx: 64, selfTimerSeconds: 0, useCustomSelectionOverlay: true, hotkeys: {