Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# Lekhini
<p align="center">
<img src="./build/icon.png" alt="Lekhini" width="128" height="128" />
</p>

<h1 align="center">Lekhini</h1>

> लेखनी — Sanskrit for *"pen"*. A free, open-source on-screen
> annotation overlay for macOS, Windows, and Linux. A project of
Expand Down
Binary file added build/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ productName: Lekhini
copyright: Copyright © 2026 Open Source Bharat — https://opensourcebharat.org
asar: true

# Single source-of-truth for the app icon. electron-builder auto-
# generates the platform-specific .icns (macOS) and .ico (Windows)
# from this PNG. Recommended: 1024×1024, transparent background.
icon: build/icon.png

directories:
output: release
buildResources: build
Expand Down
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

246 changes: 209 additions & 37 deletions src/main/capture.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { clipboard, desktopCapturer, dialog, ipcMain, nativeImage, screen, BrowserWindow } from 'electron';
import {
clipboard,
desktopCapturer,
dialog,
ipcMain,
nativeImage,
screen,
shell,
BrowserWindow,
} from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { getOverlays } from './windows/overlay';
import { notifyStatus, onFocusRecheck, screenStatus } from './permissions';
import { persisted } from './persistence';
import { patch as patchHub } from './hub';

interface Rect {
x: number;
Expand Down Expand Up @@ -37,7 +49,50 @@ export function getFocusedDisplayId(): number {
return screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).id;
}

// ─── Permission gating ──────────────────────────────────────────────
//
// macOS controls whether `desktopCapturer.getSources()` returns
// anything. We never preflight-bail: on 'granted' we proceed, on
// 'not-determined' we still call so the OS shows its native
// first-run prompt, and only on 'denied' do we surface our own
// modal. When the user grants the permission and refocuses Lekhini,
// onFocusRecheck retries the pending capture automatically.

type PendingAction = 'capture' | 'clipboard';
let pendingAction: PendingAction | null = null;

function broadcast(channel: string, payload?: unknown): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(channel, payload);
}
}

function needsScreenModal(): boolean {
if (process.platform !== 'darwin') return false;
const status = screenStatus();
return status === 'denied' || status === 'restricted';
}

function gateScreenForCapture(action: PendingAction): boolean {
if (!needsScreenModal()) return true;
broadcast('permissions:needed', { reason: 'screen' });
pendingAction = action;
onFocusRecheck((result) => {
// Pass the fresh status explicitly so renderers don't see the
// possibly-stale getMediaAccessStatus cache.
notifyStatus(result.screen, result.probeError);
if (result.screen !== 'granted') return;
const a = pendingAction;
pendingAction = null;
if (a === 'capture') void captureFocusedDisplay();
else if (a === 'clipboard') void copyFocusedSnipToClipboard();
});
return false;
}

export async function copyFocusedSnipToClipboard(): Promise<void> {
if (!gateScreenForCapture('clipboard')) return;

const displayId = getFocusedDisplayId();
const rect = snipSelections.get(displayId);
if (!rect) return;
Expand All @@ -52,57 +107,100 @@ export async function copyFocusedSnipToClipboard(): Promise<void> {
setSnipSelection(displayId, null);
await waitMs(60);

const pngBase64 = await captureCroppedComposite(overlay, display, rect);
if (!pngBase64) return;
const png = await captureCroppedComposite(overlay, display, rect);
if (!png) {
if (handleCaptureFailure()) return;
broadcast('capture:error', {
message: "Couldn't read the screen — try again.",
recoverable: true,
});
return;
}

const buf = Buffer.from(pngBase64, 'base64');
const img = nativeImage.createFromBuffer(buf);
const img = nativeImage.createFromBuffer(png);
clipboard.writeImage(img);
}

export async function captureFocusedDisplay(): Promise<void> {
if (!gateScreenForCapture('capture')) return;

const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
const overlay = getOverlays().get(display.id);
const selection = snipSelections.get(display.id) ?? null;

if (!overlay || overlay.isDestroyed()) {
// No overlay: fall back to a raw full-display capture.
const dataUrl = await fullDisplayDataUrl(display);
if (dataUrl) await persistDataUrl(dataUrl);
// No overlay: fall back to a raw full-display capture (uncomposited).
const raw = await fullDisplayPng(display);
if (!raw) {
handleCaptureFailure();
return;
}
await persistPng(raw);
return;
}

if (selection) {
// Clear the dashed selection visually before the screen grab.
setSnipSelection(display.id, null);
await waitMs(60);
const pngBase64 = await captureCroppedComposite(overlay, display, selection);
if (pngBase64) await persistDataUrl(`data:image/png;base64,${pngBase64}`);
const png = await captureCroppedComposite(overlay, display, selection);
if (!png) {
handleCaptureFailure();
return;
}
await persistPng(png);
return;
}

// No selection: full-display composite (existing behavior).
const dataUrl = await fullDisplayDataUrl(display);
if (!dataUrl) return;
const screenPng = await fullDisplayPng(display);
if (!screenPng) {
handleCaptureFailure();
return;
}

await new Promise<void>((resolve) => {
const channel = 'capture:screenshot:result';
const handler = async (_evt: Electron.IpcMainInvokeEvent, pngBase64: string) => {
const handler = async (_evt: Electron.IpcMainInvokeEvent, png: Uint8Array) => {
ipcMain.removeHandler(channel);
await persistDataUrl(`data:image/png;base64,${pngBase64}`);
await persistPng(Buffer.from(png));
resolve();
};
ipcMain.handle(channel, handler);
overlay.webContents.send('overlay:screenshot', { dataUrl });
// Send the PNG as a Uint8Array — structured-clones across IPC as
// raw bytes (no base64 round-trip), about 33% less data than the
// old dataURL string and noticeably faster decode in the renderer
// via createImageBitmap.
overlay.webContents.send('overlay:screenshot', { png: screenPng });
setTimeout(() => {
ipcMain.removeHandler(channel);
resolve();
}, 8000);
});
}

async function fullDisplayDataUrl(display: Electron.Display): Promise<string | null> {
// Called when desktopCapturer returns nothing. On macOS this almost
// always means permission was denied at the system prompt (which we
// can't intercept). Re-check status and surface the modal so the user
// gets feedback instead of silent failure.
function handleCaptureFailure(): boolean {
if (needsScreenModal()) {
broadcast('permissions:needed', { reason: 'screen' });
pendingAction = 'capture';
onFocusRecheck((result) => {
notifyStatus(result.screen, result.probeError);
if (result.screen === 'granted' && pendingAction === 'capture') {
pendingAction = null;
void captureFocusedDisplay();
}
});
return true;
}
return false;
}

async function fullDisplayPng(display: Electron.Display): Promise<Buffer | null> {
const sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: {
Expand All @@ -113,26 +211,28 @@ async function fullDisplayDataUrl(display: Electron.Display): Promise<string | n
const matching =
sources.find((s) => Number(s.display_id) === display.id) ?? sources[0];
if (!matching) return null;
return matching.thumbnail.toDataURL();
// toPNG() goes straight to a Buffer — no base64 dataURL middleman.
// Buffer is structured-clonable over IPC as raw bytes.
return matching.thumbnail.toPNG();
}

async function captureCroppedComposite(
overlay: BrowserWindow,
display: Electron.Display,
rect: Rect,
): Promise<string | null> {
const screenDataUrl = await fullDisplayDataUrl(display);
if (!screenDataUrl) return null;
): Promise<Buffer | null> {
const screenPng = await fullDisplayPng(display);
if (!screenPng) return null;

return new Promise<string | null>((resolve) => {
return new Promise<Buffer | null>((resolve) => {
const channel = 'capture:snip:result';
const handler = (_evt: Electron.IpcMainInvokeEvent, pngBase64: string) => {
const handler = (_evt: Electron.IpcMainInvokeEvent, png: Uint8Array) => {
ipcMain.removeHandler(channel);
resolve(pngBase64 || null);
resolve(png && png.byteLength > 0 ? Buffer.from(png) : null);
};
ipcMain.handle(channel, handler);
overlay.webContents.send('overlay:snip', {
dataUrl: screenDataUrl,
png: screenPng,
rect,
scaleFactor: display.scaleFactor,
});
Expand All @@ -147,22 +247,67 @@ function waitMs(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function persistDataUrl(dataUrl: string): Promise<void> {
const base64 = dataUrl.replace(/^data:image\/png;base64,/, '');
const buf = Buffer.from(base64, 'base64');
// Default filename: `lekhini-YYYY-MM-DD-HHMMSS.png`. Stable enough to
// sort chronologically, short enough to read at a glance.
function defaultFilename(): string {
const d = new Date();
const pad = (n: number): string => String(n).padStart(2, '0');
return (
`lekhini-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
`-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}.png`
);
}

const day = new Date().toISOString().slice(0, 10);
const defaultDir = path.join(os.homedir(), 'Pictures', 'Lekhini', day);
fs.mkdirSync(defaultDir, { recursive: true });
const defaultPath = path.join(defaultDir, `lekhini-${Date.now()}.png`);
function defaultSaveDir(): string {
return path.join(os.homedir(), 'Pictures', 'Lekhini');
}

const result = await dialog.showSaveDialog({
defaultPath,
filters: [{ name: 'PNG', extensions: ['png'] }],
});
async function persistPng(buf: Buffer): Promise<void> {
const state = persisted();
const shouldPrompt = state.alwaysAskSavePath || !state.saveDir;

if (result.canceled || !result.filePath) return;
fs.writeFileSync(result.filePath, buf);
let target: string;
if (shouldPrompt) {
const seedDir = state.saveDir ?? defaultSaveDir();
try {
fs.mkdirSync(seedDir, { recursive: true });
} catch {
// Non-fatal — showSaveDialog will still work even if mkdir failed.
}
const result = await dialog.showSaveDialog({
title: 'Save annotated screenshot',
defaultPath: path.join(seedDir, defaultFilename()),
filters: [{ name: 'PNG', extensions: ['png'] }],
});
if (result.canceled || !result.filePath) return;
target = result.filePath;
// Remember the chosen folder so the next save can skip the dialog.
// Going through the hub keeps every renderer's Settings panel in sync.
patchHub({ saveDir: path.dirname(target) });
} else {
// saveDir is non-null here per the check above.
const dir = state.saveDir!;
try {
fs.mkdirSync(dir, { recursive: true });
} catch (err) {
broadcast('capture:error', {
message: `Couldn't create save folder ${dir}: ${(err as Error).message}`,
recoverable: true,
});
return;
}
target = path.join(dir, defaultFilename());
}

try {
fs.writeFileSync(target, buf);
broadcast('capture:saved', { path: target });
} catch (err) {
broadcast('capture:error', {
message: `Couldn't save to ${target}: ${(err as Error).message}`,
recoverable: true,
});
}
}

export function registerCaptureIpc() {
Expand All @@ -172,5 +317,32 @@ export function registerCaptureIpc() {
ipcMain.handle('snip:clear', (_evt, payload: { displayId: number }) => {
setSnipSelection(payload.displayId, null);
});
// Renderer-triggered folder picker, used by the "Change…" button in
// Settings → File save. Returns the chosen path so the renderer can
// patch the hub with it (which is what persists + broadcasts to
// every window). We don't save here ourselves — the renderer owns
// the round-trip to keep the hub the single source of truth.
ipcMain.handle('settings:save-dir:pick', async () => {
const state = persisted();
const result = await dialog.showOpenDialog({
title: 'Choose save folder for screenshots',
defaultPath: state.saveDir ?? defaultSaveDir(),
properties: ['openDirectory', 'createDirectory'],
});
if (result.canceled || !result.filePaths.length) return null;
return result.filePaths[0];
});
// Reveal-in-Finder / file-manager link for the saved-toast.
ipcMain.handle('shell:open-path', async (_evt, p: string) => {
if (!p) return;
try {
// showItemInFolder reveals the file with it selected — better
// than openPath which just opens the parent folder.
shell.showItemInFolder(p);
} catch {
// Fall back to opening the containing folder.
void shell.openPath(path.dirname(p));
}
});
void BrowserWindow;
}
Loading
Loading