Move desktop screen capture into modules/screen-capture and align preload build paths and imports.

This commit is contained in:
CREDO23 2026-04-27 20:39:03 +02:00
parent 9cd4daa6b3
commit d4caae6de9
9 changed files with 23 additions and 16 deletions

View file

@ -0,0 +1,7 @@
/**
* Window capture for Screenshot Assist and chat fullscreen: single-session
* desktopCapturer, region overlay, and shortcut entry point.
*/
export { pickOpenWindowCapture, type PickedWindowResult } from './window-picker';
export { pickScreenRegion, captureCurrentDisplayDataUrl } from './screen-region-picker';
export { runScreenshotAssistShortcut } from './screenshot-assist';

View file

@ -0,0 +1,335 @@
import { BrowserWindow, desktopCapturer, nativeImage, screen } from 'electron';
import path from 'path';
import { IPC_CHANNELS } from '../../ipc/channels';
function fitNativeImageToWorkArea(img: Electron.NativeImage, display: Electron.Display): Electron.NativeImage {
const wa = display.workArea;
const { width: iw, height: ih } = img.getSize();
const scale = Math.min(1, wa.width / iw, wa.height / ih);
if (scale >= 1) return img;
return img.resize({
width: Math.max(1, Math.floor(iw * scale)),
height: Math.max(1, Math.floor(ih * scale)),
quality: 'best',
});
}
// One getSources per pick; overlay and final crop share that bitmap (avoids a second portal session, e.g. Wayland).
let pickInProgress = false;
type DisplayCaptureSnapshot = {
dataUrl: string;
width: number;
height: number;
};
async function captureDisplaySnapshot(display: Electron.Display): Promise<DisplayCaptureSnapshot | null> {
try {
const sf = display.scaleFactor || 1;
const tw = Math.max(1, Math.round(display.size.width * sf));
const th = Math.max(1, Math.round(display.size.height * sf));
const sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: tw, height: th },
});
if (!sources.length) return null;
const idStr = String(display.id);
let chosen =
sources.find((s) => s.display_id === idStr) ||
sources.find((s) => s.display_id && s.display_id === idStr) ||
null;
if (!chosen && screen.getPrimaryDisplay().id === display.id) {
chosen = sources[0];
}
if (!chosen) chosen = sources[0];
const dataUrl = chosen.thumbnail.toDataURL();
const { width, height } = chosen.thumbnail.getSize();
return { dataUrl, width, height };
} catch {
return null;
}
}
export async function captureCurrentDisplayDataUrl(): Promise<string | null> {
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint());
const snapshot = await captureDisplaySnapshot(display);
return snapshot?.dataUrl ?? null;
}
function buildInjectScript(dataUrl: string, iw: number, ih: number): string {
return `(() => {
const api = window.surfsenseScreenRegion;
if (!api) return;
const dataUrl = ${JSON.stringify(dataUrl)};
const iw = ${iw};
const ih = ${ih};
document.body.style.margin = '0';
document.body.style.overflow = 'hidden';
document.body.style.background = '#000';
const img = document.createElement('img');
img.draggable = false;
img.src = dataUrl;
img.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;object-fit:fill;user-select:none;pointer-events:none;';
const veil = document.createElement('div');
veil.style.cssText = 'position:fixed;inset:0;cursor:crosshair;background:rgba(0,0,0,0.15);';
const sel = document.createElement('div');
sel.style.cssText = 'position:fixed;border:2px solid #38bdf8;box-shadow:0 0 0 9999px rgba(0,0,0,0.45);display:none;pointer-events:none;z-index:2;';
document.body.appendChild(img);
document.body.appendChild(veil);
document.body.appendChild(sel);
let ax = 0, ay = 0, dragging = false;
function show(x0, y0, x1, y1) {
const l = Math.min(x0, x1), t = Math.min(y0, y1);
const w = Math.abs(x1 - x0), h = Math.abs(y1 - y0);
if (w < 2 || h < 2) { sel.style.display = 'none'; return; }
sel.style.display = 'block';
sel.style.left = l + 'px';
sel.style.top = t + 'px';
sel.style.width = w + 'px';
sel.style.height = h + 'px';
}
function mapRect(l, t, w, h) {
const vw = window.innerWidth, vh = window.innerHeight;
const sx = Math.round((l / vw) * iw);
const sy = Math.round((t / vh) * ih);
const sw = Math.max(1, Math.round((w / vw) * iw));
const sh = Math.max(1, Math.round((h / vh) * ih));
const cx = Math.min(Math.max(0, sx), iw - 1);
const cy = Math.min(Math.max(0, sy), ih - 1);
const cw = Math.min(sw, iw - cx);
const ch = Math.min(sh, ih - cy);
return { x: cx, y: cy, width: cw, height: ch };
}
function endDrag(clientX, clientY, pointerId) {
if (!dragging) return;
dragging = false;
if (typeof pointerId === 'number' && pointerId >= 0) {
try { veil.releasePointerCapture(pointerId); } catch (_) {}
}
const l = Math.min(ax, clientX), t = Math.min(ay, clientY);
const w = Math.abs(clientX - ax), h = Math.abs(clientY - ay);
if (w < 4 || h < 4) { sel.style.display = 'none'; return; }
api.submit(mapRect(l, t, w, h));
}
veil.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
try { veil.setPointerCapture(e.pointerId); } catch (_) {}
dragging = true;
ax = e.clientX; ay = e.clientY;
show(ax, ay, ax, ay);
});
veil.addEventListener('pointermove', (e) => {
if (!dragging) return;
show(ax, ay, e.clientX, e.clientY);
});
veil.addEventListener('pointerup', (e) => {
endDrag(e.clientX, e.clientY, e.pointerId);
});
window.addEventListener('pointerup', (e) => {
endDrag(e.clientX, e.clientY, e.pointerId);
});
document.addEventListener(
'mouseup',
(e) => {
endDrag(e.clientX, e.clientY, -1);
},
true
);
veil.addEventListener('pointercancel', (e) => {
if (!dragging) return;
dragging = false;
try { veil.releasePointerCapture(e.pointerId); } catch (_) {}
sel.style.display = 'none';
});
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { api.cancel(); return; }
if (e.key === 'Enter' && sel.style.display === 'block') {
const l = parseFloat(sel.style.left), t = parseFloat(sel.style.top);
const w = parseFloat(sel.style.width), h = parseFloat(sel.style.height);
if (w >= 4 && h >= 4) api.submit(mapRect(l, t, w, h));
}
});
})();`;
}
export function pickScreenRegion(opts?: { windowDataUrl?: string }): Promise<string | null> {
if (pickInProgress) return Promise.resolve(null);
pickInProgress = true;
return new Promise((resolve) => {
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint());
let settled = false;
let overlay: BrowserWindow | null = null;
/** webContents for listener removal after `BrowserWindow` may already be destroyed. */
let overlayWc: Electron.WebContents | null = null;
const cleanupListeners = () => {
const wc = overlayWc;
overlayWc = null;
if (!wc || wc.isDestroyed()) return;
wc.removeListener('before-input-event', onBeforeInput);
wc.ipc.removeListener(IPC_CHANNELS.SCREEN_REGION_SUBMIT, onSubmit);
wc.ipc.removeListener(IPC_CHANNELS.SCREEN_REGION_CANCEL, onCancel);
};
const finish = (result: string | null) => {
if (settled) return;
settled = true;
pickInProgress = false;
cleanupListeners();
if (overlay && !overlay.isDestroyed()) {
overlay.removeAllListeners('closed');
overlay.close();
}
overlay = null;
resolve(result);
};
let snapshot: DisplayCaptureSnapshot | null = null;
let cropSource: Electron.NativeImage | null = null;
const onSubmit = (
_event: Electron.IpcMainEvent,
rect: { x: number; y: number; width: number; height: number }
) => {
if (settled || !overlay || overlay.isDestroyed()) return;
if (!rect || rect.width < 1 || rect.height < 1) {
finish(null);
return;
}
if (!snapshot || !cropSource) {
finish(null);
return;
}
try {
const iw = snapshot.width;
const ih = snapshot.height;
const { width: cw, height: ch } = cropSource.getSize();
const scaleX = cw / iw;
const scaleY = ch / ih;
const ox = Math.floor(rect.x * scaleX);
const oy = Math.floor(rect.y * scaleY);
const ow = Math.min(Math.floor(rect.width * scaleX), cw - ox);
const oh = Math.min(Math.floor(rect.height * scaleY), ch - oy);
const cropped = cropSource.crop({
x: ox,
y: oy,
width: Math.max(1, ow),
height: Math.max(1, oh),
});
finish(cropped.toDataURL());
} catch {
finish(null);
}
};
const onCancel = (_event: Electron.IpcMainEvent) => {
if (settled || !overlay || overlay.isDestroyed()) return;
finish(null);
};
const onBeforeInput = (_event: Electron.Event, input: Electron.Input) => {
if (input.type === 'keyDown' && input.key === 'Escape') {
finish(null);
}
};
const openOverlay = (
cap: DisplayCaptureSnapshot,
crop: Electron.NativeImage,
bounds: { x: number; y: number; width: number; height: number }
) => {
snapshot = cap;
cropSource = crop;
overlay = new BrowserWindow({
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
frame: false,
transparent: true,
fullscreenable: false,
skipTaskbar: true,
alwaysOnTop: true,
focusable: true,
show: false,
autoHideMenuBar: true,
backgroundColor: '#00000000',
webPreferences: {
preload: path.join(__dirname, 'modules', 'screen-capture', 'screen-region-preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
overlayWc = overlay.webContents;
overlayWc.on('before-input-event', onBeforeInput);
overlayWc.ipc.on(IPC_CHANNELS.SCREEN_REGION_SUBMIT, onSubmit);
overlayWc.ipc.on(IPC_CHANNELS.SCREEN_REGION_CANCEL, onCancel);
overlay.setIgnoreMouseEvents(false);
overlay.loadURL(
'data:text/html;charset=utf-8,' +
encodeURIComponent('<!doctype html><html><head><meta charset="utf-8"/></head><body></body></html>')
);
overlay.on('closed', () => {
if (!settled) finish(null);
});
overlay.webContents.once('did-finish-load', () => {
if (!overlay || overlay.isDestroyed()) return;
overlay.webContents
.executeJavaScript(buildInjectScript(cap.dataUrl, cap.width, cap.height), true)
.then(() => {
overlay?.show();
overlay?.focus();
})
.catch(() => {
finish(null);
});
});
};
void (async () => {
try {
if (opts?.windowDataUrl) {
const fullRes = nativeImage.createFromDataURL(opts.windowDataUrl);
if (fullRes.isEmpty()) {
finish(null);
return;
}
const fitted = fitNativeImageToWorkArea(fullRes, display);
const fw = fitted.getSize().width;
const fh = fitted.getSize().height;
const wa = display.workArea;
const x = wa.x + Math.floor((wa.width - fw) / 2);
const y = wa.y + Math.floor((wa.height - fh) / 2);
openOverlay(
{ dataUrl: fitted.toDataURL(), width: fw, height: fh },
fullRes,
{ x, y, width: fw, height: fh }
);
return;
}
const cap = await captureDisplaySnapshot(display);
if (!cap) {
finish(null);
return;
}
const crop = nativeImage.createFromDataURL(cap.dataUrl);
openOverlay(cap, crop, {
x: display.bounds.x,
y: display.bounds.y,
width: display.bounds.width,
height: display.bounds.height,
});
} catch {
finish(null);
}
})();
});
}

View file

@ -0,0 +1,11 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from '../../ipc/channels';
contextBridge.exposeInMainWorld('surfsenseScreenRegion', {
submit: (rect: { x: number; y: number; width: number; height: number }) => {
ipcRenderer.send(IPC_CHANNELS.SCREEN_REGION_SUBMIT, rect);
},
cancel: () => {
ipcRenderer.send(IPC_CHANNELS.SCREEN_REGION_CANCEL);
},
});

View file

@ -0,0 +1,26 @@
import { IPC_CHANNELS } from '../../ipc/channels';
import { trackEvent } from '../analytics';
import { pickScreenRegion } from './screen-region-picker';
import { pickOpenWindowCapture } from './window-picker';
import { getMainWindow, showMainWindow } from '../window';
import { hasScreenRecordingPermission, requestScreenRecording } from '../permissions';
export async function runScreenshotAssistShortcut(): Promise<void> {
if (!hasScreenRecordingPermission()) {
requestScreenRecording();
return;
}
const picked = await pickOpenWindowCapture();
if (!picked) return;
const url = await pickScreenRegion({ windowDataUrl: picked.dataUrl });
if (!url) return;
showMainWindow('shortcut');
const mw = getMainWindow();
if (mw && !mw.isDestroyed()) {
mw.webContents.send(IPC_CHANNELS.CHAT_SCREEN_CAPTURE, url);
trackEvent('desktop_screenshot_assist_region_to_chat', {});
}
}

View file

@ -0,0 +1,15 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from '../../ipc/channels';
contextBridge.exposeInMainWorld('surfsenseWindowPick', {
list: () =>
ipcRenderer.invoke(IPC_CHANNELS.WINDOW_PICK_LIST) as Promise<
{ id: string; name: string; thumbDataUrl: string }[]
>,
submit: (sourceId: string) => {
ipcRenderer.send(IPC_CHANNELS.WINDOW_PICK_SUBMIT, sourceId);
},
cancel: () => {
ipcRenderer.send(IPC_CHANNELS.WINDOW_PICK_CANCEL);
},
});

View file

@ -0,0 +1,244 @@
import { BrowserWindow, desktopCapturer, ipcMain, screen } from 'electron';
import path from 'path';
import { IPC_CHANNELS } from '../../ipc/channels';
let pickInProgress = false;
const PREVIEW_THUMB = { width: 280, height: 180 } as const;
function maxCaptureThumbSize(): { width: number; height: number } {
const d = screen.getPrimaryDisplay();
const sf = d.scaleFactor || 1;
const w = Math.min(3840, Math.max(1280, Math.round(d.size.width * sf)));
const h = Math.min(2160, Math.max(720, Math.round(d.size.height * sf)));
return { width: w, height: h };
}
function isDesktopWindowSourceId(s: string): boolean {
return typeof s === 'string' && s.startsWith('window:');
}
export type PickedWindowResult = {
sourceId: string;
/** Same pixels as the one `desktopCapturer` snapshot (max thumbnail size). */
dataUrl: string;
};
function buildPickerInjectScript(): string {
return `(async function () {
const api = window.surfsenseWindowPick;
if (!api) return;
const items = await api.list();
document.body.style.cssText =
'margin:0;font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;padding:16px;box-sizing:border-box;';
const top = document.createElement('div');
top.style.cssText =
'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px;';
const t = document.createElement('strong');
t.textContent = 'Open windows';
const hint = document.createElement('span');
hint.style.cssText = 'opacity:0.75;font-size:13px;';
hint.textContent = 'Click a window · Esc to cancel';
top.appendChild(t);
top.appendChild(hint);
document.body.appendChild(top);
if (!items || !items.length) {
const p = document.createElement('p');
p.style.cssText = 'line-height:1.5;max-width:42rem;';
p.textContent =
'No windows were returned by the system. On Linux, allow screen capture when prompted. If other apps are open, try again.';
document.body.appendChild(p);
return;
}
const grid = document.createElement('div');
grid.style.cssText =
'display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;max-height:calc(100vh - 88px);overflow:auto;padding-bottom:8px;';
for (const it of items) {
const card = document.createElement('button');
card.type = 'button';
card.style.cssText =
'text-align:left;background:#1e293b;border:1px solid #334155;border-radius:8px;padding:8px;cursor:pointer;color:inherit;';
card.addEventListener('mouseenter', function () {
card.style.borderColor = '#38bdf8';
});
card.addEventListener('mouseleave', function () {
card.style.borderColor = '#334155';
});
const img = document.createElement('img');
img.alt = '';
img.src =
it.thumbDataUrl ||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
img.style.cssText =
'width:100%;height:100px;object-fit:cover;border-radius:4px;background:#000;display:block;';
const cap = document.createElement('div');
cap.textContent = it.name || '(untitled)';
cap.style.cssText =
'margin-top:6px;font-size:12px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;';
card.appendChild(img);
card.appendChild(cap);
card.addEventListener('click', function () {
api.submit(it.id);
});
grid.appendChild(card);
}
document.body.appendChild(grid);
window.addEventListener('keydown', function (e) {
if (e.key === 'Escape') api.cancel();
});
})();`;
}
/**
* One OS / Chromium capture session: `getSources` runs once (important on Wayland /
* PipeWire so the portal is not opened again for the same flow). Opens our grid to
* choose a window; resolves with the chosen snapshot for region or full-frame use.
*/
export function pickOpenWindowCapture(): Promise<PickedWindowResult | null> {
if (pickInProgress) return Promise.resolve(null);
pickInProgress = true;
return new Promise((resolve) => {
let settled = false;
let picker: BrowserWindow | null = null;
let pickerWc: Electron.WebContents | null = null;
/** Filled once before the grid runs — reused for list + final image (no second getSources). */
let sessionSources: Electron.DesktopCapturerSource[] = [];
const finish = (result: PickedWindowResult | null) => {
if (settled) return;
settled = true;
pickInProgress = false;
ipcMain.removeHandler(IPC_CHANNELS.WINDOW_PICK_LIST);
const wc = pickerWc;
pickerWc = null;
if (wc && !wc.isDestroyed()) {
wc.removeListener('before-input-event', onBeforeInput);
wc.ipc.removeListener(IPC_CHANNELS.WINDOW_PICK_SUBMIT, onSubmit);
wc.ipc.removeListener(IPC_CHANNELS.WINDOW_PICK_CANCEL, onCancel);
}
if (picker && !picker.isDestroyed()) {
picker.removeAllListeners('closed');
picker.close();
}
picker = null;
resolve(result);
};
const onSubmit = (_event: Electron.IpcMainEvent, sourceId: string) => {
if (settled || !picker || picker.isDestroyed()) return;
if (!isDesktopWindowSourceId(sourceId)) {
finish(null);
return;
}
const hit = sessionSources.find((s) => s.id === sourceId);
if (!hit || hit.thumbnail.isEmpty()) {
finish(null);
return;
}
finish({ sourceId, dataUrl: hit.thumbnail.toDataURL() });
};
const onCancel = () => {
if (settled || !picker || picker.isDestroyed()) return;
finish(null);
};
const onBeforeInput = (_event: Electron.Event, input: Electron.Input) => {
if (input.type === 'keyDown' && input.key === 'Escape') {
finish(null);
}
};
ipcMain.handle(IPC_CHANNELS.WINDOW_PICK_LIST, async () => {
return sessionSources.map((s, i) => {
let thumbDataUrl = '';
if (!s.thumbnail.isEmpty()) {
try {
const sm = s.thumbnail.resize({
width: PREVIEW_THUMB.width,
height: PREVIEW_THUMB.height,
quality: 'good',
});
thumbDataUrl = sm.toDataURL();
} catch {
thumbDataUrl = s.thumbnail.toDataURL();
}
}
return {
id: s.id,
name: (s.name || '').trim() || `Window ${i + 1}`,
thumbDataUrl,
};
});
});
picker = new BrowserWindow({
width: 760,
height: 560,
show: false,
center: true,
autoHideMenuBar: true,
title: 'SurfSense — choose window',
webPreferences: {
preload: path.join(__dirname, 'modules', 'screen-capture', 'window-picker-preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
pickerWc = picker.webContents;
pickerWc.on('before-input-event', onBeforeInput);
pickerWc.ipc.on(IPC_CHANNELS.WINDOW_PICK_SUBMIT, onSubmit);
pickerWc.ipc.on(IPC_CHANNELS.WINDOW_PICK_CANCEL, onCancel);
picker.on('closed', () => {
if (!settled) finish(null);
});
picker
.loadURL(
'data:text/html;charset=utf-8,' +
encodeURIComponent('<!doctype html><html><head><meta charset="utf-8"/></head><body></body></html>')
)
.catch(() => finish(null));
picker.webContents.once('did-finish-load', () => {
void (async () => {
if (!picker || picker.isDestroyed()) return;
let selfId = '';
try {
selfId = picker.getMediaSourceId();
} catch {
selfId = '';
}
try {
const { width, height } = maxCaptureThumbSize();
const sources = await desktopCapturer.getSources({
types: ['window'],
thumbnailSize: { width, height },
fetchWindowIcons: false,
});
sessionSources = sources.filter((s) => !(selfId && s.id === selfId));
} catch {
sessionSources = [];
}
if (sessionSources.length === 1) {
const only = sessionSources[0];
if (!only.thumbnail.isEmpty()) {
finish({ sourceId: only.id, dataUrl: only.thumbnail.toDataURL() });
return;
}
}
try {
await picker.webContents.executeJavaScript(buildPickerInjectScript(), true);
if (!picker.isDestroyed()) picker.show();
} catch {
finish(null);
}
})();
});
});
}