From eef9a6231f9a3440fc0658f0197a652b2d05f04f Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 00:06:23 +0530 Subject: [PATCH 01/17] feat: tighten pencil engine, redesign arrow, refresh toolbar icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coordinated improvements driven by user feedback that small handwriting strokes were blobby, the arrow looked unfinished, and the chrome felt dated. Stroke engine — fine handwriting now works: - perfect-freehand pencil thinning 0.08 → 0.04 and streamline 0.26 → 0.18 so sub-pixel widths stop being pinched out by outline smoothing. - Pencil width presets shifted to [0.5, 1, 2, 3, 6] and default pencil width dropped from 3 to 2 (DEFAULT_SETTINGS + persistence defaults). - Canvas DPR floored at 2× on both LiveLayer and CommittedLayer so strokes stay crisp on classroom IFPs / external 96-DPI monitors that report devicePixelRatio = 1. Arrow tool — original parametric geometry: - Head length scales with both arrow length and stroke width, capped at 45% of total length, floored at 12px. - Slender 0.72 aspect ratio + swept-back chevron notch read as a designed arrowhead instead of a flat-based triangle. - Shaft ends at the notch, eliminating the round-cap blob that small arrows used to show at the join. Toolbar UI — Phosphor-inspired refresh: - All toolbar icons redrawn in the Phosphor Icons regular-weight visual language: 1.4 stroke (was 1.8), simpler geometry, no decorative ferrules / ribs. Fresh hand-drawn SVGs, not verbatim copies of any third-party set. - Tool / action buttons tightened from 30×30 to 26×26. - Color swatches: 18px circles → 22px rounded squares with 2px border; hover scale 1.15 → 1.08 for a calmer feel. --- package-lock.json | 9 +- src/main/persistence.ts | 2 +- src/renderer/overlay/canvas/CommittedLayer.ts | 4 +- src/renderer/overlay/canvas/LiveLayer.ts | 12 +- src/renderer/overlay/canvas/drawItem.ts | 64 +++++-- src/renderer/toolbar/icons.tsx | 163 +++++++++++------- src/renderer/toolbar/styles.css | 32 ++-- src/shared/constants.ts | 4 +- 8 files changed, 191 insertions(+), 99 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2416f18..09e8166 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "pen-trader", - "version": "0.1.0", + "name": "@opensourcebharat/lekhini", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "pen-trader", - "version": "0.1.0", + "name": "@opensourcebharat/lekhini", + "version": "1.0.0", + "license": "MIT", "dependencies": { "active-win": "^8.2.1", "electron-store": "^10.0.0", diff --git a/src/main/persistence.ts b/src/main/persistence.ts index d9f69b3..bfa427f 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -16,7 +16,7 @@ export const PERSISTED_DEFAULTS: PersistedState = { orientation: 'v', theme: 'dark', profile: 'general', - perToolWidth: { pencil: 3, pen: 4, eraser: 20, highlighter: 18 }, + perToolWidth: { pencil: 2, pen: 4, eraser: 20, highlighter: 18 }, color: GRAPHITE_COLOR, activeTool: 'pencil', }; diff --git a/src/renderer/overlay/canvas/CommittedLayer.ts b/src/renderer/overlay/canvas/CommittedLayer.ts index 24112bb..e1d0a56 100644 --- a/src/renderer/overlay/canvas/CommittedLayer.ts +++ b/src/renderer/overlay/canvas/CommittedLayer.ts @@ -13,13 +13,13 @@ export class CommittedLayer { const ctx = canvas.getContext('2d', { alpha: true }); if (!ctx) throw new Error('CommittedLayer: 2D context unavailable'); this.ctx = ctx; - this.dpr = window.devicePixelRatio || 1; + this.dpr = Math.max(window.devicePixelRatio || 1, 2); this.resize(); } resize(): void { const { innerWidth: w, innerHeight: h } = window; - this.dpr = window.devicePixelRatio || 1; + this.dpr = Math.max(window.devicePixelRatio || 1, 2); this.canvas.width = Math.floor(w * this.dpr); this.canvas.height = Math.floor(h * this.dpr); this.canvas.style.width = `${w}px`; diff --git a/src/renderer/overlay/canvas/LiveLayer.ts b/src/renderer/overlay/canvas/LiveLayer.ts index 029f379..a692ae1 100644 --- a/src/renderer/overlay/canvas/LiveLayer.ts +++ b/src/renderer/overlay/canvas/LiveLayer.ts @@ -13,13 +13,21 @@ export class LiveLayer { const ctx = canvas.getContext('2d', { alpha: true }); if (!ctx) throw new Error('LiveLayer: 2D context unavailable'); this.ctx = ctx; - this.dpr = window.devicePixelRatio || 1; + // Floor at 2× so strokes stay crisp on standard-DPI external monitors + // (classroom IFPs are commonly 96 DPI / DPR 1) without ever downscaling + // a true Retina or higher display. ~4× canvas memory vs DPR=1, which + // is fine for one full-screen overlay. + this.dpr = Math.max(window.devicePixelRatio || 1, 2); this.resize(); } resize(): void { const { innerWidth: w, innerHeight: h } = window; - this.dpr = window.devicePixelRatio || 1; + // Floor at 2× so strokes stay crisp on standard-DPI external monitors + // (classroom IFPs are commonly 96 DPI / DPR 1) without ever downscaling + // a true Retina or higher display. ~4× canvas memory vs DPR=1, which + // is fine for one full-screen overlay. + this.dpr = Math.max(window.devicePixelRatio || 1, 2); this.canvas.width = Math.floor(w * this.dpr); this.canvas.height = Math.floor(h * this.dpr); this.canvas.style.width = `${w}px`; diff --git a/src/renderer/overlay/canvas/drawItem.ts b/src/renderer/overlay/canvas/drawItem.ts index 30ca52d..c2ad9e8 100644 --- a/src/renderer/overlay/canvas/drawItem.ts +++ b/src/renderer/overlay/canvas/drawItem.ts @@ -130,14 +130,20 @@ function drawStroke( end: { taper: 0, cap: true }, }; } else if (isPencil) { + // Tuned for fine-handwriting at sub-pixel widths: lower thinning so + // a 0.5–1px pencil doesn't get pinched into invisibility by + // perfect-freehand's outline algorithm, and lower streamline so the + // line actually follows the writer's wrist instead of being eaten + // by post-hoc smoothing. Tapers shrink proportionally so very fine + // strokes still end cleanly. opts = { - thinning: 0.08, - smoothing: 0.32, - streamline: 0.26, + thinning: 0.04, + smoothing: 0.28, + streamline: 0.18, easing: (t: number) => t, simulatePressure: false, - start: { taper: Math.min(effectiveWidth * 0.6, 6), cap: true }, - end: { taper: Math.min(effectiveWidth * 0.9, 10), cap: true }, + start: { taper: Math.min(effectiveWidth * 0.5, 4), cap: true }, + end: { taper: Math.min(effectiveWidth * 0.7, 7), cap: true }, }; } else { // pen @@ -292,21 +298,59 @@ function drawEllipse( function drawArrow(ctx: CanvasRenderingContext2D, item: Extract): void { const dx = item.p2.x - item.p1.x; const dy = item.p2.y - item.p1.y; + const length = Math.hypot(dx, dy); + if (length < 1) return; const angle = Math.atan2(dy, dx); - const head = Math.max(8, item.width * 3); + + // Head geometry: parametric in both length and width, capped at 45% + // of total length so very short arrows don't become all head, and + // floored so very thin arrows still read as arrows. The 0.72 aspect + // ratio (width as fraction of length) gives a slender, "designed" + // silhouette rather than the chunky 90° triangle a fixed-angle head + // produces. Notch at 0.22 of head length pulls the back inward so + // the head reads as a swept chevron, not a flat-based pyramid. + const widthBoost = 1 + item.width / 30; + const headLen = Math.max(12, Math.min(length * 0.22 * widthBoost, length * 0.45)); + const headHalfW = headLen * 0.36; + const notchDepth = headLen * 0.22; + + const cosA = Math.cos(angle); + const sinA = Math.sin(angle); + const perpX = -sinA; + const perpY = cosA; + + const tipX = item.p2.x; + const tipY = item.p2.y; + const backX = item.p2.x - headLen * cosA; + const backY = item.p2.y - headLen * sinA; + const notchX = backX + notchDepth * cosA; + const notchY = backY + notchDepth * sinA; + const wingLX = backX + headHalfW * perpX; + const wingLY = backY + headHalfW * perpY; + const wingRX = backX - headHalfW * perpX; + const wingRY = backY - headHalfW * perpY; + ctx.save(); ctx.strokeStyle = item.color; ctx.fillStyle = item.color; ctx.lineWidth = item.width; ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Shaft stops at the notch — if we drew through to p2 the head fill + // would overlap the shaft's round cap and give small arrows a visible + // blob at the join. Ending at the notch makes the silhouette one + // continuous shape. ctx.beginPath(); ctx.moveTo(item.p1.x, item.p1.y); - ctx.lineTo(item.p2.x, item.p2.y); + ctx.lineTo(notchX, notchY); ctx.stroke(); + ctx.beginPath(); - ctx.moveTo(item.p2.x, item.p2.y); - ctx.lineTo(item.p2.x - head * Math.cos(angle - Math.PI / 7), item.p2.y - head * Math.sin(angle - Math.PI / 7)); - ctx.lineTo(item.p2.x - head * Math.cos(angle + Math.PI / 7), item.p2.y - head * Math.sin(angle + Math.PI / 7)); + ctx.moveTo(tipX, tipY); + ctx.lineTo(wingLX, wingLY); + ctx.lineTo(notchX, notchY); + ctx.lineTo(wingRX, wingRY); ctx.closePath(); ctx.fill(); ctx.restore(); diff --git a/src/renderer/toolbar/icons.tsx b/src/renderer/toolbar/icons.tsx index dda1fce..f5e944b 100644 --- a/src/renderer/toolbar/icons.tsx +++ b/src/renderer/toolbar/icons.tsx @@ -1,109 +1,135 @@ import type { JSX } from 'solid-js'; +// Toolbar icon set, redrawn in the Phosphor Icons visual language +// (https://phosphoricons.com — MIT). Each glyph is a fresh +// implementation tailored to Lekhini's 22×22 toolbar slot, not a +// verbatim copy of any Phosphor SVG. Stroke = 1.4 to match Phosphor's +// "regular" weight at 24px equivalent. Pure currentColor; the toolbar +// theme decides the actual hue via CSS. + const SVG = (children: JSX.Element): JSX.Element => ( - + {children} ); export const Icons = { + // PencilSimple — tilted shaft with a flat tip. No ferrule clutter. pencil: () => SVG( <> - {/* Pencil body with hex-style ferrule lines and a tip */} - - + , ), + // Pen — angled barrel with a small nib square at the tip. pen: () => SVG( <> - {/* Fountain pen: tapered body + visible nib slit */} - - - - + + , ), + // Highlighter — square chisel head over a tapered shaft. highlighter: () => SVG( <> - - - + + + , ), + // Eraser — block-tip eraser with a base shadow line. eraser: () => SVG( <> - - + + + , ), + // Hand — four-finger open palm. hand: () => SVG( <> - - - - + + + + , ), - line: () => SVG(), + // Minus — single horizontal line. + line: () => SVG(), + // TrendUp — diagonal line with terminal dots. trendline: () => SVG( <> - - - + + + , ), + // Stacked horizontal lines — Fibonacci/retracement. fib: () => SVG( <> - - - - + + + + , ), - region: () => SVG(), + // Selection — rounded dashed square. + region: () => SVG(), + // Crop — four L-corners with a dashed inner outline. snip: () => SVG( <> - + , ), + // Circle — clean ellipse. ellipse: () => SVG(), + // Chalkboard — rounded frame with two stand legs. whiteboard: () => SVG( <> - - - + + + , ), + // ArrowRight — single shaft with a clean chevron tip. arrow: () => SVG( <> - + , ), + // TextT — capital T with serifs at top and base. text: () => SVG( <> - + , ), + // ArrowUUpLeft — undo arrow. undo: () => SVG( <> @@ -111,6 +137,7 @@ export const Icons = { , ), + // ArrowUUpRight — redo arrow. redo: () => SVG( <> @@ -118,36 +145,41 @@ export const Icons = { , ), + // Trash — clean lid + bin, no inner ribs. clear: () => SVG( <> - - - + + + , ), + // Camera — body + shutter + grip notch. camera: () => SVG( <> - + , ), pause: () => SVG( <> - - + + , ), - play: () => SVG(), + play: () => + SVG(), + // Rows / orientation — two stacked rounded bars. orient: () => SVG( <> - - + + , ), + // X — diagonals. close: () => SVG( <> @@ -156,44 +188,52 @@ export const Icons = { , ), minus: () => SVG(), + // Sun — circle + 8 short cardinal/diagonal rays (Phosphor regular style). sun: () => SVG( <> - - - - - - - - - + + + + + + + + + , ), moon: () => SVG(), + // Gear — simpler 6-tooth wheel + center hub. Phosphor's actual gear + // has 8 teeth at this size; 6 reads cleaner in the 22px slot. gear: () => SVG( <> - + + + + + + + + , ), - check: () => - SVG(), + check: () => SVG(), + // Lines — three stacked horizontal bars, varying weight. thickness: () => SVG( <> - {/* Three stacked lines of increasing weight — a universal */} - {/* "thickness" / "stroke weight" icon. */} - - - + + + , ), + // ArrowsInSimple — two opposing chevrons. collapse: () => SVG( <> - {/* Inward-pointing chevrons — "minimize / collapse". */} , @@ -219,4 +259,3 @@ export const Logo = (): JSX.Element => ( ); - diff --git a/src/renderer/toolbar/styles.css b/src/renderer/toolbar/styles.css index 4be445b..87c07c2 100644 --- a/src/renderer/toolbar/styles.css +++ b/src/renderer/toolbar/styles.css @@ -410,7 +410,7 @@ html, body { .bar[data-orient='v'] .tools-zone, .bar[data-orient='v'] .actions-zone { display: grid; - grid-template-columns: repeat(2, 30px); + grid-template-columns: repeat(2, 26px); gap: 4px; background: var(--group-bg); border: 1px solid var(--group-border); @@ -449,9 +449,9 @@ html, body { background: transparent; color: var(--text); border: 1px solid transparent; - border-radius: 6px; - width: 30px; - height: 30px; + border-radius: 7px; + width: 26px; + height: 26px; display: inline-flex; align-items: center; justify-content: center; @@ -486,7 +486,7 @@ html, body { color: var(--gold); opacity: 1; } -.tool-btn svg, .action-btn svg { width: 19px; height: 19px; } +.tool-btn svg, .action-btn svg { width: 18px; height: 18px; } /* ─────────────────── PINNED COLOR / WIDTH ─────────────────── */ @@ -500,26 +500,26 @@ html, body { gap: 12px; } -.swatches { display: flex; gap: 5px; align-items: center; } +.swatches { display: flex; gap: 6px; align-items: center; } .swatch { - width: 18px; - height: 18px; - border-radius: 50%; - border: 1.5px solid var(--swatch-border); + width: 22px; + height: 22px; + border-radius: 5px; + border: 2px solid var(--swatch-border); cursor: pointer; - transition: transform 0.08s, border-color 0.12s, box-shadow 0.12s; + transition: transform 0.10s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.14s, box-shadow 0.14s; } -.swatch:hover { transform: scale(1.15); border-color: var(--swatch-border-hover); } +.swatch:hover { transform: scale(1.08); border-color: var(--swatch-border-hover); } .swatch.active { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(216, 181, 114, 0.30); } .hex-pick { position: relative; - width: 20px; - height: 20px; - border-radius: 50%; - border: 1.5px dashed var(--swatch-border); + width: 22px; + height: 22px; + border-radius: 5px; + border: 2px dashed var(--swatch-border); display: inline-flex; align-items: center; justify-content: center; diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 049306e..1e615c8 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -7,7 +7,7 @@ export const GRAPHITE_COLOR = '#3a3a3c'; export const DEFAULT_SETTINGS: ToolSettings = { color: GRAPHITE_COLOR, - width: 3, + width: 2, opacity: 1, }; @@ -81,7 +81,7 @@ export const THICKNESS_PRESETS: Record< 'pencil' | 'pen' | 'eraser' | 'highlighter', number[] > = { - pencil: [1, 2, 3, 5, 8], + pencil: [0.5, 1, 2, 3, 6], pen: [2, 4, 8, 14, 22], eraser: [10, 18, 28, 44, 64], highlighter: [12, 18, 26, 34, 44], From e46e6533217ed57e34f4ef1f4203fb9e3c212387 Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 00:18:23 +0530 Subject: [PATCH 02/17] feat: permission-aware screenshot + remember save folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When macOS Screen Recording was previously denied, the screenshot button silently did nothing — desktopCapturer.getSources() returned an empty array, capture exited at the first null branch, the renderer's 8-second IPC handler timed out, and the user had no feedback. This change closes that loop end-to-end across all three desktop OSes. Permission flow: - Main process never preflight-bails. On macOS 'granted' or 'not-determined' the capture proceeds, letting the OS surface its native first-run prompt where applicable. On Wayland Linux the xdg-desktop-portal natively shows 'Allow once / Allow always / Deny' at call time — exactly the granular UX the OS gives us. - On macOS 'denied', a new PermissionModal opens in the toolbar with 'Open System Settings' (deep-links to Privacy & Security → Screen Recording) and 'Recheck' buttons. The modal auto-rechecks when the toolbar window regains focus, so granting in Settings + alt-tab back closes the modal and re-runs the pending capture automatically. - New permissions:needed / permissions:status IPC channels broadcast state to every renderer. Save destination: - New persisted fields saveDir + alwaysAskSavePath flow through the hub like other settings. - First save shows the OS save dialog (default ~/Pictures/Lekhini/). The chosen folder is remembered; subsequent saves auto-write to /lekhini-YYYY-MM-DD-HHMMSS.png with no dialog. - Settings panel gains a 'File save' section: an 'Always ask where to save' toggle and a path button that opens a folder picker. - fs.writeFileSync wrapped in try/catch; failures emit capture:error. Renderer feedback: - New Toast component (success / error / info) renders a stack bottom-right with a Reveal action that calls shell.showItemInFolder. - Success path: 'Saved to ~/Pictures/Lekhini/lekhini-…png [Reveal]'. - Error path: surfaces the underlying message; auto-closes at 6s. Honest limits: - macOS cannot programmatically re-prompt once denied — only the Settings-deep-link + focus-recheck pattern is available, which is what reputable Mac capture apps already do. - Windows + X11 have no permission gate to surface; capture just works there. The Wayland portal owns its own UI. --- src/main/capture.ts | 198 ++++++++++++++++++-- src/main/hub.ts | 21 +++ src/main/permissions.ts | 43 ++++- src/main/persistence.ts | 11 ++ src/main/preload.ts | 25 +++ src/renderer/penApi.d.ts | 18 +- src/renderer/toolbar/App.tsx | 100 ++++++++++ src/renderer/toolbar/PermissionModal.tsx | 111 +++++++++++ src/renderer/toolbar/Toast.tsx | 89 +++++++++ src/renderer/toolbar/styles.css | 225 +++++++++++++++++++++++ src/shared/types.ts | 36 +++- 11 files changed, 857 insertions(+), 20 deletions(-) create mode 100644 src/renderer/toolbar/PermissionModal.tsx create mode 100644 src/renderer/toolbar/Toast.tsx diff --git a/src/main/capture.ts b/src/main/capture.ts index 653e29a..bbf1d59 100644 --- a/src/main/capture.ts +++ b/src/main/capture.ts @@ -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; @@ -37,7 +49,48 @@ 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((newStatus) => { + notifyStatus(); + if (newStatus !== '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 { + if (!gateScreenForCapture('clipboard')) return; + const displayId = getFocusedDisplayId(); const rect = snipSelections.get(displayId); if (!rect) return; @@ -53,7 +106,14 @@ export async function copyFocusedSnipToClipboard(): Promise { await waitMs(60); const pngBase64 = await captureCroppedComposite(overlay, display, rect); - if (!pngBase64) return; + if (!pngBase64) { + 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); @@ -61,6 +121,8 @@ export async function copyFocusedSnipToClipboard(): Promise { } export async function captureFocusedDisplay(): Promise { + if (!gateScreenForCapture('capture')) return; + const cursorPoint = screen.getCursorScreenPoint(); const display = screen.getDisplayNearestPoint(cursorPoint); const overlay = getOverlays().get(display.id); @@ -69,7 +131,11 @@ export async function captureFocusedDisplay(): Promise { if (!overlay || overlay.isDestroyed()) { // No overlay: fall back to a raw full-display capture. const dataUrl = await fullDisplayDataUrl(display); - if (dataUrl) await persistDataUrl(dataUrl); + if (!dataUrl) { + handleCaptureFailure(); + return; + } + await persistDataUrl(dataUrl); return; } @@ -78,13 +144,20 @@ export async function captureFocusedDisplay(): Promise { setSnipSelection(display.id, null); await waitMs(60); const pngBase64 = await captureCroppedComposite(overlay, display, selection); - if (pngBase64) await persistDataUrl(`data:image/png;base64,${pngBase64}`); + if (!pngBase64) { + handleCaptureFailure(); + return; + } + await persistDataUrl(`data:image/png;base64,${pngBase64}`); return; } // No selection: full-display composite (existing behavior). const dataUrl = await fullDisplayDataUrl(display); - if (!dataUrl) return; + if (!dataUrl) { + handleCaptureFailure(); + return; + } await new Promise((resolve) => { const channel = 'capture:screenshot:result'; @@ -102,6 +175,26 @@ export async function captureFocusedDisplay(): Promise { }); } +// 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((newStatus) => { + notifyStatus(); + if (newStatus === 'granted' && pendingAction === 'capture') { + pendingAction = null; + void captureFocusedDisplay(); + } + }); + return true; + } + return false; +} + async function fullDisplayDataUrl(display: Electron.Display): Promise { const sources = await desktopCapturer.getSources({ types: ['screen'], @@ -147,22 +240,70 @@ function waitMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +// 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` + ); +} + +function defaultSaveDir(): string { + return path.join(os.homedir(), 'Pictures', 'Lekhini'); +} + async function persistDataUrl(dataUrl: string): Promise { const base64 = dataUrl.replace(/^data:image\/png;base64,/, ''); const buf = Buffer.from(base64, 'base64'); - 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`); + const state = persisted(); + const shouldPrompt = state.alwaysAskSavePath || !state.saveDir; - const result = await dialog.showSaveDialog({ - defaultPath, - filters: [{ name: 'PNG', extensions: ['png'] }], - }); + 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()); + } - if (result.canceled || !result.filePath) return; - fs.writeFileSync(result.filePath, buf); + 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() { @@ -172,5 +313,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; } diff --git a/src/main/hub.ts b/src/main/hub.ts index 44928d3..d28afb2 100644 --- a/src/main/hub.ts +++ b/src/main/hub.ts @@ -27,6 +27,8 @@ export interface HubState { settingsOpen: boolean; thicknessFlyoutOpen: boolean; perToolWidth: PerToolWidth; + saveDir: string | null; + alwaysAskSavePath: boolean; } const state: HubState = { @@ -42,6 +44,8 @@ const state: HubState = { settingsOpen: false, thicknessFlyoutOpen: false, perToolWidth: { pencil: 3, pen: 4, eraser: 20, highlighter: 18 }, + saveDir: null, + alwaysAskSavePath: false, }; const subscribers = new Set(); @@ -100,6 +104,10 @@ export function hydrateFromPersistence(): void { highlighter: typeof storedW.highlighter === 'number' ? storedW.highlighter : PERSISTED_DEFAULTS.perToolWidth.highlighter, }; state.activeTool = VALID_TOOLS.has(p.activeTool) ? p.activeTool : 'pencil'; + // Save destination: null until the user picks one. Schema-tolerant — + // older installs without this key fall through to the default. + state.saveDir = typeof p.saveDir === 'string' ? p.saveDir : null; + state.alwaysAskSavePath = typeof p.alwaysAskSavePath === 'boolean' ? p.alwaysAskSavePath : false; // If the active tool is pencil, the canonical color is graphite — // don't restore a stray non-graphite value from a previous session. const colorForTool = @@ -247,6 +255,19 @@ export function patch(update: HubStateUpdate) { changed.add('settingsOpen'); } } + if (update.saveDir !== undefined && update.saveDir !== state.saveDir) { + state.saveDir = update.saveDir; + changed.add('saveDir'); + save('saveDir', state.saveDir); + } + if ( + update.alwaysAskSavePath !== undefined && + update.alwaysAskSavePath !== state.alwaysAskSavePath + ) { + state.alwaysAskSavePath = update.alwaysAskSavePath; + changed.add('alwaysAskSavePath'); + save('alwaysAskSavePath', state.alwaysAskSavePath); + } broadcast(changed); } diff --git a/src/main/permissions.ts b/src/main/permissions.ts index 4600002..0299465 100644 --- a/src/main/permissions.ts +++ b/src/main/permissions.ts @@ -1,12 +1,16 @@ -import { ipcMain, shell, systemPreferences } from 'electron'; +import { app, BrowserWindow, ipcMain, shell, systemPreferences } from 'electron'; +import type { ScreenPermissionStatus } from '../shared/types'; export interface PermissionStatus { - screen: 'granted' | 'denied' | 'restricted' | 'not-determined' | 'unknown'; + screen: ScreenPermissionStatus; accessibility: boolean; } export function check(): PermissionStatus { if (process.platform !== 'darwin') { + // On Windows + X11 there is no permission gate. On Wayland the + // portal mediates at call time so we still report 'granted' here + // and let the portal prompt fire when desktopCapturer is invoked. return { screen: 'granted', accessibility: true }; } return { @@ -15,6 +19,10 @@ export function check(): PermissionStatus { }; } +export function screenStatus(): ScreenPermissionStatus { + return check().screen; +} + export function open(which: 'screen' | 'accessibility') { if (process.platform !== 'darwin') return; const urls = { @@ -25,6 +33,37 @@ export function open(which: 'screen' | 'accessibility') { shell.openExternal(urls[which]); } +// Broadcast a permission-status update to every renderer that's +// currently alive. Used by onFocusRecheck and by capture.ts when it +// discovers a denial through the capturer's empty-source return. +export function notifyStatus(): void { + const payload = { screen: screenStatus() }; + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send('permissions:status', payload); + } +} + +// macOS-only: register a one-shot listener that fires the next time +// any of our windows regains focus. The user clicked "Open System +// Settings" in the modal, granted the permission, then alt-tabbed +// back to Lekhini — that focus event is our signal to recheck. The +// listener is auto-cleared after firing so we don't leak handlers +// across modal open/close cycles. +let pendingRecheck: (() => void) | null = null; +export function onFocusRecheck(cb: (status: ScreenPermissionStatus) => void): void { + // Replace any prior pending callback (no point in stacking). + pendingRecheck = () => cb(screenStatus()); + const handler = (): void => { + if (!pendingRecheck) return; + const fn = pendingRecheck; + pendingRecheck = null; + // Defer one tick — macOS sometimes updates TCC state slightly + // after the focus event fires. + setTimeout(fn, 120); + }; + app.once('browser-window-focus', handler); +} + export function registerPermissionsIpc() { ipcMain.handle('permissions:check', () => check()); ipcMain.handle('permissions:open', (_evt, which: 'screen' | 'accessibility') => open(which)); diff --git a/src/main/persistence.ts b/src/main/persistence.ts index bfa427f..677e740 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -8,6 +8,15 @@ export interface PersistedState { perToolWidth: { pencil: number; pen: number; eraser: number; highlighter: number }; color: string; activeTool: ToolId; + // Save destination for screenshot / snip PNGs. `null` until the + // first save — the first save shows the OS dialog so the user + // explicitly picks a folder; that folder is then remembered and + // subsequent saves go straight to it with a timestamped filename. + saveDir: string | null; + // If true, every save shows the OS dialog regardless of saveDir. + // Off by default — the "remember + auto-save" UX is the recommended + // path. Lives in Settings → File save. + alwaysAskSavePath: boolean; } export const PERSISTED_DEFAULTS: PersistedState = { @@ -19,6 +28,8 @@ export const PERSISTED_DEFAULTS: PersistedState = { perToolWidth: { pencil: 2, pen: 4, eraser: 20, highlighter: 18 }, color: GRAPHITE_COLOR, activeTool: 'pencil', + saveDir: null, + alwaysAskSavePath: false, }; interface MinimalStore { diff --git a/src/main/preload.ts b/src/main/preload.ts index 95c4c2e..7729d37 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -63,6 +63,31 @@ const api = { check: () => ipcRenderer.invoke('permissions:check' satisfies IpcChannel), open: (which: 'screen' | 'accessibility') => ipcRenderer.invoke('permissions:open' satisfies IpcChannel, which), + onNeeded: (cb: (payload: { reason: 'screen' }) => void) => + bind('permissions:needed', cb as (v: unknown) => void), + onStatus: ( + cb: (payload: { + screen: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown'; + }) => void, + ) => bind('permissions:status', cb as (v: unknown) => void), + }, + capture: { + onSaved: (cb: (payload: { path: string }) => void) => + bind('capture:saved', cb as (v: unknown) => void), + onError: (cb: (payload: { message: string; recoverable: boolean }) => void) => + bind('capture:error', cb as (v: unknown) => void), + }, + settings: { + // saveDir + alwaysAskSavePath are part of HubState — write them + // via `pen.hub.update({ saveDir, alwaysAskSavePath })`. This + // method only opens the OS folder-picker dialog and returns the + // chosen path so the renderer can patch the hub with it. + pickSaveDir: () => + ipcRenderer.invoke('settings:save-dir:pick' satisfies IpcChannel) as Promise, + }, + shell: { + openPath: (p: string) => + ipcRenderer.invoke('shell:open-path' satisfies IpcChannel, p), }, app: { info: () => diff --git a/src/renderer/penApi.d.ts b/src/renderer/penApi.d.ts index 0c53868..42b8804 100644 --- a/src/renderer/penApi.d.ts +++ b/src/renderer/penApi.d.ts @@ -1,4 +1,4 @@ -import type { HubStateUpdate } from '../shared/types'; +import type { HubStateUpdate, ScreenPermissionStatus } from '../shared/types'; declare global { interface Window { @@ -50,8 +50,22 @@ declare global { setContentSize(payload: { axis: 'h' | 'v'; size: number }): Promise; }; permissions: { - check(): Promise<{ screen: string; accessibility: boolean }>; + check(): Promise<{ screen: ScreenPermissionStatus; accessibility: boolean }>; open(which: 'screen' | 'accessibility'): Promise; + onNeeded(cb: (payload: { reason: 'screen' }) => void): () => void; + onStatus(cb: (payload: { screen: ScreenPermissionStatus }) => void): () => void; + }; + capture: { + onSaved(cb: (payload: { path: string }) => void): () => void; + onError( + cb: (payload: { message: string; recoverable: boolean }) => void, + ): () => void; + }; + settings: { + pickSaveDir(): Promise; + }; + shell: { + openPath(p: string): Promise; }; app: { info(): Promise<{ name: string; version: string }>; diff --git a/src/renderer/toolbar/App.tsx b/src/renderer/toolbar/App.tsx index 40fe4f3..255f140 100644 --- a/src/renderer/toolbar/App.tsx +++ b/src/renderer/toolbar/App.tsx @@ -4,12 +4,15 @@ import { PROFILES, PROFILE_ORDER } from '../../shared/profiles'; import type { Orientation, ProfileId, + ScreenPermissionStatus, Theme, ToolId, ToolSettings, Whiteboard, } from '../../shared/types'; import { Icons, Logo } from './icons'; +import { PermissionModal } from './PermissionModal'; +import { Toast, type ToastItem } from './Toast'; interface HubSnapshot { activeTool: ToolId; @@ -23,6 +26,8 @@ interface HubSnapshot { settingsOpen: boolean; thicknessFlyoutOpen: boolean; perToolWidth: { pencil: number; pen: number; eraser: number; highlighter: number }; + saveDir: string | null; + alwaysAskSavePath: boolean; } type FlyoutTool = 'pencil' | 'pen' | 'eraser' | 'highlighter'; @@ -60,6 +65,20 @@ const TOOL_BY_ID: Record = ALL_TOOLS.reduce( {} as Record, ); +// Display-friendly path: replace the home dir with `~` and ellipsize +// the middle if the result is still long. Pure cosmetic — the toast +// is narrow and a full POSIX path overflows. +function shortenPath(p: string, max = 56): string { + let s = p; + // Best-effort home detection — `process.env.HOME` is not available + // in the renderer; fall back to the common macOS / Linux prefix. + const home = /^\/Users\/[^/]+/.exec(s)?.[0] ?? /^\/home\/[^/]+/.exec(s)?.[0]; + if (home && s.startsWith(home)) s = '~' + s.slice(home.length); + if (s.length <= max) return s; + const tail = s.slice(-(max - 3)); + return '…' + tail; +} + export function ToolbarApp() { const [hub, setHub] = createSignal({ activeTool: 'pencil', @@ -73,7 +92,24 @@ export function ToolbarApp() { settingsOpen: false, thicknessFlyoutOpen: false, perToolWidth: { pencil: 3, pen: 4, eraser: 20, highlighter: 18 }, + saveDir: null, + alwaysAskSavePath: false, }); + const [permModalOpen, setPermModalOpen] = createSignal(false); + const [permStatus, setPermStatus] = createSignal(null); + const [toasts, setToasts] = createSignal([]); + let toastSeq = 0; + const pushToast = (t: Omit): void => { + const item: ToastItem = { ...t, id: ++toastSeq }; + setToasts((prev) => { + // Cap visible toasts at 3 — older ones fall off. + const next = [...prev, item]; + return next.length > 3 ? next.slice(next.length - 3) : next; + }); + }; + const dismissToast = (id: number): void => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }; const [platform, setPlatform] = createSignal('darwin'); const [hint, setHint] = createSignal(''); const [settingsOnLeft, setSettingsOnLeft] = createSignal(false); @@ -101,6 +137,33 @@ export function ToolbarApp() { }); onCleanup(off); + // ── Permission + capture event wiring ──────────────────────── + // Main process emits 'permissions:needed' the moment a capture + // can't proceed; we mount the modal. 'permissions:status' lets + // main inform us of the current screen-recording state — used by + // the modal's Recheck button. + const offNeeded = window.pen.permissions.onNeeded(() => setPermModalOpen(true)); + const offStatus = window.pen.permissions.onStatus((p) => { + setPermStatus(p.screen); + if (p.screen === 'granted') setPermModalOpen(false); + }); + const offSaved = window.pen.capture.onSaved((p) => { + pushToast({ + kind: 'success', + message: `Saved to ${shortenPath(p.path)}`, + action: { label: 'Reveal', run: () => void window.pen.shell.openPath(p.path) }, + }); + }); + const offError = window.pen.capture.onError((p) => { + pushToast({ kind: 'error', message: p.message }); + }); + onCleanup(() => { + offNeeded(); + offStatus(); + offSaved(); + offError(); + }); + const el = scrollRef; if (!el) return; @@ -263,6 +326,12 @@ export function ToolbarApp() { void window.pen.hub.update({ theme: hub().theme === 'dark' ? 'light' : 'dark' }); }; const setProfile = (p: ProfileId) => void window.pen.hub.update({ profile: p }); + const toggleAlwaysAsk = () => + void window.pen.hub.update({ alwaysAskSavePath: !hub().alwaysAskSavePath }); + const pickSaveDir = async () => { + const dir = (await window.pen.settings.pickSaveDir()) as string | null; + if (dir) void window.pen.hub.update({ saveDir: dir }); + }; const toggleSettings = () => { const next = !hub().settingsOpen; if (next) refreshSide(); @@ -692,6 +761,31 @@ export function ToolbarApp() { +
+ +
+ Always ask where to save + +
+
+ Save folder + +
+
+
@@ -712,6 +806,12 @@ export function ToolbarApp() {
+ setPermModalOpen(false)} + /> +
); } diff --git a/src/renderer/toolbar/PermissionModal.tsx b/src/renderer/toolbar/PermissionModal.tsx new file mode 100644 index 0000000..d93a080 --- /dev/null +++ b/src/renderer/toolbar/PermissionModal.tsx @@ -0,0 +1,111 @@ +import { createSignal, onCleanup, onMount, Show } from 'solid-js'; +import { Portal } from 'solid-js/web'; +import { Icons } from './icons'; + +interface Props { + open: boolean; + // Renderer informs the modal when the OS reports a fresh status — + // used for the "still denied" hint after a manual Recheck. + lastStatus: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown' | null; + onClose: () => void; +} + +// PermissionModal — surfaced when desktopCapturer can't proceed (macOS +// Screen Recording denied, or Linux Wayland user picked Deny in the +// portal). The macOS path is the one that needs hand-holding because +// the OS will not re-prompt programmatically; the user must toggle +// Lekhini on in System Settings and return focus, at which point we +// auto-recheck and the modal closes itself. +export function PermissionModal(props: Props) { + const [busy, setBusy] = createSignal(false); + const [stillDenied, setStillDenied] = createSignal(false); + const isMac = (): boolean => navigator.userAgent.toLowerCase().includes('mac'); + + const openSettings = (): void => { + if (busy()) return; + setBusy(true); + void window.pen.permissions.open('screen'); + // Re-enable Recheck quickly so the user can confirm after granting. + setTimeout(() => setBusy(false), 600); + }; + + const recheck = async (): Promise => { + setBusy(true); + try { + const status = (await window.pen.permissions.check()) as { + screen: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown'; + }; + if (status.screen === 'granted') { + props.onClose(); + } else { + setStillDenied(true); + } + } finally { + setBusy(false); + } + }; + + // Auto-recheck whenever this window regains focus — the user almost + // always reaches us via System Settings → toggle Lekhini → ⌘-tab + // back. The focus event is the cheap, reliable trigger. + let focusHandler: (() => void) | null = null; + onMount(() => { + focusHandler = () => { + if (!props.open) return; + void recheck(); + }; + window.addEventListener('focus', focusHandler); + }); + onCleanup(() => { + if (focusHandler) window.removeEventListener('focus', focusHandler); + }); + + return ( + + +
@@ -525,7 +590,14 @@ export function ToolbarApp() {
-
+
{ + const p = revealPath(); + if (p) void window.pen.shell.openPath(p); + }} + > {vertHintLine()}
@@ -814,13 +886,89 @@ export function ToolbarApp() {
+ + {/* ─── STATUS PANEL (permission / save error) ────────────── + Reuses the .settings-panel layout slot so it docks like + the Settings panel and grows the toolbar window the same + way. Settings has render priority — we only show this + when the settings panel is closed. */} + +
+
+ + {panelKind() === 'permission' ? 'Screen Recording' : "Couldn't save screenshot"} + + +
+ + +
+
+ {Icons.camera()} +
+ Lekhini needs Screen Recording permission to capture annotated + screenshots.{' '} + + macOS controls this — toggle Lekhini on under Privacy & + Security → Screen Recording, then return here. Lekhini + retries automatically when you come back. + + + You denied the system prompt last time. Try the screenshot + button again to be asked once more. + +
+
+ +
{panelHint()}
+
+
+ + + + +
+
+
+ + +
+
+ {Icons.clear()} +
{panelError() ?? 'Unknown error.'}
+
+
+ + +
+
+
+
+
- setPermModalOpen(false)} - /> - ); } diff --git a/src/renderer/toolbar/PermissionModal.tsx b/src/renderer/toolbar/PermissionModal.tsx deleted file mode 100644 index d93a080..0000000 --- a/src/renderer/toolbar/PermissionModal.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { createSignal, onCleanup, onMount, Show } from 'solid-js'; -import { Portal } from 'solid-js/web'; -import { Icons } from './icons'; - -interface Props { - open: boolean; - // Renderer informs the modal when the OS reports a fresh status — - // used for the "still denied" hint after a manual Recheck. - lastStatus: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown' | null; - onClose: () => void; -} - -// PermissionModal — surfaced when desktopCapturer can't proceed (macOS -// Screen Recording denied, or Linux Wayland user picked Deny in the -// portal). The macOS path is the one that needs hand-holding because -// the OS will not re-prompt programmatically; the user must toggle -// Lekhini on in System Settings and return focus, at which point we -// auto-recheck and the modal closes itself. -export function PermissionModal(props: Props) { - const [busy, setBusy] = createSignal(false); - const [stillDenied, setStillDenied] = createSignal(false); - const isMac = (): boolean => navigator.userAgent.toLowerCase().includes('mac'); - - const openSettings = (): void => { - if (busy()) return; - setBusy(true); - void window.pen.permissions.open('screen'); - // Re-enable Recheck quickly so the user can confirm after granting. - setTimeout(() => setBusy(false), 600); - }; - - const recheck = async (): Promise => { - setBusy(true); - try { - const status = (await window.pen.permissions.check()) as { - screen: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown'; - }; - if (status.screen === 'granted') { - props.onClose(); - } else { - setStillDenied(true); - } - } finally { - setBusy(false); - } - }; - - // Auto-recheck whenever this window regains focus — the user almost - // always reaches us via System Settings → toggle Lekhini → ⌘-tab - // back. The focus event is the cheap, reliable trigger. - let focusHandler: (() => void) | null = null; - onMount(() => { - focusHandler = () => { - if (!props.open) return; - void recheck(); - }; - window.addEventListener('focus', focusHandler); - }); - onCleanup(() => { - if (focusHandler) window.removeEventListener('focus', focusHandler); - }); - - return ( - - - diff --git a/src/renderer/toolbar/styles.css b/src/renderer/toolbar/styles.css index effef48..a236384 100644 --- a/src/renderer/toolbar/styles.css +++ b/src/renderer/toolbar/styles.css @@ -998,6 +998,18 @@ html, body { } .status-btn-primary:hover { filter: brightness(1.05); } +/* Relaunch — clearly destructive-ish, dimmed so it doesn't compete + with the primary action but visible enough to be the escape hatch + when Recheck keeps reporting denied. */ +.status-btn-relaunch { + background: transparent !important; + border-color: rgba(231, 76, 60, 0.4) !important; + color: #e74c3c !important; +} +.status-btn-relaunch:hover { + background: rgba(231, 76, 60, 0.12) !important; +} + /* Clickable success hint in the titlebar — when revealPath is set the message becomes a Reveal link. Gold accent + pointer cursor. */ .hint.is-reveal, diff --git a/src/shared/types.ts b/src/shared/types.ts index 16156d6..f25b6c6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -179,6 +179,8 @@ export type IpcChannel = | 'permissions:open' | 'permissions:needed' | 'permissions:status' + | 'permissions:deep-recheck' + | 'app:relaunch' | 'settings:save-dir:pick' | 'shell:open-path'; From 902d39fbd5853df28ba0ccad28a7cf0de4d7c56c Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 01:14:22 +0530 Subject: [PATCH 07/17] fix: when getSources() throws, promote Relaunch over Recheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported that even after granting Screen Recording in macOS System Settings, the panel kept showing 'Still off' on focus-return auto-recheck. The console showed '[pen] deepRecheck capture probe failed Failed to get sources.' — desktopCapturer.getSources() was throwing outright, not just returning an empty array. This is a Chromium-in-Electron quirk on macOS: when the process starts with Screen Recording denied, Chromium initialises its capture pipeline in 'can't capture' mode and there's no way to refresh it from inside the running process. Only a restart recovers. Dev-mode amplifies the issue because the running process bundle id is 'com.github.Electron', not the production 'com.opensourcebharat.lekhini', so the TCC entry the user toggles may not even map to the process that's checking. Changes: - deepRecheck() now returns { screen, probeError } — probeError is true when getSources() threw (vs returned no sources). That's the signal that the process is permanently stuck this session. - notifyStatus + onStatus carry probeError end-to-end so the renderer knows the recovery story. - Permission panel reshuffles when probeError = true: * 'Relaunch Lekhini' becomes the gold primary button. * 'Open System Settings' demotes to secondary. * Hint turns red-tinted and reads 'macOS can't refresh the permission for a running process — Click Relaunch'. - New 'packaged' field on app:info; renderer shows a small italic footnote in dev mode explaining the quirk is dev-specific. --- src/main/capture.ts | 12 +++--- src/main/permissions.ts | 51 +++++++++++++++------- src/main/preload.ts | 1 + src/main/windows/toolbar.ts | 4 ++ src/renderer/penApi.d.ts | 8 ++-- src/renderer/toolbar/App.tsx | 76 ++++++++++++++++++++++++++------- src/renderer/toolbar/styles.css | 18 ++++++++ 7 files changed, 130 insertions(+), 40 deletions(-) diff --git a/src/main/capture.ts b/src/main/capture.ts index d80cec9..1f9626f 100644 --- a/src/main/capture.ts +++ b/src/main/capture.ts @@ -77,11 +77,11 @@ function gateScreenForCapture(action: PendingAction): boolean { if (!needsScreenModal()) return true; broadcast('permissions:needed', { reason: 'screen' }); pendingAction = action; - onFocusRecheck((newStatus) => { + onFocusRecheck((result) => { // Pass the fresh status explicitly so renderers don't see the // possibly-stale getMediaAccessStatus cache. - notifyStatus(newStatus); - if (newStatus !== 'granted') return; + notifyStatus(result.screen, result.probeError); + if (result.screen !== 'granted') return; const a = pendingAction; pendingAction = null; if (a === 'capture') void captureFocusedDisplay(); @@ -185,9 +185,9 @@ function handleCaptureFailure(): boolean { if (needsScreenModal()) { broadcast('permissions:needed', { reason: 'screen' }); pendingAction = 'capture'; - onFocusRecheck((newStatus) => { - notifyStatus(newStatus); - if (newStatus === 'granted' && pendingAction === 'capture') { + onFocusRecheck((result) => { + notifyStatus(result.screen, result.probeError); + if (result.screen === 'granted' && pendingAction === 'capture') { pendingAction = null; void captureFocusedDisplay(); } diff --git a/src/main/permissions.ts b/src/main/permissions.ts index e91f0a1..c8ea32d 100644 --- a/src/main/permissions.ts +++ b/src/main/permissions.ts @@ -30,6 +30,17 @@ export function screenStatus(): ScreenPermissionStatus { return check().screen; } +export interface DeepRecheckResult { + screen: ScreenPermissionStatus; + // True when desktopCapturer.getSources() threw outright (not just + // returned an empty array). On macOS this signals that Chromium's + // in-process capture pipeline initialised while permission was + // denied and is now permanently stuck — only restarting the + // process will recover. The renderer uses this to make "Relaunch" + // the recommended next action instead of "Recheck". + probeError: boolean; +} + // Active probe of the actual capture API. macOS's TCC layer returns a // cached value from getMediaAccessStatus that does NOT refresh just // because the user toggled Screen Recording on in System Settings — @@ -41,23 +52,28 @@ export function screenStatus(): ScreenPermissionStatus { // The probe requests a 1×1 thumbnail so it returns fast and uses // almost no memory. If we get any sources back, permission is real // and the user can proceed. -export async function deepRecheck(): Promise { - if (process.platform !== 'darwin') return 'granted'; +export async function deepRecheck(): Promise { + if (process.platform !== 'darwin') return { screen: 'granted', probeError: false }; const cached = screenStatus(); - if (cached === 'granted') return 'granted'; + if (cached === 'granted') return { screen: 'granted', probeError: false }; try { const sources = await desktopCapturer.getSources({ types: ['screen'], thumbnailSize: { width: 1, height: 1 }, }); - if (sources.length > 0) return 'granted'; + if (sources.length > 0) return { screen: 'granted', probeError: false }; + return { screen: screenStatus(), probeError: false }; } catch (err) { console.warn('[pen] deepRecheck capture probe failed', err); + // The throw itself is the diagnostic signal — the process can't + // recover until restart. Re-read cache one more time in case it + // flipped during the probe call. + const after = screenStatus(); + return { + screen: after === 'granted' ? 'granted' : cached, + probeError: true, + }; } - // Probe failed too — re-read the cache one more time in case it - // flipped during the probe, otherwise return whatever we saw first. - const after = screenStatus(); - return after === 'granted' ? 'granted' : cached; } export function open(which: 'screen' | 'accessibility') { @@ -76,9 +92,14 @@ export function open(which: 'screen' | 'accessibility') { // // Pass `override` when you already know the fresh status — e.g. after // deepRecheck() — so renderers receive the truth instead of the -// possibly-stale cached value from getMediaAccessStatus. -export function notifyStatus(override?: ScreenPermissionStatus): void { - const payload = { screen: override ?? screenStatus() }; +// possibly-stale cached value from getMediaAccessStatus. `probeError` +// piggy-backs along so the renderer can promote the Relaunch button +// when getSources() outright threw (process-permanently-stuck case). +export function notifyStatus( + override?: ScreenPermissionStatus, + probeError = false, +): void { + const payload = { screen: override ?? screenStatus(), probeError }; for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) win.webContents.send('permissions:status', payload); } @@ -95,9 +116,9 @@ export function notifyStatus(override?: ScreenPermissionStatus): void { // The previous behaviour (stacking) leaked listeners every time a // denied screenshot was attempted, eventually hitting Node's // MaxListeners warning at 11. -let pendingRecheck: ((status: ScreenPermissionStatus) => void) | null = null; +let pendingRecheck: ((result: DeepRecheckResult) => void) | null = null; let activeFocusHandler: (() => void) | null = null; -export function onFocusRecheck(cb: (status: ScreenPermissionStatus) => void): void { +export function onFocusRecheck(cb: (result: DeepRecheckResult) => void): void { pendingRecheck = cb; if (activeFocusHandler) return; // already armed; just updated cb activeFocusHandler = () => { @@ -119,9 +140,7 @@ export function onFocusRecheck(cb: (status: ScreenPermissionStatus) => void): vo export function registerPermissionsIpc() { ipcMain.handle('permissions:check', () => check()); ipcMain.handle('permissions:open', (_evt, which: 'screen' | 'accessibility') => open(which)); - ipcMain.handle('permissions:deep-recheck', async () => ({ - screen: await deepRecheck(), - })); + ipcMain.handle('permissions:deep-recheck', () => deepRecheck()); // Relaunch escape hatch — surfaced in the permission panel for the // rare macOS cases where even the deep probe can't see a freshly // granted permission until the process restarts. diff --git a/src/main/preload.ts b/src/main/preload.ts index 3518700..26ea2d9 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -69,6 +69,7 @@ const api = { onStatus: ( cb: (payload: { screen: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown'; + probeError?: boolean; }) => void, ) => bind('permissions:status', cb as (v: unknown) => void), }, diff --git a/src/main/windows/toolbar.ts b/src/main/windows/toolbar.ts index ee1013e..5115866 100644 --- a/src/main/windows/toolbar.ts +++ b/src/main/windows/toolbar.ts @@ -170,6 +170,10 @@ export function registerToolbarIpc() { ipcMain.handle('app:info', () => ({ name: app.getName(), version: app.getVersion(), + // isPackaged is the canonical Electron check for production vs + // dev-server run. Surfaced so the renderer can flag dev-mode TCC + // quirks (which are normal in dev but absent in packaged builds). + packaged: app.isPackaged, })); // Renderer reports its desired content height (vertical) or width // (horizontal). Main resizes the window to fit so empty space below diff --git a/src/renderer/penApi.d.ts b/src/renderer/penApi.d.ts index 508a1b2..0fffba7 100644 --- a/src/renderer/penApi.d.ts +++ b/src/renderer/penApi.d.ts @@ -51,10 +51,12 @@ declare global { }; permissions: { check(): Promise<{ screen: ScreenPermissionStatus; accessibility: boolean }>; - deepCheck(): Promise<{ screen: ScreenPermissionStatus }>; + deepCheck(): Promise<{ screen: ScreenPermissionStatus; probeError: boolean }>; open(which: 'screen' | 'accessibility'): Promise; onNeeded(cb: (payload: { reason: 'screen' }) => void): () => void; - onStatus(cb: (payload: { screen: ScreenPermissionStatus }) => void): () => void; + onStatus( + cb: (payload: { screen: ScreenPermissionStatus; probeError?: boolean }) => void, + ): () => void; }; capture: { onSaved(cb: (payload: { path: string }) => void): () => void; @@ -69,7 +71,7 @@ declare global { openPath(p: string): Promise; }; app: { - info(): Promise<{ name: string; version: string }>; + info(): Promise<{ name: string; version: string; packaged: boolean }>; relaunch(): Promise; }; env: { diff --git a/src/renderer/toolbar/App.tsx b/src/renderer/toolbar/App.tsx index 0e6d82b..24f5045 100644 --- a/src/renderer/toolbar/App.tsx +++ b/src/renderer/toolbar/App.tsx @@ -68,6 +68,22 @@ const TOOL_BY_ID: Record = ALL_TOOLS.reduce( {} as Record, ); +// Permission-panel hint copy. When probeError=true, desktopCapturer +// outright threw on the recheck attempt — the process can't pick the +// new TCC state up without a relaunch. +function stuckHint(probeError: boolean): string { + if (probeError) { + return ( + "macOS can't refresh the permission for a running process — " + + 'Click Relaunch to restart Lekhini and pick up the change.' + ); + } + return ( + "Still off. Make sure Lekhini is toggled on under Privacy & Security " + + '→ Screen Recording, then click Recheck.' + ); +} + // Display-friendly path: replace the home dir with `~` and ellipsize // the middle if the result is still long. Pure cosmetic — the toast // is narrow and a full POSIX path overflows. @@ -107,6 +123,10 @@ export function ToolbarApp() { const [panelKind, setPanelKind] = createSignal(null); const [panelError, setPanelError] = createSignal(null); const [panelHint, setPanelHint] = createSignal(null); + // True after a recheck attempt where desktopCapturer.getSources() + // outright threw — process is stuck until relaunch. Drives the + // panel to promote the Relaunch button over Recheck. + const [permStuck, setPermStuck] = createSignal(false); // Set by capture:saved so the titlebar hint becomes a clickable // 'Reveal' that opens the file's folder. Cleared on next hover hint // or after revealMs. @@ -115,9 +135,14 @@ export function ToolbarApp() { const [platform, setPlatform] = createSignal('darwin'); const [hint, setHint] = createSignal(''); const [settingsOnLeft, setSettingsOnLeft] = createSignal(false); - const [appInfo, setAppInfo] = createSignal<{ name: string; version: string }>({ + const [appInfo, setAppInfo] = createSignal<{ + name: string; + version: string; + packaged: boolean; + }>({ name: 'Lekhini', version: '1.0.0', + packaged: true, }); let scrollRef: HTMLDivElement | undefined; let barMainRef: HTMLDivElement | undefined; @@ -148,6 +173,7 @@ export function ToolbarApp() { // toast inside a tiny toolbar window. const offNeeded = window.pen.permissions.onNeeded(() => { setPanelHint(null); + setPermStuck(false); setPanelKind('permission'); // Close settings if it was occupying the slot. if (hub().settingsOpen) void window.pen.hub.update({ settingsOpen: false }); @@ -156,11 +182,10 @@ export function ToolbarApp() { if (p.screen === 'granted') { setPanelKind((k) => (k === 'permission' ? null : k)); setPanelHint(null); + setPermStuck(false); } else if (panelKind() === 'permission') { - setPanelHint( - "Still off — macOS sometimes only sees the change after a restart. " + - "If you just toggled it on, click Relaunch.", - ); + setPermStuck(!!p.probeError); + setPanelHint(stuckHint(!!p.probeError)); } }); const offSaved = window.pen.capture.onSaved((p) => { @@ -407,17 +432,16 @@ export function ToolbarApp() { // permission on in System Settings. The deep probe actually hits // desktopCapturer and forces a TCC refresh. setPanelHint('Checking…'); - const status = (await window.pen.permissions.deepCheck()) as { + const result = (await window.pen.permissions.deepCheck()) as { screen: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown'; + probeError: boolean; }; - if (status.screen === 'granted') { + if (result.screen === 'granted') { closePanel(); - } else { - setPanelHint( - "Still off — macOS sometimes only sees the change after a restart. " + - "If you just toggled it on, click Relaunch.", - ); + return; } + setPermStuck(result.probeError); + setPanelHint(stuckHint(result.probeError)); }; const openScreenPrefs = () => void window.pen.permissions.open('screen'); const relaunchApp = () => void window.pen.app.relaunch(); @@ -963,10 +987,12 @@ export function ToolbarApp() { -
{panelHint()}
+
+ {panelHint()} +
- + + - + +
+ +
+ Dev mode — TCC quirks are normal here. The packaged + Lekhini build doesn't have this caching issue. +
+
diff --git a/src/renderer/toolbar/styles.css b/src/renderer/toolbar/styles.css index a236384..a7123b8 100644 --- a/src/renderer/toolbar/styles.css +++ b/src/renderer/toolbar/styles.css @@ -983,6 +983,24 @@ html, body { border-radius: 6px; } +/* When the recheck probe outright threw — process is stuck until + relaunch. Red-tinted hint draws attention to the Relaunch button. */ +.status-hint.is-stuck { + color: #e74c3c; + background: rgba(231, 76, 60, 0.10); + border-color: rgba(231, 76, 60, 0.30); +} + +/* Dev-mode footnote — small, italic, neutral. Disappears in packaged + builds where it's never relevant. */ +.status-footnote { + font-size: 10px; + line-height: 1.35; + color: var(--hint); + font-style: italic; + margin-top: 2px; +} + .status-actions { display: flex; flex-wrap: wrap; From 38d4180ee633eef3783b3e71881e6bca345b72e4 Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 01:30:22 +0530 Subject: [PATCH 08/17] fix: snip live border + action menu; exclude toolbar from screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three user-reported issues addressed in one pass since they're all in the screenshot / snip surface: Toolbar in saved screenshots: - setContentProtection(true) on the toolbar window. macOS uses NSWindowSharingNone and Windows uses WDA_EXCLUDEFROMCAPTURE, so desktopCapturer (ours or any third-party recorder) skips it. The PNG now contains the underlying app + the user's annotations and nothing of Lekhini's chrome. Snip drag border invisible: - The snip tool was passing a generic 'region' item to setDraft, and drawRegion painted a 1px white dashed line — invisible against most desktops. Added an opt-in 'marchingAnts' flag on RegionShape and a two-pass black-underneath / white-on-top dashed render path keyed on the flag, mirroring the committed snip-selection style. The user-facing rectangle tool still gets its own coloured outline. No action UX after snip completes: - New SnipActions component renders a floating menu pinned to the bottom-right of the completed selection (auto-flips above the rect if it would clip the bottom edge of the display). [ Copy ] [ Save ] [ ✕ ] - Copy → existing pen.snip.copy() (clipboard), then clears the selection. User pastes wherever. - Save → existing pen.relay.screenshot(), which already respects the active selection and goes through the remember-folder save flow. - ✕ → just clears. - Menu is only mounted while drawMode is on AND the snip tool is the active tool — otherwise the overlay window is click-through and the menu would render but not respond. Hiding it keeps the screen clean. --- src/main/windows/toolbar.ts | 7 ++ src/renderer/overlay/App.tsx | 93 ++++++++++++++++++++++++- src/renderer/overlay/canvas/drawItem.ts | 34 ++++++--- src/renderer/overlay/index.html | 50 +++++++++++++ src/renderer/overlay/tools/snip.ts | 11 +-- src/shared/types.ts | 4 ++ 6 files changed, 185 insertions(+), 14 deletions(-) diff --git a/src/main/windows/toolbar.ts b/src/main/windows/toolbar.ts index 5115866..95ea62e 100644 --- a/src/main/windows/toolbar.ts +++ b/src/main/windows/toolbar.ts @@ -65,6 +65,13 @@ export function createToolbar(orientation: Orientation = 'h'): BrowserWindow { toolbar.setAlwaysOnTop(true, 'screen-saver', 2); toolbar.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + // Hide the toolbar from screen capture so the screenshot the user + // takes via Lekhini contains the underlying app + their annotations + // but NOT our toolbar chrome. macOS uses NSWindowSharingNone; + // Windows uses SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE); + // Linux is no-op. Also keeps the toolbar out of any other capture + // tool the user runs (Loom, QuickTime, Zoom share, etc.). + toolbar.setContentProtection(true); if (VITE_DEV_SERVER_URL) { toolbar.loadURL(`${VITE_DEV_SERVER_URL}src/renderer/toolbar/index.html`); diff --git a/src/renderer/overlay/App.tsx b/src/renderer/overlay/App.tsx index e25782f..4ebdeb4 100644 --- a/src/renderer/overlay/App.tsx +++ b/src/renderer/overlay/App.tsx @@ -3,7 +3,7 @@ import { CommittedLayer } from './canvas/CommittedLayer'; import { LiveLayer } from './canvas/LiveLayer'; import { attachPointerPipeline } from './canvas/pointerPipeline'; import { cursorFor } from './cursors'; -import { store } from './store'; +import { store, type SnipRect } from './store'; import { buildRegistry } from './tools/registry'; import type { Item, Theme, ToolSettings, Whiteboard } from '../../shared/types'; import type { Tool, ToolContext } from './tools/types'; @@ -15,6 +15,10 @@ export function OverlayApp() { const [drawMode, setDrawMode] = createSignal(store.getState().drawMode); const [activeTool, setActiveToolSignal] = createSignal(store.getState().activeTool); const [whiteboard, setWhiteboard] = createSignal('off'); + // Reactive mirror of the store's snipRect so the SnipActions menu + // can re-render on Solid's signal cycle. Synced inside the store + // subscriber below. + const [snipRectSig, setSnipRectSig] = createSignal(null); let currentTheme: Theme = 'dark'; const applyCursor = () => { @@ -134,6 +138,7 @@ export function OverlayApp() { ) { committed.render(state.items, state.selectedId, state.snipRect); } + if (state.snipRect !== prev.snipRect) setSnipRectSig(state.snipRect); }); const onResize = () => { @@ -250,6 +255,92 @@ export function OverlayApp() { /> )} + {/* Menu is only operable while the snip tool is the active + drawing tool, because the overlay window is click-through + otherwise (setIgnoreMouseEvents). Hiding it then prevents a + visible-but-dead menu floating on screen. The underlying + selection stays in main's snipSelections map either way. + (Order matters: snipRectSig() goes last so the && chain + resolves to the SnipRect itself for Show's accessor.) */} + + {(rect) => } + + + ); +} + +// Floating Copy / Save / Cancel menu for the active snip selection. +// Anchored at the bottom-right corner of the rect with a small offset. +// Falls back to inside-rect-bottom-right if the rect is too close to +// the screen edge to fit the menu below it. +function SnipActions(props: { rect: SnipRect }) { + const MENU_W = 168; + const MENU_H = 32; + const GAP = 8; + const clearSnip = (): void => { + const displayId = window.pen.env.displayId(); + void window.pen.snip.clear({ displayId }); + }; + const onCopy = async (): Promise => { + await window.pen.snip.copy(); + clearSnip(); + }; + const onSave = (): void => { + // The main process's captureFocusedDisplay picks up the existing + // selection and writes a cropped PNG (going through the save + // dialog or the remembered folder). + void window.pen.relay.screenshot(); + // Don't clear the selection here — capture.ts clears the visual + // selection itself just before grabbing the pixels so it isn't + // baked into the PNG, then we let the user know via the titlebar + // hint in the toolbar window. + }; + const onCancel = (): void => clearSnip(); + + const positioned = (): { left: string; top: string } => { + const r = props.rect; + const winW = window.innerWidth; + const winH = window.innerHeight; + // Default: below the rect, right-aligned to its right edge. + let left = r.x + r.w - MENU_W; + let top = r.y + r.h + GAP; + // If it would overflow the bottom of the screen, place ABOVE the rect. + if (top + MENU_H > winH - 4) top = r.y - MENU_H - GAP; + // If still off-screen (very tall rect near top), tuck inside the rect. + if (top < 4) top = Math.min(r.y + r.h - MENU_H - GAP, winH - MENU_H - 4); + // Horizontal clamping: never let the menu fall off either edge. + left = Math.max(4, Math.min(left, winW - MENU_W - 4)); + return { left: `${left}px`, top: `${top}px` }; + }; + + return ( +
e.stopPropagation()} + > + + +
); } diff --git a/src/renderer/overlay/canvas/drawItem.ts b/src/renderer/overlay/canvas/drawItem.ts index c2ad9e8..a589ed4 100644 --- a/src/renderer/overlay/canvas/drawItem.ts +++ b/src/renderer/overlay/canvas/drawItem.ts @@ -262,14 +262,32 @@ function drawRegion( const w = Math.abs(item.p2.x - item.p1.x); const h = Math.abs(item.p2.y - item.p1.y); ctx.save(); - ctx.globalAlpha = item.opacity * 0.18; - ctx.fillStyle = item.color; - ctx.fillRect(x, y, w, h); - ctx.globalAlpha = item.opacity; - ctx.strokeStyle = item.color; - ctx.setLineDash([4, 4]); - ctx.lineWidth = 1; - ctx.strokeRect(x, y, w, h); + if (item.marchingAnts) { + // Snip preview: two-pass marching ants — black underneath, white on + // top with a dash-offset so the alternation is visible on light + // AND dark surfaces. Half-pixel offset for crisp 1px lines. A faint + // dim wash inside indicates the captured region. + ctx.globalAlpha = 0.18; + ctx.fillStyle = '#000000'; + ctx.fillRect(x, y, w, h); + ctx.globalAlpha = 1; + ctx.lineWidth = 1; + ctx.setLineDash([6, 4]); + ctx.strokeStyle = 'rgba(0, 0, 0, 0.85)'; + ctx.strokeRect(x + 0.5, y + 0.5, w, h); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.95)'; + ctx.lineDashOffset = 5; + ctx.strokeRect(x + 0.5, y + 0.5, w, h); + } else { + ctx.globalAlpha = item.opacity * 0.18; + ctx.fillStyle = item.color; + ctx.fillRect(x, y, w, h); + ctx.globalAlpha = item.opacity; + ctx.strokeStyle = item.color; + ctx.setLineDash([4, 4]); + ctx.lineWidth = 1; + ctx.strokeRect(x, y, w, h); + } ctx.restore(); } diff --git a/src/renderer/overlay/index.html b/src/renderer/overlay/index.html index 8a3c9ef..be18975 100644 --- a/src/renderer/overlay/index.html +++ b/src/renderer/overlay/index.html @@ -58,6 +58,56 @@ min-width: 160px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); } + /* Snip action menu — Copy / Save / Cancel pinned to the + bottom-right of a completed snip selection. Sits ABOVE the + capture-surface in DOM order so clicks land on buttons rather + than the underlying drawing surface; we also stopPropagation + on pointerdown defensively. */ + .snip-actions { + position: absolute; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px; + background: rgba(20, 20, 22, 0.94); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45); + font-size: 12px; + line-height: 1; + z-index: 50; + animation: snipActionsIn 0.14s cubic-bezier(0.2, 0.7, 0.2, 1); + } + @keyframes snipActionsIn { + from { opacity: 0; transform: translateY(-2px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + .snip-action { + appearance: none; + background: transparent; + color: #e4e4e6; + border: 1px solid transparent; + border-radius: 5px; + padding: 6px 12px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.12s, color 0.12s; + } + .snip-action:hover { background: rgba(255, 255, 255, 0.08); } + .snip-action:active { background: rgba(255, 255, 255, 0.14); } + .snip-action-primary { + background: linear-gradient(180deg, #d8b572 0%, #b89150 100%); + color: #1c1c1e; + font-weight: 600; + } + .snip-action-primary:hover { filter: brightness(1.05); } + .snip-action-quiet { + color: rgba(228, 228, 230, 0.65); + padding: 6px 8px; + font-size: 13px; + } + .snip-action-quiet:hover { color: #e4e4e6; } diff --git a/src/renderer/overlay/tools/snip.ts b/src/renderer/overlay/tools/snip.ts index b382c1a..043df29 100644 --- a/src/renderer/overlay/tools/snip.ts +++ b/src/renderer/overlay/tools/snip.ts @@ -2,9 +2,6 @@ import type { RegionShape } from '../../../shared/types'; import type { Tool } from './types'; import { nextId } from './types'; -const PREVIEW_COLOR = '#ffffff'; -const PREVIEW_OPACITY = 0.9; - export const snip: Tool = (() => { let draft: RegionShape | null = null; let anchor: { x: number; y: number } | null = null; @@ -21,8 +18,12 @@ export const snip: Tool = (() => { id: nextId('snip-preview'), p1: anchor, p2: anchor, - color: PREVIEW_COLOR, - opacity: PREVIEW_OPACITY, + // Color/opacity are ignored when marchingAnts is set — the + // renderer paints its own high-contrast B/W pattern. Kept here + // for type-shape compatibility with RegionShape. + color: '#ffffff', + opacity: 1, + marchingAnts: true, }; ctx.setDraft(draft); }, diff --git a/src/shared/types.ts b/src/shared/types.ts index f25b6c6..e076d85 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -64,6 +64,10 @@ export interface RegionShape { p2: { x: number; y: number }; color: string; opacity: number; + // When set, render with the high-contrast B/W marching-ants pattern + // used for selection rectangles (snip preview). Without it the + // 1px-dashed white stroke is invisible against most desktops. + marchingAnts?: boolean; } export interface EllipseShape { From 71bfe9570a784bd7de14f19fa458efe3d494546f Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 01:39:37 +0530 Subject: [PATCH 09/17] fix: snip Copy / Save now exits drawMode so user can paste / move on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a snip action completes the user wants the overlay to step out of the way — clicking Copy is almost always followed by ⌘V into another app, and Save just means "I'm done with that selection." Staying in drawMode meant the overlay kept intercepting the next click and the user had to manually toggle pen mode off first. After Copy or Save we now patch hub.drawMode=false. The snip tool itself stays selected, so re-enabling drawMode (⌘⇧D or the status dot) jumps straight into another selection without a tool change. Cancel still just clears the rect without changing drawMode — the user explicitly said 'never mind' but probably wants to keep drawing. --- src/renderer/overlay/App.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/renderer/overlay/App.tsx b/src/renderer/overlay/App.tsx index 4ebdeb4..1c8ffe8 100644 --- a/src/renderer/overlay/App.tsx +++ b/src/renderer/overlay/App.tsx @@ -281,19 +281,28 @@ function SnipActions(props: { rect: SnipRect }) { const displayId = window.pen.env.displayId(); void window.pen.snip.clear({ displayId }); }; + // After the user picks Copy or Save the snip is done — drop the + // overlay out of drawMode so it becomes click-through immediately, + // letting the user paste into another app or click around without + // the snip tool intercepting the next click. The snip tool stays + // selected, so re-enabling drawMode (⌘⇧D or status dot) jumps + // straight back into another selection. + const exitToIdle = (): void => { + void window.pen.hub.update({ drawMode: false }); + }; const onCopy = async (): Promise => { await window.pen.snip.copy(); clearSnip(); + exitToIdle(); }; const onSave = (): void => { // The main process's captureFocusedDisplay picks up the existing // selection and writes a cropped PNG (going through the save - // dialog or the remembered folder). + // dialog or the remembered folder). capture.ts also clears the + // visual selection itself just before grabbing the pixels so it + // isn't baked into the PNG. void window.pen.relay.screenshot(); - // Don't clear the selection here — capture.ts clears the visual - // selection itself just before grabbing the pixels so it isn't - // baked into the PNG, then we let the user know via the titlebar - // hint in the toolbar window. + exitToIdle(); }; const onCancel = (): void => clearSnip(); From aeea643d17c0510bb5f15c539322ac22e50e5a3d Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 01:50:08 +0530 Subject: [PATCH 10/17] perf: binary IPC + createImageBitmap + toBlob for snip + screenshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User asked for snip Copy/Save to feel quick. Roughly halves the end-to-end latency from selection-release to clipboard-ready by cutting the base64 round-trips and the heavier image-decode path. End-to-end IPC is now Uint8Array all the way: - main: desktopCapturer.thumbnail.toPNG() returns a Buffer; sent directly to the overlay via overlay:snip / overlay:screenshot as { png: Buffer }. No more toDataURL → base64 string → 33% larger payload → renderer base64 decode → HTMLImageElement.src round-trip. - renderer: createImageBitmap(blob) decodes off the main thread and is materially faster than 'new Image; img.src = dataURL' for big full-screen PNGs. Composite uses the bitmap, then canvas.toBlob (async) → arrayBuffer for the result PNG. - main: receives Uint8Array from capture:snip:result / capture:screenshot:result, Buffer.from(uint8) — skips the base64 decode entirely. Clipboard write and fs.writeFile both work straight from the Buffer. Snip Copy UX: - Button shows 'Copying…' and disables itself + the others while the await is in flight, so the user can't double-click and knows something is happening. Save remains fire-and-forget (it goes through the save dialog or remembered folder and confirms via the toolbar's gold reveal hint). PNG format unchanged — still lossless, still universally clipboard-compatible. The savings come from skipping the base64 detour, not from quality loss. --- src/main/capture.ts | 60 ++++++++-------- src/main/preload.ts | 12 ++-- src/renderer/overlay/App.tsx | 117 ++++++++++++++++++++++---------- src/renderer/overlay/index.html | 3 + src/renderer/penApi.d.ts | 8 +-- 5 files changed, 124 insertions(+), 76 deletions(-) diff --git a/src/main/capture.ts b/src/main/capture.ts index 1f9626f..0ef37f8 100644 --- a/src/main/capture.ts +++ b/src/main/capture.ts @@ -107,8 +107,8 @@ export async function copyFocusedSnipToClipboard(): Promise { setSnipSelection(displayId, null); await waitMs(60); - const pngBase64 = await captureCroppedComposite(overlay, display, rect); - if (!pngBase64) { + const png = await captureCroppedComposite(overlay, display, rect); + if (!png) { if (handleCaptureFailure()) return; broadcast('capture:error', { message: "Couldn't read the screen — try again.", @@ -117,8 +117,7 @@ export async function copyFocusedSnipToClipboard(): Promise { return; } - const buf = Buffer.from(pngBase64, 'base64'); - const img = nativeImage.createFromBuffer(buf); + const img = nativeImage.createFromBuffer(png); clipboard.writeImage(img); } @@ -131,13 +130,13 @@ export async function captureFocusedDisplay(): Promise { 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) { + // No overlay: fall back to a raw full-display capture (uncomposited). + const raw = await fullDisplayPng(display); + if (!raw) { handleCaptureFailure(); return; } - await persistDataUrl(dataUrl); + await persistPng(raw); return; } @@ -145,31 +144,35 @@ export async function captureFocusedDisplay(): Promise { // 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) { + const png = await captureCroppedComposite(overlay, display, selection); + if (!png) { handleCaptureFailure(); return; } - await persistDataUrl(`data:image/png;base64,${pngBase64}`); + await persistPng(png); return; } // No selection: full-display composite (existing behavior). - const dataUrl = await fullDisplayDataUrl(display); - if (!dataUrl) { + const screenPng = await fullDisplayPng(display); + if (!screenPng) { handleCaptureFailure(); return; } await new Promise((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(); @@ -197,7 +200,7 @@ function handleCaptureFailure(): boolean { return false; } -async function fullDisplayDataUrl(display: Electron.Display): Promise { +async function fullDisplayPng(display: Electron.Display): Promise { const sources = await desktopCapturer.getSources({ types: ['screen'], thumbnailSize: { @@ -208,26 +211,28 @@ async function fullDisplayDataUrl(display: Electron.Display): Promise 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 { - const screenDataUrl = await fullDisplayDataUrl(display); - if (!screenDataUrl) return null; +): Promise { + const screenPng = await fullDisplayPng(display); + if (!screenPng) return null; - return new Promise((resolve) => { + return new Promise((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, }); @@ -257,10 +262,7 @@ function defaultSaveDir(): string { return path.join(os.homedir(), 'Pictures', 'Lekhini'); } -async function persistDataUrl(dataUrl: string): Promise { - const base64 = dataUrl.replace(/^data:image\/png;base64,/, ''); - const buf = Buffer.from(base64, 'base64'); - +async function persistPng(buf: Buffer): Promise { const state = persisted(); const shouldPrompt = state.alwaysAskSavePath || !state.saveDir; diff --git a/src/main/preload.ts b/src/main/preload.ts index 26ea2d9..a1f1ef4 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -16,11 +16,11 @@ const api = { onUndo: (cb: () => void) => bind('overlay:undo', cb), onRedo: (cb: () => void) => bind('overlay:redo', cb), onClear: (cb: () => void) => bind('overlay:clear', cb), - onScreenshot: (cb: (payload: { dataUrl: string }) => void) => + onScreenshot: (cb: (payload: { png: Uint8Array }) => void) => bind('overlay:screenshot', cb as (v: unknown) => void), onSnip: ( cb: (payload: { - dataUrl: string; + png: Uint8Array; rect: { x: number; y: number; w: number; h: number }; scaleFactor: number; }) => void, @@ -30,10 +30,10 @@ const api = { ) => bind('overlay:snip-selection', cb as (v: unknown) => void), requestFocus: () => ipcRenderer.invoke('overlay:request-focus' satisfies IpcChannel), releaseFocus: () => ipcRenderer.invoke('overlay:release-focus' satisfies IpcChannel), - sendScreenshotResult: (pngBase64: string) => - ipcRenderer.invoke('capture:screenshot:result' satisfies IpcChannel, pngBase64), - sendSnipResult: (pngBase64: string) => - ipcRenderer.invoke('capture:snip:result' satisfies IpcChannel, pngBase64), + sendScreenshotResult: (png: Uint8Array) => + ipcRenderer.invoke('capture:screenshot:result' satisfies IpcChannel, png), + sendSnipResult: (png: Uint8Array) => + ipcRenderer.invoke('capture:snip:result' satisfies IpcChannel, png), }, snip: { set: (payload: { diff --git a/src/renderer/overlay/App.tsx b/src/renderer/overlay/App.tsx index 1c8ffe8..9804517 100644 --- a/src/renderer/overlay/App.tsx +++ b/src/renderer/overlay/App.tsx @@ -155,13 +155,13 @@ export function OverlayApp() { const unUndo = window.pen.overlay.onUndo(() => store.getState().undo()); const unRedo = window.pen.overlay.onRedo(() => store.getState().redo()); const unClear = window.pen.overlay.onClear(() => store.getState().clear()); - const unShot = window.pen.overlay.onScreenshot(async ({ dataUrl }) => { - const png = await composite(dataUrl, committed.getCanvas()); - await window.pen.overlay.sendScreenshotResult(png); + const unShot = window.pen.overlay.onScreenshot(async ({ png }) => { + const out = await composite(png, committed.getCanvas()); + await window.pen.overlay.sendScreenshotResult(out); }); - const unSnip = window.pen.overlay.onSnip(async ({ dataUrl, rect, scaleFactor }) => { - const png = await compositeAndCrop(dataUrl, committed.getCanvas(), rect, scaleFactor); - await window.pen.overlay.sendSnipResult(png); + const unSnip = window.pen.overlay.onSnip(async ({ png, rect, scaleFactor }) => { + const out = await compositeAndCrop(png, committed.getCanvas(), rect, scaleFactor); + await window.pen.overlay.sendSnipResult(out); }); const unSnipSel = window.pen.overlay.onSnipSelection((rect) => { store.getState().setSnipRect(rect); @@ -277,6 +277,12 @@ function SnipActions(props: { rect: SnipRect }) { const MENU_W = 168; const MENU_H = 32; const GAP = 8; + // Tracks an in-flight Copy so the button can show 'Copying…' and + // block double-clicks. Save is fire-and-forget (capture goes + // through the toolbar's save flow), so it doesn't get a busy state + // — the menu just dismisses immediately. + const [busy, setBusy] = createSignal<'copy' | null>(null); + const clearSnip = (): void => { const displayId = window.pen.env.displayId(); void window.pen.snip.clear({ displayId }); @@ -291,9 +297,15 @@ function SnipActions(props: { rect: SnipRect }) { void window.pen.hub.update({ drawMode: false }); }; const onCopy = async (): Promise => { - await window.pen.snip.copy(); - clearSnip(); - exitToIdle(); + if (busy()) return; + setBusy('copy'); + try { + await window.pen.snip.copy(); + } finally { + setBusy(null); + clearSnip(); + exitToIdle(); + } }; const onSave = (): void => { // The main process's captureFocusedDisplay picks up the existing @@ -331,13 +343,15 @@ function SnipActions(props: { rect: SnipRect }) { - { - const p = revealPath(); - if (p) void window.pen.shell.openPath(p); - }} - title={revealPath() ? 'Click to reveal in folder' : ''} - >{brandLine()} +
@@ -560,12 +539,6 @@ export function ToolbarApp() { onMouseLeave={clearHint} title="Collapse" >{Icons.collapse()} -
- - -
-
{ - const p = revealPath(); - if (p) void window.pen.shell.openPath(p); - }} - > - {vertHintLine()}
@@ -842,6 +784,47 @@ export function ToolbarApp() { + + {/* ─── FOOTER ─── hover-hint on the left, plus the + rarely-touched status-dot + settings on the right. Lives + at the bottom of bar-main so it doesn't take attention + away from the tools. The hint area is also where the + 'Saved · …/lekhini-…png' reveal link surfaces. */} + {/* ─── SETTINGS DROPDOWN ─── */} diff --git a/src/renderer/toolbar/styles.css b/src/renderer/toolbar/styles.css index 2d1b922..995c55a 100644 --- a/src/renderer/toolbar/styles.css +++ b/src/renderer/toolbar/styles.css @@ -157,16 +157,17 @@ html, body { pointer-events: none; } .tb-center .logo { display: inline-flex; align-items: center; } -.tb-center .hint { - font-size: 11px; - color: var(--hint); - letter-spacing: 0.02em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - transition: color 0.15s; -} -.tb-center .hint.has-hint { color: var(--hint-accent); font-weight: 500; } +/* h-mode logo: bigger now that nothing else sits in the titlebar + center. Subtle hover scale + soft glow when drawing so it doubles + as a brand mark and a 'is this thing working' affordance. */ +.tb-center .logo.big svg, +.tb-center .logo.big img { width: 32px; height: 32px; border-radius: 7px; } +.tb-center .logo.big { + transition: transform 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); +} +.tb-center .logo.big:hover { transform: scale(1.06); } +/* (Hover-hint text moved out of the titlebar into the new + .bar-footer at the bottom of the bar — see footer rules below.) */ /* ── Window controls (generic / Windows) ── */ @@ -256,7 +257,15 @@ html, body { flex-shrink: 0; } .v-brand .logo { display: inline-flex; } -.v-brand .logo svg, .v-brand .logo img { width: 24px; height: 24px; } +/* v-mode logo: even bigger now that brand strip carries only the + logo (settings + status-dot moved to footer). The vertical + toolbar is 88px wide so 40px works comfortably with breathing + room. */ +.v-brand .logo svg, .v-brand .logo img { width: 40px; height: 40px; border-radius: 9px; } +.v-brand .logo { + transition: transform 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); +} +.v-brand .logo:hover { transform: scale(1.06); } .v-theme-btn { -webkit-app-region: no-drag; @@ -276,33 +285,8 @@ html, body { .v-theme-btn:hover { background: var(--winctl-hover); color: var(--text); } .v-theme-btn svg { width: 12px; height: 12px; } -/* Vertical hover-hint strip (replacement for floating tooltips, which - get clipped by the narrow toolbar window). */ -.v-hint { - -webkit-app-region: drag; - flex-shrink: 0; - height: 30px; - padding: 0 5px; - font-size: 9.5px; - letter-spacing: 0.02em; - color: var(--hint); - background: var(--titlebar-bg); - border-bottom: 1px solid var(--separator); - transition: color 0.12s; - text-align: center; - line-height: 1.15; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - word-break: break-word; - align-items: center; - justify-content: center; -} -.v-hint.has-hint { - color: var(--hint-accent); - font-weight: 600; -} +/* (The old .v-hint strip was removed — the hover-hint now lives in + the bottom .bar-footer that's shared by both orientations.) */ /* ─────────────────── SCROLL AREA ─────────────────── */ @@ -1028,10 +1012,9 @@ html, body { background: rgba(231, 76, 60, 0.12) !important; } -/* Clickable success hint in the titlebar — when revealPath is set the +/* Clickable success hint in the footer — when revealPath is set the message becomes a Reveal link. Gold accent + pointer cursor. */ -.hint.is-reveal, -.v-hint.is-reveal { +.bar-footer-hint.is-reveal { color: var(--gold) !important; cursor: pointer; -webkit-app-region: no-drag; @@ -1041,8 +1024,7 @@ html, body { text-decoration-color: rgba(216, 181, 114, 0.5); text-underline-offset: 2px; } -.hint.is-reveal:hover, -.v-hint.is-reveal:hover { +.bar-footer-hint.is-reveal:hover { text-decoration-color: var(--gold); } @@ -1074,3 +1056,70 @@ html, body { overflow: hidden; text-overflow: ellipsis; } + +/* ─────────────────── BAR FOOTER ─────────────────── */ +/* Bottom strip in both orientations carrying the hover-hint on the + left and the rarely-used status-dot + settings button on the right. + Moved here from the titlebar so the top of the toolbar is just the + logo + window controls, and incidental UI (like 'what does this + button do?' tooltips) doesn't compete with the brand mark. */ + +.bar-footer { + -webkit-app-region: drag; + flex-shrink: 0; + border-top: 1px solid var(--separator); + background: var(--footer-bg); + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; +} + +.bar-footer-hint { + flex: 1 1 auto; + min-width: 0; + font-size: 10.5px; + color: var(--hint); + letter-spacing: 0.01em; + transition: color 0.15s; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.bar-footer-hint.has-hint { + color: var(--hint-accent); + font-weight: 500; +} + +.bar-footer-controls { + -webkit-app-region: no-drag; + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} +.bar-footer-controls .winctl.footer-settings { + width: 22px; + height: 22px; +} +.bar-footer-controls .winctl.footer-settings svg { width: 13px; height: 13px; } + +/* Vertical-mode footer needs to stack — the bar is 88px wide which + isn't enough room for hint text alongside two buttons in a row. */ +.bar[data-orient='v'] .bar-footer { + flex-direction: column; + gap: 4px; + padding: 6px 4px; +} +.bar[data-orient='v'] .bar-footer-hint { + white-space: normal; + text-align: center; + line-height: 1.2; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + font-size: 10px; +} +.bar[data-orient='v'] .bar-footer-controls { + justify-content: center; +} From afbc462b847c9a7947fc9bb6821dbf10de6f18ff Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 03:02:15 +0530 Subject: [PATCH 13/17] =?UTF-8?q?fix:=20collapsed=20pill=20=E2=80=94=20cli?= =?UTF-8?q?ck=20anywhere=20to=20restore,=20full=20logo=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues with the minimized state: 1. Restore was unreliable. .mini had -webkit-app-region: drag covering the whole 56px pill, with only a 30px no-drag .mini-logo span inside. Click events on drag regions are eaten by Electron, so only clicks landing exactly on the small logo restored — clicking anywhere else on the pill did nothing. 2. Logo rendered awkwardly — 30px image inside the pill plus a 22×3 pseudo-element bar at top:5 (an old drag-indicator hint that doesn't actually drag). The user described it as 'half logo'. Fix: - .mini-logo is now a button covering the whole pill except a 5px drag border around the edge — click anywhere except the border restores, while the border still drags the window. - The misleading ::after horizontal bar is gone. - Logo bumped 30→36px, object-fit: contain, pointer-events: none on the image so the parent button owns clicks (no aspect-ratio distortion if the PNG isn't perfectly square). - Subtle hover background + scale on the inner button so the click target is obvious. - MIN_SIZE 56→64 so the 36px logo + drag border has breathing room. --- src/main/windows/toolbar.ts | 4 ++- src/renderer/toolbar/App.tsx | 12 +++++++-- src/renderer/toolbar/styles.css | 47 ++++++++++++++++++++++++--------- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/main/windows/toolbar.ts b/src/main/windows/toolbar.ts index 95ea62e..c38a1d4 100644 --- a/src/main/windows/toolbar.ts +++ b/src/main/windows/toolbar.ts @@ -13,7 +13,9 @@ let toolbar: BrowserWindow | null = null; // of drifting inward each open/close cycle. let anchorPos: { x: number; y: number } | null = null; -const MIN_SIZE = { w: 56, h: 56 }; +// Collapsed pill: square enough to be a chunky tap target and big +// enough that a 36px logo with a 5px drag border breathes. +const MIN_SIZE = { w: 64, h: 64 }; function defaultPosition(orientation: Orientation, minimized: boolean, settingsOpen: boolean) { const primary = screen.getPrimaryDisplay(); diff --git a/src/renderer/toolbar/App.tsx b/src/renderer/toolbar/App.tsx index 3ee1b41..91f023a 100644 --- a/src/renderer/toolbar/App.tsx +++ b/src/renderer/toolbar/App.tsx @@ -499,8 +499,16 @@ export function ToolbarApp() { - +
+ {/* Click target is the inner span (no-drag region). The + outer .mini is a thin drag border so the pill can + still be moved without expanding. */} +
} > diff --git a/src/renderer/toolbar/styles.css b/src/renderer/toolbar/styles.css index 995c55a..0d1fb36 100644 --- a/src/renderer/toolbar/styles.css +++ b/src/renderer/toolbar/styles.css @@ -574,28 +574,51 @@ html, body { } /* ─────────────────── MINIMIZED PILL ─────────────────── */ +/* Layout: outer .mini is the drag region (thin border around the + pill) so the user can still move the collapsed toolbar. The inner + .mini-logo fills almost the whole pill and is the click target + that restores — click events on drag regions are eaten by Electron, + so the previous setup (whole pill draggable, tiny logo span as the + only click target) made restoring fragile because users couldn't + reliably hit the 30px logo inside the 56px pill. */ .mini { width: 100%; height: 100%; + position: relative; + cursor: pointer; + -webkit-app-region: drag; +} +.mini-logo { + appearance: none; + background: transparent; + border: none; + padding: 0; + color: inherit; + font: inherit; + position: absolute; + inset: 5px; + -webkit-app-region: no-drag; display: flex; align-items: center; justify-content: center; + border-radius: 8px; cursor: pointer; - -webkit-app-region: drag; - position: relative; + transition: background 0.15s, transform 0.15s cubic-bezier(0.2, 0.7, 0.2, 1); } -.mini::after { - content: ''; - position: absolute; - top: 5px; - width: 22px; - height: 3px; - border-radius: 2px; - background: var(--separator); +.mini-logo:hover { + background: var(--hover); + transform: scale(1.04); +} +.mini-logo:active { transform: scale(0.97); } +.mini-logo svg, +.mini-logo img { + width: 36px; + height: 36px; + border-radius: 7px; + object-fit: contain; + pointer-events: none; /* clicks land on .mini-logo, not the image */ } -.mini-logo { -webkit-app-region: no-drag; display: inline-flex; } -.mini-logo svg, .mini-logo img { width: 30px; height: 30px; border-radius: 6px; } /* ─────────────────── SETTINGS DROPDOWN ─────────────────── */ From 2b8f08ca27ad5a45375c56c9932c3fe3628be805 Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 03:08:36 +0530 Subject: [PATCH 14/17] fix: distinct pen / pencil icons + footer no longer clips after restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues: 1. The Phosphor-refresh pass made pen and pencil look almost identical — same diagonal-pencil silhouette with a small ferrule line. Added distinguishing details that read at 22 px: - Pencil gets a filled rectangular eraser cap at the wide end. - Pen gets a filled triangular nib protruding at the writing tip and a slightly bulkier barrel. 2. After collapsing and restoring, the bottom footer was clipped below the window edge until the next state change (tool selection, theme toggle, etc.) triggered reportContentSize to remeasure. Root cause: TOOLBAR_SIZES (the static post-restore window size in shared/constants.ts) was set before the footer existed and was shorter than the actual content. The renderer eventually catches up via setContentSize, but until then the footer sits outside the visible window. Fix two ways: - Bump TOOLBAR_SIZES (h.h 102→140, v.h 480→560) to cover the worst-case content height including the footer, so the window is generous from the moment it restores. - Add a second RAF after the first inside the resize effect, so transitions that unmount+remount bar-main (notably restore from minimised) get a follow-up measurement on the frame after layout fully settles. The window can shrink past these generous defaults via setContentSize — they're a floor, not a target. --- src/renderer/toolbar/App.tsx | 12 +++++++++++- src/renderer/toolbar/icons.tsx | 22 ++++++++++++++++++---- src/shared/constants.ts | 22 +++++++++++++--------- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/renderer/toolbar/App.tsx b/src/renderer/toolbar/App.tsx index 91f023a..403c099 100644 --- a/src/renderer/toolbar/App.tsx +++ b/src/renderer/toolbar/App.tsx @@ -333,7 +333,17 @@ export function ToolbarApp() { void s.profile; void s.activeTool; void panelKind(); - requestAnimationFrame(reportContentSize); + // First RAF catches the common case (single-frame layout). A + // second RAF after it covers transitions where the bar-main was + // just unmounted-then-remounted (notably restore from + // minimized) — children sometimes need an extra frame to lay out + // their final size, and without this the footer would render + // clipped below the window's content-size until the next + // unrelated re-measure. + requestAnimationFrame(() => { + reportContentSize(); + requestAnimationFrame(reportContentSize); + }); }); // Refresh which side the panel sits on whenever a status panel opens diff --git a/src/renderer/toolbar/icons.tsx b/src/renderer/toolbar/icons.tsx index 1cf1ad3..1ac0574 100644 --- a/src/renderer/toolbar/icons.tsx +++ b/src/renderer/toolbar/icons.tsx @@ -23,20 +23,34 @@ const SVG = (children: JSX.Element): JSX.Element => ( ); export const Icons = { - // PencilSimple — tilted shaft with a flat tip. No ferrule clutter. + // Pencil — tilted body with a filled eraser cap on the wide end + // and a ferrule line. The eraser cap is the visual signature that + // tells it apart from the pen at a glance. pencil: () => SVG( <> + , ), - // Pen — angled barrel with a small nib square at the tip. + // Pen — slightly thicker tilted barrel with a FILLED triangular + // nib protruding at the writing tip. The filled nib is the + // signature that distinguishes it from the (eraser-capped) pencil. pen: () => SVG( <> - - + + + , ), // Highlighter — square chisel head over a tapered shaft. diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 1e615c8..7cd28d2 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -23,17 +23,21 @@ export const SNAP_ANGLES_DEG = [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 1 // Initial sizes for the toolbar window. The renderer reports its // actual content size after mount and the window resizes to fit — so -// these values just need to be a reasonable first-paint estimate -// close to typical content size, minimizing the brief flicker before -// dynamic resize lands. +// these values just need to be a generous first-paint estimate close +// to typical content size, minimizing flicker before dynamic resize +// lands. CRITICAL: must be ≥ actual content height including the +// bottom footer, otherwise the footer is clipped below the visible +// window edge until the next state change triggers a remeasure. +// That's what bit users when they restored from the collapsed pill. // -// h.w is sized to fit teacher profile (11 tools) + actions + the -// inline color grid + right margin. h.h matches the typical bar -// height when the inline color grid is in place (it's taller than -// the icons row alone since the grid is 3 rows). +// Rough budget per orientation (sum to current values with margin): +// h: titlebar 28 + tools row 56 + footer 28 + borders 2 = ~114 +// → 140 leaves slack for taller tool rows +// v: v-controls 32 + v-brand 52 + tools ~280 + pinned ~96 + +// footer 52 + borders 2 = ~514 → 560 with slack export const TOOLBAR_SIZES = { - h: { w: 740, h: 102 }, - v: { w: 88, h: 480 }, + h: { w: 740, h: 140 }, + v: { w: 88, h: 560 }, }; // Extra space added when the settings dropdown is open. From 7b47f897c879a95a937222bf6a301d80d8c8dd89 Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 09:01:19 +0530 Subject: [PATCH 15/17] =?UTF-8?q?feat:=20AI=20integration=20scaffolding=20?= =?UTF-8?q?=E2=80=94=20providers,=20credentials,=20IPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation layer for the upcoming Ask-AI feature. Renderer UI lands in follow-up commits. What's in: Provider abstraction (src/main/ai/): - types.ts — ProviderAdapter interface (async-iterable text deltas), AbortSignal honoured for cancellation. - registry.ts — adapter lookup + vision-capable model dropdown options + console URLs for the user to grab a key. - anthropic.ts — Claude via @anthropic-ai/sdk, messages.stream with base64 image on first user turn; PNG mime coerced into the SDK's literal union. - openai.ts — ChatGPT via openai SDK, chat.completions.create stream with image_url data URL. - gemini.ts — Gemini via @google/generative-ai, generateContentStream with inlineData part. Credentials (src/main/ai/credentials.ts): - safeStorage-encrypted API keys, ciphertext in /ai-credentials.json with 0600 perms. - Falls back to in-memory store with a console warning if safeStorage.isEncryptionAvailable() is false (rare). - Keys NEVER appear in PersistedState — renderer only sees a configured: true boolean. IPC layer (src/main/ai/ipc.ts): - ai:set-key, ai:delete-key, ai:get-status, ai:test-connection (1-char probe), ai:ask (returns requestId, streams ai:chunk events), ai:cancel (AbortController per request). Shared/persistence/hub plumbing: - New ProviderId, AiStatus, ChatTurn, AskInput, StreamChunk, ConnectionTestResult types. - HubStateUpdate gains chatOpen, aiActiveProvider, aiActiveModel, aiProfilePrompts. PersistedState mirrors the three persistent fields (chatOpen is transient). - Hub patch handler enforces mutual exclusion between settings, status panel, AND the new chat panel (all share the dock slot). - main.ts onChange resizes the toolbar window when chatOpen flips, same path as settingsOpen/statusPanelOpen. Profile prompts (src/shared/profiles.ts): - Profile interface gains aiPrompt: string. - Trader → chart-analysis prompt; Teacher → student-explainer; General → concise observation. resolveAiPrompt() helper picks the user override from hub.aiProfilePrompts when present, else falls back to the profile default. Preload + penApi.d.ts: - pen.ai.{setKey, deleteKey, getStatus, testConnection, ask, cancel, onChunk} exposed to the renderer. Renderer UI (Settings AI section, ChatPanel, SnipActions button, CSS) follows in subsequent commits. --- package-lock.json | 117 ++++++++++++++++++++++++++++ package.json | 10 ++- src/main/ai/anthropic.ts | 79 +++++++++++++++++++ src/main/ai/credentials.ts | 106 ++++++++++++++++++++++++++ src/main/ai/gemini.ts | 58 ++++++++++++++ src/main/ai/ipc.ts | 152 +++++++++++++++++++++++++++++++++++++ src/main/ai/openai.ts | 73 ++++++++++++++++++ src/main/ai/registry.ts | 56 ++++++++++++++ src/main/ai/types.ts | 23 ++++++ src/main/hub.ts | 99 +++++++++++++++++++++--- src/main/main.ts | 14 ++-- src/main/persistence.ts | 15 +++- src/main/preload.ts | 31 +++++++- src/renderer/penApi.d.ts | 22 +++++- src/shared/profiles.ts | 33 ++++++++ src/shared/types.ts | 56 +++++++++++++- 16 files changed, 922 insertions(+), 22 deletions(-) create mode 100644 src/main/ai/anthropic.ts create mode 100644 src/main/ai/credentials.ts create mode 100644 src/main/ai/gemini.ts create mode 100644 src/main/ai/ipc.ts create mode 100644 src/main/ai/openai.ts create mode 100644 src/main/ai/registry.ts create mode 100644 src/main/ai/types.ts diff --git a/package-lock.json b/package-lock.json index 09e8166..6aaa380 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,12 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.97.1", + "@google/generative-ai": "^0.24.1", "active-win": "^8.2.1", "electron-store": "^10.0.0", + "marked": "^18.0.4", + "openai": "^6.38.0", "perfect-freehand": "^1.2.2", "solid-js": "^1.8.22", "zustand": "^4.5.5" @@ -27,6 +31,27 @@ "vite-plugin-solid": "^2.10.2" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.97.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.97.1.tgz", + "integrity": "sha512-wOf7AUeJPitcVpvKO4UMu63mWH5SaVipkGd7OOQJt/G6VYGlV8D2Gp9dLxOrttDJh/9gqPqdaBwDGcBevumeAg==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -282,6 +307,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1111,6 +1145,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@google/generative-ai": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", + "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1805,6 +1848,12 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -4163,6 +4212,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -5026,6 +5081,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5316,6 +5384,18 @@ "node": ">=12" } }, + "node_modules/marked": { + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", + "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -5850,6 +5930,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "6.38.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.38.0.tgz", + "integrity": "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -6720,6 +6821,16 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -6972,6 +7083,12 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", diff --git a/package.json b/package.json index 1de772f..fb3bade 100644 --- a/package.json +++ b/package.json @@ -22,17 +22,21 @@ "prebuild": "tsc --noEmit && vite build", "build": "npm run prebuild && electron-builder", "build:unpacked": "npm run prebuild && electron-builder --dir", - "build:mac": "npm run prebuild && electron-builder --mac", - "build:win": "npm run prebuild && electron-builder --win", + "build:mac": "npm run prebuild && electron-builder --mac", + "build:win": "npm run prebuild && electron-builder --win", "build:linux": "npm run prebuild && electron-builder --linux", - "build:all": "npm run prebuild && electron-builder -mwl", + "build:all": "npm run prebuild && electron-builder -mwl", "typecheck": "tsc --noEmit", "format": "prettier --write .", "fix:electron": "bash scripts/fix-electron.sh" }, "dependencies": { + "@anthropic-ai/sdk": "^0.97.1", + "@google/generative-ai": "^0.24.1", "active-win": "^8.2.1", "electron-store": "^10.0.0", + "marked": "^18.0.4", + "openai": "^6.38.0", "perfect-freehand": "^1.2.2", "solid-js": "^1.8.22", "zustand": "^4.5.5" diff --git a/src/main/ai/anthropic.ts b/src/main/ai/anthropic.ts new file mode 100644 index 0000000..e839ad8 --- /dev/null +++ b/src/main/ai/anthropic.ts @@ -0,0 +1,79 @@ +import Anthropic from '@anthropic-ai/sdk'; +import type { AskInput } from '../../shared/types'; +import type { ProviderAdapter } from './types'; + +// The Anthropic SDK's MessageParam type is stricter than what's +// useful at our boundary (media_type is a literal union; content is +// a discriminated union per role). We build the array structurally +// and cast at the call site — the runtime shape matches the SDK +// expectations exactly. Stream shape documented at +// https://docs.anthropic.com/en/api/messages-streaming. + +const MAX_TOKENS = 2048; + +// Anthropic only accepts these image MIME types — coerce so the SDK +// doesn't reject. The user can only produce PNGs from snip today, so +// the runtime path is always 'image/png'. +function normaliseMime(mime: string): 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' { + if (mime === 'image/jpeg' || mime === 'image/gif' || mime === 'image/webp') return mime; + return 'image/png'; +} + +function buildMessages(input: AskInput): Anthropic.MessageParam[] { + const out: Anthropic.MessageParam[] = []; + // Prior turns go in verbatim. Prior user turns were text-only — + // only the initial user turn carries the image. + for (const turn of input.history) { + out.push({ role: turn.role, content: turn.content }); + } + const hasPriorUser = input.history.some((t) => t.role === 'user'); + if (input.image && !hasPriorUser) { + out.push({ + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: normaliseMime(input.image.mime), + data: input.image.base64, + }, + }, + { + type: 'text', + text: + input.userMessage.length > 0 + ? input.userMessage + : 'Please analyse the attached image as instructed.', + }, + ], + }); + } else { + out.push({ role: 'user', content: input.userMessage }); + } + return out; +} + +export const anthropic: ProviderAdapter = { + id: 'anthropic', + async *ask(input, apiKey, signal) { + const client = new Anthropic({ apiKey }); + const stream = client.messages.stream( + { + model: input.model, + max_tokens: MAX_TOKENS, + system: input.systemPrompt, + messages: buildMessages(input), + }, + { signal }, + ); + for await (const event of stream) { + if ( + event.type === 'content_block_delta' && + event.delta.type === 'text_delta' + ) { + yield event.delta.text; + } + } + }, +}; diff --git a/src/main/ai/credentials.ts b/src/main/ai/credentials.ts new file mode 100644 index 0000000..74f26af --- /dev/null +++ b/src/main/ai/credentials.ts @@ -0,0 +1,106 @@ +import { app, safeStorage } from 'electron'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { ProviderId } from '../../shared/types'; + +// API keys live OUTSIDE PersistedState (which is plaintext electron-store +// JSON). Each key is encrypted with Electron's safeStorage and stashed +// in a tiny sidecar file in userData/. safeStorage uses the platform +// keychain underneath: macOS Keychain, Windows DPAPI, libsecret on +// Linux. Decryption only succeeds for the same OS user account — so a +// stolen config.json doesn't yield the keys. +// +// File format on disk: +// /ai-credentials.json +// { +// "anthropic": "", +// "openai": "", +// "gemini": "" +// } +// +// In-memory fallback: if safeStorage.isEncryptionAvailable() returns +// false (rare — would happen on a freshly-installed Linux without +// libsecret), keys live in process memory only and are LOST when the +// app quits. We log a clear warning and the renderer surfaces that +// state in the AI settings UI. + +const FILE_NAME = 'ai-credentials.json'; + +let memoryFallback: Partial> | null = null; + +function filePath(): string { + return path.join(app.getPath('userData'), FILE_NAME); +} + +function readStore(): Record { + try { + const raw = fs.readFileSync(filePath(), 'utf-8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function writeStore(store: Record): void { + try { + fs.writeFileSync(filePath(), JSON.stringify(store), { mode: 0o600 }); + } catch (err) { + console.warn('[pen] failed to persist AI credentials store', err); + } +} + +export function encryptionAvailable(): boolean { + return safeStorage.isEncryptionAvailable(); +} + +export function setKey(provider: ProviderId, key: string): void { + const trimmed = key.trim(); + if (!encryptionAvailable()) { + if (!memoryFallback) memoryFallback = {}; + memoryFallback[provider] = trimmed; + console.warn( + '[pen] safeStorage unavailable; AI key for', + provider, + 'held in process memory only (will be lost on quit)', + ); + return; + } + const cipher = safeStorage.encryptString(trimmed).toString('base64'); + const store = readStore(); + store[provider] = cipher; + writeStore(store); +} + +export function getKey(provider: ProviderId): string | null { + if (!encryptionAvailable()) { + return memoryFallback?.[provider] ?? null; + } + const store = readStore(); + const cipher = store[provider]; + if (!cipher) return null; + try { + return safeStorage.decryptString(Buffer.from(cipher, 'base64')); + } catch (err) { + console.warn('[pen] failed to decrypt AI key for', provider, err); + return null; + } +} + +export function hasKey(provider: ProviderId): boolean { + if (!encryptionAvailable()) { + return Boolean(memoryFallback?.[provider]); + } + const store = readStore(); + return typeof store[provider] === 'string' && store[provider].length > 0; +} + +export function deleteKey(provider: ProviderId): void { + if (!encryptionAvailable()) { + if (memoryFallback) delete memoryFallback[provider]; + return; + } + const store = readStore(); + delete store[provider]; + writeStore(store); +} diff --git a/src/main/ai/gemini.ts b/src/main/ai/gemini.ts new file mode 100644 index 0000000..48021ee --- /dev/null +++ b/src/main/ai/gemini.ts @@ -0,0 +1,58 @@ +import { GoogleGenerativeAI } from '@google/generative-ai'; +import type { AskInput } from '../../shared/types'; +import type { ProviderAdapter } from './types'; + +// Gemini's generateContentStream API takes content parts as either +// text or inlineData (base64 with mimeType). The streaming response +// gives chunks where each .text() returns the new delta. The system +// prompt is passed via `systemInstruction` on the model — separate +// from the messages. + +type GeminiPart = { text: string } | { inlineData: { data: string; mimeType: string } }; + +type GeminiContent = { role: 'user' | 'model'; parts: GeminiPart[] }; + +function roleFor(role: 'user' | 'assistant'): 'user' | 'model' { + return role === 'assistant' ? 'model' : 'user'; +} + +function buildContents(input: AskInput): GeminiContent[] { + const out: GeminiContent[] = []; + for (const turn of input.history) { + out.push({ role: roleFor(turn.role), parts: [{ text: turn.content }] }); + } + const hasPriorUser = input.history.some((t) => t.role === 'user'); + const userParts: GeminiPart[] = []; + if (input.image && !hasPriorUser) { + userParts.push({ + inlineData: { data: input.image.base64, mimeType: input.image.mime }, + }); + } + userParts.push({ + text: + input.userMessage.length > 0 + ? input.userMessage + : 'Please analyse the attached image as instructed.', + }); + out.push({ role: 'user', parts: userParts }); + return out; +} + +export const gemini: ProviderAdapter = { + id: 'gemini', + async *ask(input, apiKey, signal) { + const client = new GoogleGenerativeAI(apiKey); + const model = client.getGenerativeModel({ + model: input.model, + systemInstruction: input.systemPrompt, + }); + const result = await model.generateContentStream( + { contents: buildContents(input) }, + { signal }, + ); + for await (const chunk of result.stream) { + const text = chunk.text(); + if (text.length > 0) yield text; + } + }, +}; diff --git a/src/main/ai/ipc.ts b/src/main/ai/ipc.ts new file mode 100644 index 0000000..2fab313 --- /dev/null +++ b/src/main/ai/ipc.ts @@ -0,0 +1,152 @@ +import { BrowserWindow, ipcMain } from 'electron'; +import type { + AiStatus, + AskInput, + ConnectionTestResult, + ProviderId, + StreamChunk, +} from '../../shared/types'; +import { deleteKey, getKey, hasKey, setKey } from './credentials'; +import { getAdapter } from './registry'; + +// Active in-flight requests, keyed by the requestId we hand back to +// the renderer. Lets the chat panel cancel a stream cleanly via +// ai:cancel. Removed on completion / error / cancellation. +const inFlight = new Map(); + +let requestSeq = 0; +function nextRequestId(): string { + return `ai-${Date.now()}-${++requestSeq}`; +} + +function broadcastChunk(chunk: StreamChunk): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send('ai:chunk', chunk); + } +} + +function isProviderId(value: unknown): value is ProviderId { + return value === 'anthropic' || value === 'openai' || value === 'gemini'; +} + +export function registerAiIpc(): void { + ipcMain.handle('ai:set-key', (_evt, payload: { provider: ProviderId; key: string }) => { + if (!isProviderId(payload.provider)) return; + if (typeof payload.key !== 'string' || payload.key.trim().length === 0) { + deleteKey(payload.provider); + return; + } + setKey(payload.provider, payload.key); + }); + + ipcMain.handle('ai:delete-key', (_evt, payload: { provider: ProviderId }) => { + if (!isProviderId(payload.provider)) return; + deleteKey(payload.provider); + }); + + ipcMain.handle('ai:get-status', (): AiStatus[] => { + return (['anthropic', 'openai', 'gemini'] as ProviderId[]).map((provider) => ({ + provider, + configured: hasKey(provider), + })); + }); + + // Tiny request that confirms the key reaches the provider and the + // model exists. We use the default model for each provider and ask + // it to reply with a single character — cheapest possible probe. + ipcMain.handle( + 'ai:test-connection', + async (_evt, payload: { provider: ProviderId; model: string }): Promise => { + const provider = payload.provider; + const model = payload.model; + if (!isProviderId(provider)) return { ok: false, message: 'Unknown provider' }; + const key = getKey(provider); + if (!key) return { ok: false, message: 'No API key configured' }; + const adapter = getAdapter(provider); + const ctrl = new AbortController(); + const started = Date.now(); + try { + const stream = adapter.ask( + { + provider, + model, + systemPrompt: 'You are a connection test. Reply with a single dot.', + history: [], + userMessage: 'ping', + }, + key, + ctrl.signal, + ); + let total = ''; + for await (const chunk of stream) { + total += chunk; + // First chunk is enough to confirm the round-trip. + if (total.length > 0) { + ctrl.abort(); + break; + } + } + return { ok: true, latencyMs: Date.now() - started }; + } catch (err) { + // AbortError on success-with-early-break is expected + const msg = (err as Error)?.message ?? String(err); + if (msg.toLowerCase().includes('abort')) { + return { ok: true, latencyMs: Date.now() - started }; + } + return { ok: false, message: msg }; + } + }, + ); + + ipcMain.handle( + 'ai:ask', + async (_evt, input: AskInput): Promise<{ requestId: string }> => { + const requestId = nextRequestId(); + if (!isProviderId(input.provider)) { + broadcastChunk({ requestId, error: 'Unknown provider', done: true }); + return { requestId }; + } + const key = getKey(input.provider); + if (!key) { + broadcastChunk({ + requestId, + error: 'No API key configured for ' + input.provider, + done: true, + }); + return { requestId }; + } + const adapter = getAdapter(input.provider); + const ctrl = new AbortController(); + inFlight.set(requestId, ctrl); + // Stream in the background so the IPC invoke can return the + // requestId immediately. The renderer subscribes to 'ai:chunk' + // events and matches by requestId. + void (async () => { + try { + for await (const delta of adapter.ask(input, key, ctrl.signal)) { + if (ctrl.signal.aborted) break; + broadcastChunk({ requestId, delta }); + } + broadcastChunk({ requestId, done: true }); + } catch (err) { + const msg = (err as Error)?.message ?? String(err); + // User-initiated abort isn't an error; just close cleanly. + if (ctrl.signal.aborted || msg.toLowerCase().includes('abort')) { + broadcastChunk({ requestId, done: true }); + } else { + broadcastChunk({ requestId, error: msg, done: true }); + } + } finally { + inFlight.delete(requestId); + } + })(); + return { requestId }; + }, + ); + + ipcMain.handle('ai:cancel', (_evt, payload: { requestId: string }) => { + const ctrl = inFlight.get(payload.requestId); + if (ctrl) ctrl.abort(); + inFlight.delete(payload.requestId); + }); +} diff --git a/src/main/ai/openai.ts b/src/main/ai/openai.ts new file mode 100644 index 0000000..cf1a1ab --- /dev/null +++ b/src/main/ai/openai.ts @@ -0,0 +1,73 @@ +import OpenAI from 'openai'; +import type { AskInput } from '../../shared/types'; +import type { ProviderAdapter } from './types'; + +// OpenAI's chat.completions API takes vision via `image_url` content +// parts on user messages. The URL can be a data: URL so we don't need +// to host the image anywhere. Stream chunks arrive with deltas under +// choices[0].delta.content as strings (null when the message starts). + +const MAX_TOKENS = 2048; + +type ContentPart = + | { type: 'text'; text: string } + | { type: 'image_url'; image_url: { url: string } }; + +type OpenAIMessage = + | { role: 'system'; content: string } + | { role: 'user'; content: string | ContentPart[] } + | { role: 'assistant'; content: string }; + +function buildMessages(input: AskInput): OpenAIMessage[] { + const out: OpenAIMessage[] = [{ role: 'system', content: input.systemPrompt }]; + for (const turn of input.history) { + out.push({ role: turn.role, content: turn.content }); + } + const hasPriorUser = input.history.some((t) => t.role === 'user'); + if (input.image && !hasPriorUser) { + out.push({ + role: 'user', + content: [ + { + type: 'image_url', + image_url: { + url: `data:${input.image.mime};base64,${input.image.base64}`, + }, + }, + { + type: 'text', + text: + input.userMessage.length > 0 + ? input.userMessage + : 'Please analyse the attached image as instructed.', + }, + ], + }); + } else { + out.push({ role: 'user', content: input.userMessage }); + } + return out; +} + +export const openai: ProviderAdapter = { + id: 'openai', + async *ask(input, apiKey, signal) { + const client = new OpenAI({ apiKey }); + const stream = await client.chat.completions.create( + { + model: input.model, + max_tokens: MAX_TOKENS, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + messages: buildMessages(input) as any, + stream: true, + }, + { signal }, + ); + for await (const chunk of stream) { + const delta = chunk.choices?.[0]?.delta?.content; + if (typeof delta === 'string' && delta.length > 0) { + yield delta; + } + } + }, +}; diff --git a/src/main/ai/registry.ts b/src/main/ai/registry.ts new file mode 100644 index 0000000..a261e39 --- /dev/null +++ b/src/main/ai/registry.ts @@ -0,0 +1,56 @@ +import type { ProviderId } from '../../shared/types'; +import type { ModelOption, ProviderAdapter } from './types'; +import { anthropic } from './anthropic'; +import { openai } from './openai'; +import { gemini } from './gemini'; + +const ADAPTERS: Record = { + anthropic, + openai, + gemini, +}; + +export function getAdapter(id: ProviderId): ProviderAdapter { + const adapter = ADAPTERS[id]; + if (!adapter) throw new Error(`Unknown AI provider: ${id}`); + return adapter; +} + +// Vision-capable models exposed in the Settings dropdown. The first +// `recommended: true` entry is the default when the user picks a new +// provider. Keep this list small — every model adds a row to the +// dropdown and a maintenance line as providers rotate IDs. +export const MODELS_BY_PROVIDER: Record = { + anthropic: [ + { id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', recommended: true }, + { id: 'claude-opus-4-5', label: 'Claude Opus 4.5' }, + { id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5 (fast / cheap)' }, + ], + openai: [ + { id: 'gpt-4o', label: 'GPT-4o', recommended: true }, + { id: 'gpt-4o-mini', label: 'GPT-4o mini (fast / cheap)' }, + ], + gemini: [ + { id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash', recommended: true }, + { id: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' }, + ], +}; + +export function defaultModelFor(provider: ProviderId): string { + const list = MODELS_BY_PROVIDER[provider]; + return (list.find((m) => m.recommended) ?? list[0]).id; +} + +export const PROVIDER_LABELS: Record = { + anthropic: 'Anthropic Claude', + openai: 'OpenAI ChatGPT', + gemini: 'Google Gemini', +}; + +// The Settings UI uses this to render Set up → links to provider +// console pages for users to grab an API key. +export const PROVIDER_KEY_URLS: Record = { + anthropic: 'https://console.anthropic.com/settings/keys', + openai: 'https://platform.openai.com/api-keys', + gemini: 'https://aistudio.google.com/app/apikey', +}; diff --git a/src/main/ai/types.ts b/src/main/ai/types.ts new file mode 100644 index 0000000..63e7fac --- /dev/null +++ b/src/main/ai/types.ts @@ -0,0 +1,23 @@ +import type { AskInput, ProviderId } from '../../shared/types'; + +export type { ProviderId, AskInput } from '../../shared/types'; + +// Each provider implements this interface in a separate file. The +// async iterable yields plain text deltas; the IPC layer pipes them +// to the renderer as 'ai:chunk' events. AbortSignal is honoured by +// all three SDKs (Anthropic / OpenAI / Gemini) and lets the renderer +// cancel an in-flight stream from the chat panel. +export interface ProviderAdapter { + id: ProviderId; + ask( + input: AskInput, + apiKey: string, + signal: AbortSignal, + ): AsyncIterable; +} + +export interface ModelOption { + id: string; + label: string; + recommended?: boolean; +} diff --git a/src/main/hub.ts b/src/main/hub.ts index db099dc..3c42b48 100644 --- a/src/main/hub.ts +++ b/src/main/hub.ts @@ -8,6 +8,7 @@ import type { Orientation, PerToolWidth, ProfileId, + ProviderId, Theme, ToolId, ToolSettings, @@ -34,6 +35,15 @@ export interface HubState { // in hub so main can grow the toolbar window to fit, the same way // it does for settingsOpen. statusPanelOpen: boolean; + // AI chat panel visibility — transient like statusPanelOpen. + // Mutually exclusive with settingsOpen + statusPanelOpen at the + // dock slot level. + chatOpen: boolean; + // Persisted AI configuration mirrored into the hub so renderers + // can subscribe via the existing hub.onBroadcast pipe. + aiActiveProvider: ProviderId | null; + aiActiveModel: string | null; + aiProfilePrompts: Partial>; } const state: HubState = { @@ -52,6 +62,10 @@ const state: HubState = { saveDir: null, alwaysAskSavePath: false, statusPanelOpen: false, + chatOpen: false, + aiActiveProvider: null, + aiActiveModel: null, + aiProfilePrompts: {}, }; const subscribers = new Set(); @@ -114,6 +128,16 @@ export function hydrateFromPersistence(): void { // older installs without this key fall through to the default. state.saveDir = typeof p.saveDir === 'string' ? p.saveDir : null; state.alwaysAskSavePath = typeof p.alwaysAskSavePath === 'boolean' ? p.alwaysAskSavePath : false; + // AI config — schema-tolerant: missing fields fall back to null / + // empty so old installs upgrade cleanly when they first launch the + // build with AI integration. + state.aiActiveProvider = + p.aiActiveProvider === 'anthropic' || p.aiActiveProvider === 'openai' || p.aiActiveProvider === 'gemini' + ? p.aiActiveProvider + : null; + state.aiActiveModel = typeof p.aiActiveModel === 'string' ? p.aiActiveModel : null; + state.aiProfilePrompts = + p.aiProfilePrompts && typeof p.aiProfilePrompts === 'object' ? p.aiProfilePrompts : {}; // If the active tool is pencil, the canonical color is graphite — // don't restore a stray non-graphite value from a previous session. const colorForTool = @@ -244,10 +268,21 @@ export function patch(update: HubStateUpdate) { if (update.settingsOpen !== undefined && update.settingsOpen !== state.settingsOpen) { state.settingsOpen = update.settingsOpen; changed.add('settingsOpen'); - // Settings and flyout share the side panel slot; only one open at once. - if (state.settingsOpen && state.thicknessFlyoutOpen) { - state.thicknessFlyoutOpen = false; - changed.add('thicknessFlyoutOpen'); + // The dock slot holds AT MOST ONE of: settings, status panel, + // chat panel, thickness flyout. Opening settings closes the rest. + if (state.settingsOpen) { + if (state.thicknessFlyoutOpen) { + state.thicknessFlyoutOpen = false; + changed.add('thicknessFlyoutOpen'); + } + if (state.statusPanelOpen) { + state.statusPanelOpen = false; + changed.add('statusPanelOpen'); + } + if (state.chatOpen) { + state.chatOpen = false; + changed.add('chatOpen'); + } } } if ( @@ -280,13 +315,57 @@ export function patch(update: HubStateUpdate) { ) { state.statusPanelOpen = update.statusPanelOpen; changed.add('statusPanelOpen'); - // Status panel and settings are mutually exclusive panels in the - // same dock slot — opening one closes the other so the renderer - // and main agree on what's showing. - if (state.statusPanelOpen && state.settingsOpen) { - state.settingsOpen = false; - changed.add('settingsOpen'); + // Mutex with the other dock-slot panels. + if (state.statusPanelOpen) { + if (state.settingsOpen) { + state.settingsOpen = false; + changed.add('settingsOpen'); + } + if (state.chatOpen) { + state.chatOpen = false; + changed.add('chatOpen'); + } + } + } + if (update.chatOpen !== undefined && update.chatOpen !== state.chatOpen) { + state.chatOpen = update.chatOpen; + changed.add('chatOpen'); + // Mutex with the other dock-slot panels. + if (state.chatOpen) { + if (state.settingsOpen) { + state.settingsOpen = false; + changed.add('settingsOpen'); + } + if (state.statusPanelOpen) { + state.statusPanelOpen = false; + changed.add('statusPanelOpen'); + } + } + } + if ( + update.aiActiveProvider !== undefined && + update.aiActiveProvider !== state.aiActiveProvider + ) { + state.aiActiveProvider = update.aiActiveProvider; + changed.add('aiActiveProvider'); + save('aiActiveProvider', state.aiActiveProvider); + } + if (update.aiActiveModel !== undefined && update.aiActiveModel !== state.aiActiveModel) { + state.aiActiveModel = update.aiActiveModel; + changed.add('aiActiveModel'); + save('aiActiveModel', state.aiActiveModel); + } + if (update.aiProfilePrompts !== undefined) { + // Merge — caller can patch a single profile's override without + // wiping the others. Empty-string entry removes the override. + const merged = { ...state.aiProfilePrompts, ...update.aiProfilePrompts }; + for (const key of Object.keys(merged) as ProfileId[]) { + const v = merged[key]; + if (typeof v !== 'string' || v.length === 0) delete merged[key]; } + state.aiProfilePrompts = merged; + changed.add('aiProfilePrompts'); + save('aiProfilePrompts', state.aiProfilePrompts); } broadcast(changed); } diff --git a/src/main/main.ts b/src/main/main.ts index 58515a7..c197918 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -10,6 +10,7 @@ import { import { createToolbar, getToolbar, registerToolbarIpc, resizeToolbar } from './windows/toolbar'; import { registerPermissionsIpc } from './permissions'; import { registerCaptureIpc } from './capture'; +import { registerAiIpc } from './ai/ipc'; import { registerDrawingHotkeys, registerEscapeWhileDrawing, @@ -36,6 +37,7 @@ app.whenReady().then(async () => { registerPermissionsIpc(); registerCaptureIpc(); registerToolbarIpc(); + registerAiIpc(); for (const display of screen.getAllDisplays()) { console.log('[pen] creating overlay for display', display.id, display.bounds); @@ -59,16 +61,18 @@ app.whenReady().then(async () => { registerEscapeWhileDrawing(state.drawMode); registerDrawingHotkeys(state.drawMode); } - // The status panel (permission / save error) occupies the same - // dock slot as Settings in the toolbar, so we treat either being - // open as "the side panel is showing" for window-resize purposes. - const sidePanelOpen = state.settingsOpen || state.statusPanelOpen; + // Three panels share the dock slot: settings, status (permission + // / save error), and AI chat. Any of them being open means the + // toolbar window should grow to fit a side panel. + const sidePanelOpen = + state.settingsOpen || state.statusPanelOpen || state.chatOpen; if (changed.has('orientation')) { resizeToolbar(state.orientation, state.minimized, sidePanelOpen, 'default'); } else if ( changed.has('minimized') || changed.has('settingsOpen') || - changed.has('statusPanelOpen') + changed.has('statusPanelOpen') || + changed.has('chatOpen') ) { resizeToolbar(state.orientation, state.minimized, sidePanelOpen, 'keep'); } diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 677e740..75ba22c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1,5 +1,5 @@ import { GRAPHITE_COLOR } from '../shared/constants'; -import type { Orientation, ProfileId, Theme, ToolId } from '../shared/types'; +import type { Orientation, ProfileId, ProviderId, Theme, ToolId } from '../shared/types'; export interface PersistedState { orientation: Orientation; @@ -17,6 +17,16 @@ export interface PersistedState { // Off by default — the "remember + auto-save" UX is the recommended // path. Lives in Settings → File save. alwaysAskSavePath: boolean; + // AI integration. The provider/model pair the "Ask AI" button will + // use; `null` until the user has configured at least one provider. + // API keys themselves are NEVER in PersistedState — they live behind + // OS keychain via src/main/ai/credentials.ts. + aiActiveProvider: ProviderId | null; + aiActiveModel: string | null; + // Per-profile user overrides for the default AI system prompt. + // Falls back to the profile's built-in prompt (see profiles.ts) + // when a profile isn't present here. + aiProfilePrompts: Partial>; } export const PERSISTED_DEFAULTS: PersistedState = { @@ -30,6 +40,9 @@ export const PERSISTED_DEFAULTS: PersistedState = { activeTool: 'pencil', saveDir: null, alwaysAskSavePath: false, + aiActiveProvider: null, + aiActiveModel: null, + aiProfilePrompts: {}, }; interface MinimalStore { diff --git a/src/main/preload.ts b/src/main/preload.ts index a1f1ef4..33dacef 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -1,5 +1,13 @@ import { contextBridge, ipcRenderer } from 'electron'; -import type { HubStateUpdate, IpcChannel } from '../shared/types'; +import type { + AiStatus, + AskInput, + ConnectionTestResult, + HubStateUpdate, + IpcChannel, + ProviderId, + StreamChunk, +} from '../shared/types'; const api = { hub: { @@ -91,6 +99,27 @@ const api = { openPath: (p: string) => ipcRenderer.invoke('shell:open-path' satisfies IpcChannel, p), }, + ai: { + setKey: (provider: ProviderId, key: string) => + ipcRenderer.invoke('ai:set-key' satisfies IpcChannel, { provider, key }), + deleteKey: (provider: ProviderId) => + ipcRenderer.invoke('ai:delete-key' satisfies IpcChannel, { provider }), + getStatus: () => + ipcRenderer.invoke('ai:get-status' satisfies IpcChannel) as Promise, + testConnection: (provider: ProviderId, model: string) => + ipcRenderer.invoke('ai:test-connection' satisfies IpcChannel, { + provider, + model, + }) as Promise, + ask: (input: AskInput) => + ipcRenderer.invoke('ai:ask' satisfies IpcChannel, input) as Promise<{ + requestId: string; + }>, + cancel: (requestId: string) => + ipcRenderer.invoke('ai:cancel' satisfies IpcChannel, { requestId }), + onChunk: (cb: (c: StreamChunk) => void) => + bind('ai:chunk', cb as (v: unknown) => void), + }, app: { info: () => ipcRenderer.invoke('app:info' satisfies IpcChannel) as Promise<{ diff --git a/src/renderer/penApi.d.ts b/src/renderer/penApi.d.ts index 5665557..a9e82c5 100644 --- a/src/renderer/penApi.d.ts +++ b/src/renderer/penApi.d.ts @@ -1,4 +1,12 @@ -import type { HubStateUpdate, ScreenPermissionStatus } from '../shared/types'; +import type { + AiStatus, + AskInput, + ConnectionTestResult, + HubStateUpdate, + ProviderId, + ScreenPermissionStatus, + StreamChunk, +} from '../shared/types'; declare global { interface Window { @@ -70,6 +78,18 @@ declare global { shell: { openPath(p: string): Promise; }; + ai: { + setKey(provider: ProviderId, key: string): Promise; + deleteKey(provider: ProviderId): Promise; + getStatus(): Promise; + testConnection( + provider: ProviderId, + model: string, + ): Promise; + ask(input: AskInput): Promise<{ requestId: string }>; + cancel(requestId: string): Promise; + onChunk(cb: (c: StreamChunk) => void): () => void; + }; app: { info(): Promise<{ name: string; version: string; packaged: boolean }>; relaunch(): Promise; diff --git a/src/shared/profiles.ts b/src/shared/profiles.ts index 3c86d53..0c7c71c 100644 --- a/src/shared/profiles.ts +++ b/src/shared/profiles.ts @@ -5,6 +5,11 @@ export interface Profile { label: string; description: string; tools: ToolId[]; + // Default system prompt used when the user clicks "Ask AI" on a + // snip while this profile is active. Overridable per profile in + // Settings → AI. The user override lives in + // PersistedState.aiProfilePrompts; this is the fallback. + aiPrompt: string; } export const PROFILES: Record = { @@ -13,6 +18,11 @@ export const PROFILES: Record = { label: 'General', description: 'Everyday annotations — simple & common', tools: ['pencil', 'pen', 'eraser', 'hand', 'line', 'arrow', 'text', 'region', 'ellipse', 'snip'], + aiPrompt: + "You are looking at a screenshot the user has captured. Describe what's " + + 'shown concretely and concisely, then answer their question. If they ' + + "don't ask a specific question, surface the most useful one or two " + + 'observations.', }, teacher: { id: 'teacher', @@ -31,6 +41,11 @@ export const PROFILES: Record = { 'ellipse', 'snip', ], + aiPrompt: + "You are explaining this captured image to a curious student. Identify " + + 'what is shown, why it matters in its subject area, and the single key ' + + 'idea the student should take away. Plain language; no jargon unless ' + + 'you define it. Keep it under 150 words unless the user asks for depth.', }, trader: { id: 'trader', @@ -49,9 +64,27 @@ export const PROFILES: Record = { 'text', 'snip', ], + aiPrompt: + 'You are an experienced market analyst looking at a price chart. ' + + 'In order: (1) name the instrument and timeframe if visible, (2) ' + + 'identify the prevailing trend, (3) call out key support / resistance ' + + 'levels and notable patterns, (4) offer one or two probabilistic ' + + 'scenarios with the invalidation level for each. Be concise; do not ' + + 'give financial advice — frame everything as observation.', }, }; export const DEFAULT_PROFILE: ProfileId = 'general'; export const PROFILE_ORDER: ProfileId[] = ['general', 'teacher', 'trader']; + +// Returns the effective system prompt for a profile, preferring the +// user's override (when set) and falling back to the profile default. +export function resolveAiPrompt( + profile: ProfileId, + overrides: Partial>, +): string { + const override = overrides[profile]; + if (override && override.trim().length > 0) return override; + return PROFILES[profile].aiPrompt; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index e076d85..1f13147 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -148,8 +148,54 @@ export type HubStateUpdate = { // main can resize the toolbar window to fit it, the same way it // does for settingsOpen. statusPanelOpen?: boolean; + // AI integration. chatOpen is transient (panel visibility); + // the others persist in PersistedState too. + chatOpen?: boolean; + aiActiveProvider?: ProviderId | null; + aiActiveModel?: string | null; + aiProfilePrompts?: Partial>; }; +// ── AI integration types ─────────────────────────────────────────── + +export type ProviderId = 'anthropic' | 'openai' | 'gemini'; + +export interface AiStatus { + provider: ProviderId; + configured: boolean; +} + +export interface ChatTurn { + role: 'user' | 'assistant'; + content: string; +} + +export interface AskInput { + provider: ProviderId; + model: string; + systemPrompt: string; + // PNG attached to the FIRST user turn only. Renderer encodes the + // snip and sends bytes through IPC; main decodes and forwards to + // the provider in whatever shape it wants. + image?: { mime: string; base64: string }; + history: ChatTurn[]; + userMessage: string; +} + +export interface StreamChunk { + requestId: string; + delta?: string; + done?: boolean; + error?: string; +} + +export interface ConnectionTestResult { + ok: boolean; + message?: string; + // Round-trip duration in milliseconds, populated on ok=true. + latencyMs?: number; +} + export type IpcChannel = | 'hub:state:get' | 'hub:state:update' @@ -186,7 +232,15 @@ export type IpcChannel = | 'permissions:deep-recheck' | 'app:relaunch' | 'settings:save-dir:pick' - | 'shell:open-path'; + | 'shell:open-path' + // AI integration + | 'ai:set-key' + | 'ai:delete-key' + | 'ai:get-status' + | 'ai:test-connection' + | 'ai:ask' + | 'ai:cancel' + | 'ai:chunk'; export interface CaptureSaved { path: string; From 58771b43923f821d0b263613dd7c04809acce88b Mon Sep 17 00:00:00 2001 From: Rajan Singh Date: Thu, 21 May 2026 09:17:03 +0530 Subject: [PATCH 16/17] =?UTF-8?q?feat:=20Ask=20AI=20UI=20=E2=80=94=20Setti?= =?UTF-8?q?ngs=20section,=20ChatPanel,=20snip-menu=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renderer UI half of the AI integration. Wires the scaffolding from the previous commit into the user-facing surface. Settings → AI section (toolbar Settings panel): - Provider dropdown (Anthropic / OpenAI / Gemini, marked "configured" when a key is saved). - Model dropdown filtered by provider. - Masked API key input + Save / Test / Delete buttons. Test runs a 1-char probe with cancel-on-first-chunk to confirm round-trip without burning tokens. - ● Configured badge + last-test result (ok / fail with latency). - "Get a key →" link to the provider's console. - Per-profile prompt textareas (Trader / Teacher / General) with "Reset" links that restore the built-in default. - Disclosure paragraph: images go directly to the chosen provider; Lekhini doesn't log or proxy. ChatPanel (new component, src/renderer/toolbar/ChatPanel.tsx): - Reuses the .settings-panel chrome — docks in the same slot as Settings and the Status panel (mutex enforced in hub.patch). - Header with provider · model badge and close button. - Image thumbnail of the snip (Blob → object URL). - Streaming message list with user / assistant bubbles. Assistant responses render through `marked` for proper markdown (code blocks, lists, bold/italic, links). - Composer textarea with Send (⌘↩) and Esc-to-close. Send disables while a stream is in flight; replaces itself with a red Cancel button that calls pen.ai.cancel(requestId). SnipActions Ask AI button (overlay): - New 'Ask AI' button between Save and ✕ — only rendered when hub.aiActiveProvider is non-null (i.e. user has configured a key and we know which provider to use). - Menu widens from 168 to 232 px to fit the extra button without wrapping. - Click → pen.snip.askAi(profile). Main captures + composites the snip (same path as Save/Copy), then broadcasts chat:session to all renderers. Toolbar's ChatPanel picks it up and auto-fires the first AI turn. - Drawmode stays on (unlike Save/Copy) — the chat is in the toolbar window so the overlay can stay interactive for more snipping. Cross-window flow: - New 'snip:ask-ai' IPC (renderer → main) and 'chat:start' IPC (alternative renderer → main with raw bytes; not used by SnipActions but available for future entry points). - 'chat:session' broadcast (main → all renderers) carries { sessionId, png, mime, profile }. - startChatSession() helper exported from ai/ipc.ts; used by both the chat:start IPC handler and capture.ts's askAiAboutFocusedSnip. CSS: - .chat-panel, .chat-thumb-wrap, .chat-bubble-user/.assistant, .chat-typing animation, .chat-markdown tight typography for the narrow panel, .chat-composer with auto-grow textarea. - .ai-section settings styles: .ai-select (native dropdown styled), .ai-key-input (monospace masked), .ai-prompt-textarea, .ai-badge-configured (green pill), .ai-test-result.ok/.fail, .ai-disclosure italic footnote. - Overlay's snip-action-ai button: muted purple accent so it reads as a different class than Copy/Save. --- src/main/ai/ipc.ts | 36 +++ src/main/capture.ts | 32 +++ src/main/preload.ts | 14 ++ src/renderer/overlay/App.tsx | 71 +++++- src/renderer/overlay/index.html | 13 ++ src/renderer/penApi.d.ts | 11 + src/renderer/toolbar/App.tsx | 335 ++++++++++++++++++++++++++- src/renderer/toolbar/ChatPanel.tsx | 311 +++++++++++++++++++++++++ src/renderer/toolbar/styles.css | 354 +++++++++++++++++++++++++++++ src/shared/types.ts | 26 ++- 10 files changed, 1189 insertions(+), 14 deletions(-) create mode 100644 src/renderer/toolbar/ChatPanel.tsx diff --git a/src/main/ai/ipc.ts b/src/main/ai/ipc.ts index 2fab313..f55c5a8 100644 --- a/src/main/ai/ipc.ts +++ b/src/main/ai/ipc.ts @@ -2,12 +2,15 @@ import { BrowserWindow, ipcMain } from 'electron'; import type { AiStatus, AskInput, + ChatSessionPayload, ConnectionTestResult, + ProfileId, ProviderId, StreamChunk, } from '../../shared/types'; import { deleteKey, getKey, hasKey, setKey } from './credentials'; import { getAdapter } from './registry'; +import { patch as patchHub } from '../hub'; // Active in-flight requests, keyed by the requestId we hand back to // the renderer. Lets the chat panel cancel a stream cleanly via @@ -149,4 +152,37 @@ export function registerAiIpc(): void { if (ctrl) ctrl.abort(); inFlight.delete(payload.requestId); }); + + // Renderer-facing chat:start handler. Calls startChatSession with + // the bytes the renderer hands over. Equivalent to the in-process + // startChatSession call that capture.ts makes for the snip-ask path. + ipcMain.handle( + 'chat:start', + (_evt, payload: { png: Uint8Array; mime: string; profile: ProfileId }) => { + const sessionId = startChatSession( + Buffer.from(payload.png), + payload.mime, + payload.profile, + ); + return { sessionId }; + }, + ); +} + +// Shared helper: broadcast a new chat session to every renderer and +// open the dock-slot chat panel. Called by the chat:start IPC and +// also by capture.ts when Ask AI is triggered from the snip menu. +let chatSeq = 0; +export function startChatSession( + png: Buffer, + mime: string, + profile: ProfileId, +): string { + const sessionId = `chat-${Date.now()}-${++chatSeq}`; + const session: ChatSessionPayload = { sessionId, png, mime, profile }; + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send('chat:session', session); + } + patchHub({ chatOpen: true }); + return sessionId; } diff --git a/src/main/capture.ts b/src/main/capture.ts index 0ef37f8..b58b06f 100644 --- a/src/main/capture.ts +++ b/src/main/capture.ts @@ -15,6 +15,8 @@ import { getOverlays } from './windows/overlay'; import { notifyStatus, onFocusRecheck, screenStatus } from './permissions'; import { persisted } from './persistence'; import { patch as patchHub } from './hub'; +import { startChatSession } from './ai/ipc'; +import type { ProfileId } from '../shared/types'; interface Rect { x: number; @@ -121,6 +123,33 @@ export async function copyFocusedSnipToClipboard(): Promise { clipboard.writeImage(img); } +// Start an AI chat about the user's current snip selection. Same +// capture + composite path Save / Copy use; the bytes are handed to +// startChatSession which broadcasts chat:session and opens the dock +// chat panel. +export async function askAiAboutFocusedSnip(profile: ProfileId): Promise { + if (!gateScreenForCapture('clipboard')) return; + const displayId = getFocusedDisplayId(); + const rect = snipSelections.get(displayId); + if (!rect) return; + const display = screen.getAllDisplays().find((d) => d.id === displayId); + if (!display) return; + const overlay = getOverlays().get(displayId); + if (!overlay || overlay.isDestroyed()) return; + + // Hide the dashed selection so it isn't baked into the PNG sent + // to the AI (the existing crop rect is already in hand). + setSnipSelection(displayId, null); + await waitMs(60); + + const png = await captureCroppedComposite(overlay, display, rect); + if (!png) { + handleCaptureFailure(); + return; + } + startChatSession(png, 'image/png', profile); +} + export async function captureFocusedDisplay(): Promise { if (!gateScreenForCapture('capture')) return; @@ -317,6 +346,9 @@ export function registerCaptureIpc() { ipcMain.handle('snip:clear', (_evt, payload: { displayId: number }) => { setSnipSelection(payload.displayId, null); }); + ipcMain.handle('snip:ask-ai', async (_evt, payload: { profile: ProfileId }) => { + await askAiAboutFocusedSnip(payload.profile); + }); // 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 diff --git a/src/main/preload.ts b/src/main/preload.ts index 33dacef..fee227e 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -2,9 +2,11 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { AiStatus, AskInput, + ChatSessionPayload, ConnectionTestResult, HubStateUpdate, IpcChannel, + ProfileId, ProviderId, StreamChunk, } from '../shared/types'; @@ -51,6 +53,8 @@ const api = { clear: (payload: { displayId: number }) => ipcRenderer.invoke('snip:clear' satisfies IpcChannel, payload), copy: () => ipcRenderer.invoke('snip:copy' satisfies IpcChannel), + askAi: (profile: ProfileId) => + ipcRenderer.invoke('snip:ask-ai' satisfies IpcChannel, { profile }), }, relay: { undo: () => ipcRenderer.invoke('relay:undo' satisfies IpcChannel), @@ -120,6 +124,16 @@ const api = { onChunk: (cb: (c: StreamChunk) => void) => bind('ai:chunk', cb as (v: unknown) => void), }, + chat: { + // Called by SnipActions in the overlay to hand a snip off to the + // toolbar's ChatPanel. Main relays via chat:session. + start: (payload: { png: Uint8Array; mime: string; profile: ProfileId }) => + ipcRenderer.invoke('chat:start' satisfies IpcChannel, payload) as Promise<{ + sessionId: string; + }>, + onSession: (cb: (s: ChatSessionPayload) => void) => + bind('chat:session', cb as (v: unknown) => void), + }, app: { info: () => ipcRenderer.invoke('app:info' satisfies IpcChannel) as Promise<{ diff --git a/src/renderer/overlay/App.tsx b/src/renderer/overlay/App.tsx index 9804517..efc3fce 100644 --- a/src/renderer/overlay/App.tsx +++ b/src/renderer/overlay/App.tsx @@ -5,7 +5,7 @@ import { attachPointerPipeline } from './canvas/pointerPipeline'; import { cursorFor } from './cursors'; import { store, type SnipRect } from './store'; import { buildRegistry } from './tools/registry'; -import type { Item, Theme, ToolSettings, Whiteboard } from '../../shared/types'; +import type { Item, ProfileId, Theme, ToolSettings, Whiteboard } from '../../shared/types'; import type { Tool, ToolContext } from './tools/types'; export function OverlayApp() { @@ -19,6 +19,10 @@ export function OverlayApp() { // can re-render on Solid's signal cycle. Synced inside the store // subscriber below. const [snipRectSig, setSnipRectSig] = createSignal(null); + // AI-configuration mirror + current profile, used by the SnipActions + // Ask AI button. Updated from hub.onBroadcast below. + const [aiConfigured, setAiConfigured] = createSignal(false); + const [activeProfile, setActiveProfile] = createSignal('general'); let currentTheme: Theme = 'dark'; const applyCursor = () => { @@ -175,6 +179,8 @@ export function OverlayApp() { whiteboard?: Whiteboard; theme?: Theme; thicknessFlyoutOpen?: boolean; + aiActiveProvider?: string | null; + profile?: ProfileId; }; if (s.activeTool) store.getState().setActiveTool(s.activeTool as never); if (typeof s.drawMode === 'boolean') store.getState().setDrawMode(s.drawMode); @@ -187,6 +193,8 @@ export function OverlayApp() { if (typeof s.thicknessFlyoutOpen === 'boolean') { toolbarFlyoutOpen = s.thicknessFlyoutOpen; } + if ('aiActiveProvider' in s) setAiConfigured(s.aiActiveProvider != null); + if (s.profile) setActiveProfile(s.profile); }); void window.pen.hub.get().then((state) => { @@ -197,6 +205,8 @@ export function OverlayApp() { whiteboard: Whiteboard; theme?: Theme; thicknessFlyoutOpen?: boolean; + aiActiveProvider?: string | null; + profile?: ProfileId; }; store.getState().setActiveTool(s.activeTool as never); store.getState().setDrawMode(s.drawMode); @@ -206,6 +216,8 @@ export function OverlayApp() { if (typeof s.thicknessFlyoutOpen === 'boolean') { toolbarFlyoutOpen = s.thicknessFlyoutOpen; } + setAiConfigured(s.aiActiveProvider != null); + if (s.profile) setActiveProfile(s.profile); applyCursor(); }); @@ -263,7 +275,13 @@ export function OverlayApp() { (Order matters: snipRectSig() goes last so the && chain resolves to the SnipRect itself for Show's accessor.) */} - {(rect) => } + {(rect) => ( + + )} ); @@ -273,15 +291,19 @@ export function OverlayApp() { // Anchored at the bottom-right corner of the rect with a small offset. // Falls back to inside-rect-bottom-right if the rect is too close to // the screen edge to fit the menu below it. -function SnipActions(props: { rect: SnipRect }) { - const MENU_W = 168; +function SnipActions(props: { + rect: SnipRect; + aiConfigured: boolean; + profile: ProfileId; +}) { + // Wider menu when the Ask AI button is showing so the four buttons + // fit in one row without wrapping. + const MENU_W = () => (props.aiConfigured ? 232 : 168); const MENU_H = 32; const GAP = 8; - // Tracks an in-flight Copy so the button can show 'Copying…' and - // block double-clicks. Save is fire-and-forget (capture goes - // through the toolbar's save flow), so it doesn't get a busy state - // — the menu just dismisses immediately. - const [busy, setBusy] = createSignal<'copy' | null>(null); + // Tracks an in-flight Copy / AskAi so the button can show its + // busy label and block double-clicks. Save is fire-and-forget. + const [busy, setBusy] = createSignal<'copy' | 'ask' | null>(null); const clearSnip = (): void => { const displayId = window.pen.env.displayId(); @@ -316,21 +338,38 @@ function SnipActions(props: { rect: SnipRect }) { void window.pen.relay.screenshot(); exitToIdle(); }; + const onAskAi = async (): Promise => { + if (busy()) return; + setBusy('ask'); + try { + // Main captures + composites + broadcasts chat:session → + // toolbar's ChatPanel picks it up and fires the first AI turn. + // Selection is cleared by capture.ts during the capture (same + // path Save / Copy use). + await window.pen.snip.askAi(props.profile); + } finally { + setBusy(null); + // Don't exitToIdle here — the user might want to keep snipping + // while chatting. The chat panel is in the toolbar window; the + // overlay stays interactive. + } + }; const onCancel = (): void => clearSnip(); const positioned = (): { left: string; top: string } => { const r = props.rect; const winW = window.innerWidth; const winH = window.innerHeight; + const menuW = MENU_W(); // Default: below the rect, right-aligned to its right edge. - let left = r.x + r.w - MENU_W; + let left = r.x + r.w - menuW; let top = r.y + r.h + GAP; // If it would overflow the bottom of the screen, place ABOVE the rect. if (top + MENU_H > winH - 4) top = r.y - MENU_H - GAP; // If still off-screen (very tall rect near top), tuck inside the rect. if (top < 4) top = Math.min(r.y + r.h - MENU_H - GAP, winH - MENU_H - 4); // Horizontal clamping: never let the menu fall off either edge. - left = Math.max(4, Math.min(left, winW - MENU_W - 4)); + left = Math.max(4, Math.min(left, winW - menuW - 4)); return { left: `${left}px`, top: `${top}px` }; }; @@ -356,6 +395,16 @@ function SnipActions(props: { rect: SnipRect }) { > Save + + + + + + + + + + {(r) => ( +
+ {r().ok + ? `✓ ${r().message ?? 'OK'}${ + r().latencyMs ? ` · ${r().latencyMs}ms` : '' + }` + : `✗ ${r().message ?? 'Failed'}`} +
+ )} +
+
{ + e.preventDefault(); + void window.pen.shell.openPath(PROVIDER_KEY_URLS[aiSelectedProvider()]); + }} + > + Get a key → + + +
+ Profile prompts + + {(pid) => ( +
+
+ {PROFILES[pid].label} + + + +
+