From a3b41bfb545943147476e9b863ebda1af3147c06 Mon Sep 17 00:00:00 2001 From: jvaught01 Date: Thu, 30 Apr 2026 13:18:43 -0700 Subject: [PATCH 01/12] feat: annotated walkthroughs, ptt modes, clipboard typing, transcription tuning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walkthroughs - New [POINT:...] semantics: a sequence of tags becomes a numbered walkthrough rather than a single label. Cursor walks step-by-step, each step gets a pulsing halo and a numbered bubble (e.g. "2/4 click File"). Single-point answers fall through as 1-step walkthroughs and look the same as before plus the halo. - Stream window renders a walkthrough card in lockstep with the overlay: the active step is highlighted, completed steps get a strike-through and check, pending steps are dimmed. - Step timing is driven from the main process (companion-manager) and broadcast to overlay + stream via WALKTHROUGH / WALKTHROUGH_STEP IPC, so they can never drift. Per-step dwell scales with caption length (2.6–5.5s) so longer instructions stay readable. - Stream window stays visible across a walkthrough in 'responses' mode, instead of hiding the moment voice state goes back to idle. Screenshot quality - captureDisplays now defaults to cursor-only — only the screen the cursor is on is sent to the LLM. Less wasted vision tokens, no ambiguity over which screen the model is talking about. - Empty thumbnails (the macOS "screen recording denied" pattern) are filtered out instead of being uploaded as empty base64. If all thumbnails come back empty, the user gets a friendly spoken response telling them to grant Screen Recording, instead of an Anthropic 400. - Bumped MAX_DIMENSION from 1280 → 1600 and quality 80 → 82 so the model has more pixel precision to land [POINT:...] coordinates on small UI targets. Push-to-talk modes - New `pttMode: 'hold' | 'toggle'` setting, exposed as a segmented control in General → Shortcut. Hold = press-and-hold (Win/Linux); Toggle = tap to start, tap to stop. macOS forces toggle regardless of stored value because Electron's globalShortcut exposes no key-up event there. Clipboard typing - New [TYPE:text] tag the model can emit when the user asks to type something into a focused field. Text is copied to the clipboard and a glassy toast appears over the cursor display ("Copied — press ⌘V to paste") with a preview of the text. Stripped from the spoken/streamed reply so TTS doesn't read it back. Setting toggle ("Allow Flicky to type for you") is in place; native auto-typer wires up next commit. Transcription tuning - Groq Whisper now gets `language=en`, `temperature=0`, and a vocab prompt loaded with the kind of words a screen-aware assistant hears (UI verbs, common app names). Cuts a lot of homophone weirdness and language-detection drift on short clips. TTS echo fix - TTS audio was being broadcast to every overlay window, so users with N displays heard N copies. Added sendToOneOverlay so audio plays exactly once. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/companion-manager.ts | 115 ++++++++++++++-- src/main/index.ts | 69 ++++++++-- src/main/services/element-detector.ts | 85 +++++++++--- src/main/services/prompts.ts | 25 +++- src/main/services/screen-capture.ts | 46 ++++++- src/main/services/settings-store.ts | 7 + src/main/services/transcription.ts | 19 +++ src/preload/index.ts | 26 +++- src/renderer/components/OverlayApp.tsx | 117 +++++++++++----- src/renderer/components/StreamApp.tsx | 56 +++++++- src/renderer/components/panel/GeneralTab.tsx | 52 ++++++- src/renderer/styles/overlay.css | 135 +++++++++++++++++++ src/renderer/styles/panel.css | 33 +++++ src/renderer/styles/stream.css | 83 ++++++++++++ src/shared/types.ts | 50 +++++++ 15 files changed, 827 insertions(+), 91 deletions(-) diff --git a/src/main/companion-manager.ts b/src/main/companion-manager.ts index affe63d..2756f74 100644 --- a/src/main/companion-manager.ts +++ b/src/main/companion-manager.ts @@ -1,11 +1,11 @@ -import { app, systemPreferences, shell, desktopCapturer } from 'electron'; +import { app, systemPreferences, shell, desktopCapturer, clipboard } from 'electron'; import { ClaudeAPI } from './services/claude-api'; import { OpenAIAPI } from './services/openai-api'; import { OllamaAPI } from './services/ollama-api'; import { ElevenLabsTTS } from './services/elevenlabs-tts'; import { createTranscriptionProvider, type TranscriptionProvider } from './services/transcription'; import { captureAllDisplays } from './services/screen-capture'; -import { parsePointTags } from './services/element-detector'; +import { parseAllPointTags, parseTypeTags, TAG_STRIP_REGEX } from './services/element-detector'; import { ContextManager } from './services/context-manager'; import * as settingsStore from './services/settings-store'; import * as keyStore from './services/key-store'; @@ -19,7 +19,9 @@ import type { MindProvider, GroqTranscriptionModel, TranscriptionResult, - DetectedElement, + Walkthrough, + PttMode, + TypeRequest, ScreenCapture, ApiKeyName, ReasoningDepth, @@ -35,7 +37,10 @@ export interface CompanionCallbacks { onTranscriptUpdate: (result: TranscriptionResult) => void; onAiResponseChunk: (chunk: string) => void; onAiResponseComplete: (fullText: string) => void; - onElementDetected: (element: DetectedElement | null) => void; + onWalkthrough: (walkthrough: Walkthrough | null) => void; + /** Active step index (0-based) inside the current walkthrough, or null when idle. */ + onWalkthroughStep: (index: number | null) => void; + onTypeFulfilled: (request: TypeRequest) => void; onSettingsChanged: (settings: FlickySettings) => void; onMemoryStatsChanged: (stats: MemoryStats) => void; onChatEntryAdded: (entry: ChatEntry) => void; @@ -67,6 +72,8 @@ export class CompanionManager { */ private turnId = 0; private currentAbort: AbortController | null = null; + /** Pending walkthrough step timers, cleared on new turn or end-of-walkthrough. */ + private walkthroughTimers: ReturnType[] = []; /** * If startRecording is in flight, other callers (typically a quick-release * stopPushToTalk) await this before deciding whether to stop. Without it, @@ -185,6 +192,16 @@ export class CompanionManager { this.emitSettings(); } + setPttMode(mode: PttMode): void { + settingsStore.set('pttMode', mode); + this.emitSettings(); + } + + setAutoTypeEnabled(enabled: boolean): void { + settingsStore.set('autoTypeEnabled', enabled); + this.emitSettings(); + } + setLaunchAtLogin(enabled: boolean): void { settingsStore.set('launchAtLogin', enabled); try { @@ -359,6 +376,46 @@ export class CompanionManager { await this.stopRecordingAndProcess(); } + private clearWalkthroughTimers(): void { + for (const t of this.walkthroughTimers) clearTimeout(t); + this.walkthroughTimers = []; + } + + /** + * Schedule a walkthrough so overlay + stream + any other surface stay + * in lockstep. Emits the full step list once, then a step index per + * step at computed times, then clears with `null` after the last step. + * + * Per-step dwell scales with caption length so longer instructions + * stay on screen long enough to read; floor of 2.6s, ceiling of 5.5s. + */ + private startWalkthrough(walkthrough: Walkthrough, isCurrent: () => boolean): void { + this.clearWalkthroughTimers(); + this.callbacks.onWalkthrough(walkthrough); + + const dwellFor = (label: string): number => + Math.max(2600, Math.min(5500, 1800 + label.length * 80)); + + let cursor = 0; + walkthrough.steps.forEach((step, i) => { + const start = cursor; + const t = setTimeout(() => { + if (!isCurrent()) return; + this.callbacks.onWalkthroughStep(i); + }, start); + this.walkthroughTimers.push(t); + cursor += dwellFor(step.label); + }); + + // After the last step has had its dwell, clear the walkthrough. + const endTimer = setTimeout(() => { + if (!isCurrent()) return; + this.callbacks.onWalkthroughStep(null); + this.callbacks.onWalkthrough(null); + }, cursor); + this.walkthroughTimers.push(endTimer); + } + private async startRecording(): Promise { // Bump the turn and abort any in-flight work from the previous one // so the user's new message supersedes whatever Flicky was doing. @@ -367,6 +424,9 @@ export class CompanionManager { this.currentAbort.abort(); this.currentAbort = null; } + this.clearWalkthroughTimers(); + this.callbacks.onWalkthrough(null); + this.callbacks.onWalkthroughStep(null); this.isRecording = true; this.setVoiceState('listening'); @@ -417,6 +477,19 @@ export class CompanionManager { console.error('Screen capture failed:', err); this.lastScreenshots = []; } + if (this.lastScreenshots.length === 0) { + // Almost always means Screen Recording permission is missing on + // macOS — desktopCapturer returns empty thumbnails in that case. + // Surface a friendly response instead of letting an empty image + // 400 the upstream LLM call. + const msg = process.platform === 'darwin' + ? "i can't see your screen right now — give flicky screen recording permission in system settings, then quit and reopen the app." + : "i can't see your screen right now — screen capture failed."; + this.callbacks.onAiResponseChunk(msg); + this.callbacks.onAiResponseComplete(msg); + this.setVoiceState('idle'); + return; + } const settings = settingsStore.getAll(); const myTurnId = this.turnId; @@ -446,7 +519,7 @@ export class CompanionManager { if (!isCurrent()) return; analytics.trackAiResponseReceived(fullText); - const cleanText = fullText.replace(/\[POINT:[^\]]+\]/g, '').trim(); + const cleanText = fullText.replace(TAG_STRIP_REGEX, '').trim(); this.callbacks.onAiResponseComplete(cleanText); await this.context.recordExchange(result.text, cleanText, { @@ -462,10 +535,31 @@ export class CompanionManager { }); this.callbacks.onChatEntryAdded(entry); - const element = parsePointTags(fullText, this.lastScreenshots); - if (element) { - this.callbacks.onElementDetected(element); - analytics.trackElementPointed(element.label); + const walkthrough = parseAllPointTags(fullText, this.lastScreenshots); + if (walkthrough) { + console.log( + `[Flicky] Walkthrough: ${walkthrough.steps.length} step(s) →`, + walkthrough.steps.map((s) => `${s.step}/${s.total} "${s.label}"`).join(', '), + ); + this.startWalkthrough(walkthrough, isCurrent); + analytics.trackElementPointed( + walkthrough.steps.length > 1 + ? `${walkthrough.steps[0].label} (+${walkthrough.steps.length - 1} more)` + : walkthrough.steps[0].label, + ); + } + + // [TYPE:...] tags. For now we always handle them via clipboard + // handoff regardless of the autoTypeEnabled setting, since the + // native auto-typer module isn't wired up yet. The setting is + // reserved for the upcoming follow-up. + const typeTexts = parseTypeTags(fullText); + for (const text of typeTexts) { + if (!text) continue; + clipboard.writeText(text); + const preview = text.length > 50 ? `${text.slice(0, 50)}…` : text; + console.log(`[Flicky] Type request → clipboard: "${preview}"`); + this.callbacks.onTypeFulfilled({ text, preview, autoTyped: false }); } if (settings.speakReplies && keyStore.getKeyStatus().elevenlabs) { @@ -488,9 +582,6 @@ export class CompanionManager { if (!isCurrent()) return; this.setVoiceState('idle'); - setTimeout(() => { - if (isCurrent()) this.callbacks.onElementDetected(null); - }, 6000); }, onError: (err: Error) => { if (!isCurrent()) return; diff --git a/src/main/index.ts b/src/main/index.ts index 7265d77..8851ca4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -23,6 +23,8 @@ let streamWindow: BrowserWindow | null = null; let companion: CompanionManager; let isAppQuitting = false; let lastVoiceState = 'idle'; +/** Whether a walkthrough is currently playing (steps 1..N animating). */ +let walkthroughActive = false; app.on('before-quit', () => { isAppQuitting = true; }); @@ -89,6 +91,17 @@ function sendToOverlays(channel: string, ...args: unknown[]): void { } } +/** + * Pick a single overlay to receive an event. Used for things that must + * not duplicate across displays — TTS audio playback being the canonical + * case (broadcasting to all overlays plays the buffer once per display + * and audibly doubles). + */ +function sendToOneOverlay(channel: string, ...args: unknown[]): void { + const target = overlayWindows.find((w) => !w.isDestroyed()); + if (target) target.webContents.send(channel, ...args); +} + function sendToStream(channel: string, ...args: unknown[]): void { if (streamWindow && !streamWindow.isDestroyed()) { streamWindow.webContents.send(channel, ...args); @@ -120,13 +133,32 @@ app.whenReady().then(() => { sendToPanel(IPC.AI_RESPONSE_COMPLETE, text); sendToStream(IPC.AI_RESPONSE_COMPLETE, text); }, - onElementDetected: (el) => sendToOverlays(IPC.ELEMENT_DETECTED, el), + onWalkthrough: (w) => { + walkthroughActive = !!w; + sendToOverlays(IPC.WALKTHROUGH, w); + sendToStream(IPC.WALKTHROUGH, w); + // Keep the stream visible across the walkthrough in 'responses' mode, + // even after voice state has returned to idle. When the walkthrough + // ends we re-evaluate based on the current voice state. + if (w) applyStreamVisibility(companion.getSettings().streamVisibility); + else updateStreamForVoiceState(lastVoiceState); + }, + onWalkthroughStep: (i) => { + sendToOverlays(IPC.WALKTHROUGH_STEP, i); + sendToStream(IPC.WALKTHROUGH_STEP, i); + }, + onTypeFulfilled: (req) => { + // Toast goes on a single overlay (cursor display) so the user + // sees one notification, not one per monitor. + sendToOneOverlay(IPC.TYPE_FULFILLED, req); + sendToStream(IPC.TYPE_FULFILLED, req); + }, onSettingsChanged: (s) => sendToPanel(IPC.SETTINGS_CHANGED, s), onMemoryStatsChanged: (stats) => sendToPanel(IPC.MEMORY_STATS, stats), onChatEntryAdded: (entry) => sendToPanel(IPC.CHAT_ENTRY_ADDED, entry), onStartAudioCapture: () => sendToOverlays(AUDIO_IPC.START_CAPTURE), onStopAudioCapture: () => sendToOverlays(AUDIO_IPC.STOP_CAPTURE), - onPlayAudio: (buf) => sendToOverlays('play-audio', buf), + onPlayAudio: (buf) => sendToOneOverlay('play-audio', buf), onCursorVisibilityChanged: (enabled) => applyOverlayVisibility(enabled), onStreamVisibilityChanged: (v) => applyStreamVisibility(v), }); @@ -182,22 +214,25 @@ app.whenReady().then(() => { // Register global push-to-talk shortcut. // - // Windows/Linux: globalShortcut fires repeatedly on OS key-repeat while - // the accelerator is held, so we debounce — first press starts recording, - // subsequent repeats are ignored, and we stop after no fires for 250 ms - // (meaning the key was released). + // Two modes, chosen by the `pttMode` setting: + // 'hold' — Windows/Linux only. globalShortcut fires repeatedly on + // OS key-repeat while the accelerator is held; we start on + // the first fire and stop after 250 ms of silence (release). + // 'toggle' — first tap starts, second tap stops. Required on macOS, + // where globalShortcut fires exactly once per press and + // Electron exposes no key-up event. // - // macOS: globalShortcut fires exactly once on press; the OS does not - // repeat global accelerators and Electron exposes no key-up event. We - // can't implement true press-and-hold without a native key-event hook, - // so fall back to toggle: first tap starts, second tap stops. + // On macOS we always behave as 'toggle' regardless of the stored setting, + // so a user who set 'hold' on another platform doesn't get a stuck mic. + const isMac = process.platform === 'darwin'; let pttDebounceTimer: ReturnType | null = null; let pttActive = false; let currentShortcut = ''; - const isMac = process.platform === 'darwin'; const pttHandler = () => { - if (isMac) { + const mode = isMac ? 'toggle' : companion.getSettings().pttMode; + + if (mode === 'toggle') { if (!pttActive) { pttActive = true; companion.startPushToTalk(); @@ -207,6 +242,8 @@ app.whenReady().then(() => { } return; } + + // 'hold' mode (Windows/Linux): rely on key-repeat, debounce on silence. if (pttDebounceTimer) { clearTimeout(pttDebounceTimer); pttDebounceTimer = null; @@ -281,6 +318,8 @@ app.whenReady().then(() => { ipcMain.on(IPC.TOGGLE_CURSOR, (_e, enabled) => companion.toggleCursor(enabled)); ipcMain.on(IPC.SET_LAUNCH_AT_LOGIN, (_e, enabled) => companion.setLaunchAtLogin(enabled)); ipcMain.on(IPC.SET_PUSH_TO_TALK_SHORTCUT, (_e, accel: string) => companion.setPushToTalkShortcut(accel)); + ipcMain.on(IPC.SET_PTT_MODE, (_e, mode) => companion.setPttMode(mode)); + ipcMain.on(IPC.SET_AUTO_TYPE_ENABLED, (_e, enabled: boolean) => companion.setAutoTypeEnabled(enabled)); ipcMain.on(IPC.SET_STREAM_VISIBILITY, (_e, v: StreamVisibility) => companion.setStreamVisibility(v)); ipcMain.on(IPC.SET_STREAM_WINDOW_BOUNDS, (_e, b: StreamWindowBounds) => companion.setStreamWindowBounds(b)); ipcMain.on(IPC.CLEAR_STREAM, () => sendToStream(IPC.CLEAR_STREAM)); @@ -488,7 +527,11 @@ function updateStreamForVoiceState(state: string): void { if (!streamWindow || streamWindow.isDestroyed()) return; const v = companion.getSettings().streamVisibility; if (v !== 'responses') return; - const active = state === 'listening' || state === 'processing' || state === 'responding'; + const active = + state === 'listening' || + state === 'processing' || + state === 'responding' || + walkthroughActive; if (active) { streamWindow.showInactive(); } else if (state === 'idle') { diff --git a/src/main/services/element-detector.ts b/src/main/services/element-detector.ts index 9525629..e50238f 100644 --- a/src/main/services/element-detector.ts +++ b/src/main/services/element-detector.ts @@ -1,22 +1,45 @@ -import type { ScreenCapture, DetectedElement } from '../../shared/types'; +import type { ScreenCapture, DetectedElement, Walkthrough, WalkthroughStep } from '../../shared/types'; + +/** Regex used to find every [TYPE:...] tag in a model response. */ +const TYPE_TAG_REGEX = /\[TYPE:((?:[^\]\\]|\\.)+)\]/g; + +/** Regex that matches both POINT and TYPE tags, used to strip them + * from text we feed to TTS / chat history / display. */ +export const TAG_STRIP_REGEX = /\[(?:POINT|TYPE):[^\]]+\]/g; /** - * Detect UI element locations using Claude's Computer Use API. - * Equivalent to ElementLocationDetector.swift. + * Parse [TYPE:text] tags. Backslashes inside the text escape the next + * character (so the model can include a literal `]` if it must), e.g. + * [TYPE:hello\] world] → "hello] world". + */ +export function parseTypeTags(responseText: string): string[] { + TYPE_TAG_REGEX.lastIndex = 0; + const out: string[] = []; + let m: RegExpExecArray | null; + while ((m = TYPE_TAG_REGEX.exec(responseText)) !== null) { + out.push(m[1].replace(/\\(.)/g, '$1')); + } + return out; +} + +/** + * Parse [POINT:x,y:label:screenN] tags from an LLM response. + * + * - parsePointTags: returns the first match (legacy single-point flow). + * - parseAllPointTags: returns every match in the order it appeared, so + * multi-step walkthroughs can be reconstructed from the response. * - * Parses [POINT:x,y:label:screenN] tags from Claude's response text - * and maps pixel coordinates back to display coordinates. + * Pixel coordinates inside the screenshot are mapped back to the + * corresponding display's logical coordinate space using each capture's + * recorded bounds, so points line up regardless of display scaling. */ -const POINT_TAG_REGEX = /\[POINT:(\d+),(\d+):([^:]+):screen(\d+)\]/; +const POINT_TAG_REGEX = /\[POINT:(\d+),(\d+):([^:]+):screen(\d+)\]/g; -export function parsePointTags( - responseText: string, +function pointToElement( + match: RegExpExecArray, screenshots: ScreenCapture[], ): DetectedElement | null { - const match = POINT_TAG_REGEX.exec(responseText); - if (!match) return null; - const pixelX = parseInt(match[1], 10); const pixelY = parseInt(match[2], 10); const label = match[3]; @@ -25,17 +48,45 @@ export function parsePointTags( const screenshot = screenshots[screenIndex]; if (!screenshot) return null; - // Convert from screenshot pixel space to display coordinate space const scaleX = screenshot.displayBounds.width / screenshot.imageWidth; const scaleY = screenshot.displayBounds.height / screenshot.imageHeight; - const displayX = screenshot.displayBounds.x + pixelX * scaleX; - const displayY = screenshot.displayBounds.y + pixelY * scaleY; - return { - x: displayX, - y: displayY, + x: screenshot.displayBounds.x + pixelX * scaleX, + y: screenshot.displayBounds.y + pixelY * scaleY, label, screenIndex, }; } + +export function parsePointTags( + responseText: string, + screenshots: ScreenCapture[], +): DetectedElement | null { + POINT_TAG_REGEX.lastIndex = 0; + const match = POINT_TAG_REGEX.exec(responseText); + if (!match) return null; + return pointToElement(match, screenshots); +} + +export function parseAllPointTags( + responseText: string, + screenshots: ScreenCapture[], +): Walkthrough | null { + POINT_TAG_REGEX.lastIndex = 0; + const elements: DetectedElement[] = []; + let match: RegExpExecArray | null; + while ((match = POINT_TAG_REGEX.exec(responseText)) !== null) { + const el = pointToElement(match, screenshots); + if (el) elements.push(el); + } + if (elements.length === 0) return null; + + const total = elements.length; + const steps: WalkthroughStep[] = elements.map((el, i) => ({ + ...el, + step: i + 1, + total, + })); + return { steps }; +} diff --git a/src/main/services/prompts.ts b/src/main/services/prompts.ts index 840ba22..e31a68d 100644 --- a/src/main/services/prompts.ts +++ b/src/main/services/prompts.ts @@ -13,11 +13,32 @@ you can see the user's screen — reference specific things you see. if the user POINTING AT ELEMENTS: when you want to show the user something on screen, use the tag: [POINT:x,y:label:screenN] - x,y are pixel coordinates within the screenshot image (origin is top-left corner, x goes right, y goes down) -- label is a short description of the element you're pointing at +- label is a short description of the element you're pointing at — keep it under 6 words; this is shown verbatim as a caption next to the cursor - screenN is which screenshot (screen0 = first image shown, which is the screen the cursor is on) -- be precise: aim for the center of the UI element, button, or text you want to highlight +- be precise: aim for the visual *center* of the UI element (button, icon, link, input). do not pick the corner, the label next to it, or whitespace beside it. if the element is small, take an extra moment to estimate the center accurately — the user is going to click exactly where you point - always point when showing the user where something is or telling them to click/interact with something +WALKTHROUGHS (multi-step instructions): +- if the answer is a sequence of actions ("how do I X?", "guide me through Y"), emit one [POINT:...] tag per step, in the exact order the user should perform them +- each label is the user-facing instruction for that step (e.g. "click File", "choose Export", "hit Save") +- keep labels under 6 words and action-oriented (start with a verb) +- do not number the steps in the label — the UI numbers them automatically based on tag order +- 2–6 steps is the sweet spot; for longer flows, summarize into the most important hops +- only include points the user must actually look at; don't pad with filler steps +- example for "how do I export this as PDF?": your spoken text is a normal short sentence, and you append the step tags at the end: + "sure, just walk through these. [POINT:412,38:click File:screen0] [POINT:430,112:choose Export:screen0] [POINT:520,260:pick PDF:screen0]" +- if the answer is a single location ("where's X?"), still use one [POINT:...] tag — the UI handles 1-step the same way + +TYPING FOR THE USER: +when the user asks you to type, fill in, draft, paste, or write something into a field on screen, use the tag: [TYPE:exact text to type] +- emit ONE [TYPE:...] tag per text the user wants typed; only use this when the user explicitly asks for text to be entered +- the text inside the tag is exactly what gets copied to the user's clipboard for them to paste +- include only the literal text — no quotes around it, no "type this:" preamble +- if you also want to point at the field, emit a [POINT:...] tag for the field, then a [TYPE:...] tag with the content. order matters; users will see the cursor land on the field and then a paste prompt +- example for "draft a quick reply that I'm running late": + "here's a quick one — paste it in. [POINT:520,640:reply field:screen0] [TYPE:Hey, running about 10 minutes late, see you soon!]" +- never use [TYPE:...] for something the user did not ask you to type. don't volunteer text for fields they didn't mention + never use markdown formatting. speak naturally like a friend.`; /** Appended only for providers that actually have web search wired. */ diff --git a/src/main/services/screen-capture.ts b/src/main/services/screen-capture.ts index 756a850..444f823 100644 --- a/src/main/services/screen-capture.ts +++ b/src/main/services/screen-capture.ts @@ -1,16 +1,37 @@ import { desktopCapturer, screen } from 'electron'; import type { ScreenCapture } from '../../shared/types'; -const MAX_DIMENSION = 1280; -const JPEG_QUALITY = 80; +// Bumped from 1280 → 1600. Higher res = more pixel precision when the +// model reports POINT coordinates, at the cost of ~50% more image tokens +// per turn. With cursor-only capture this stays well under the per-image +// limits for both Anthropic and OpenAI vision models. +const MAX_DIMENSION = 1600; +const JPEG_QUALITY = 82; /** - * Capture all displays, returning JPEG screenshots with metadata. - * Equivalent to CompanionScreenCaptureUtility.swift. + * Capture displays as JPEG screenshots with metadata. + * + * @param opts.cursorOnly - if true, return only the display the user's + * cursor is currently on. Saves tokens and avoids confusing the model + * with screens the user isn't actively looking at. Default true. */ -export async function captureAllDisplays(): Promise { - const displays = screen.getAllDisplays(); +export async function captureDisplays( + opts: { cursorOnly?: boolean } = {}, +): Promise { + const { cursorOnly = true } = opts; + const allDisplays = screen.getAllDisplays(); const cursorPoint = screen.getCursorScreenPoint(); + const displays = cursorOnly + ? allDisplays.filter((d) => { + const b = d.bounds; + return ( + cursorPoint.x >= b.x && + cursorPoint.x < b.x + b.width && + cursorPoint.y >= b.y && + cursorPoint.y < b.y + b.height + ); + }) + : allDisplays; // Get all screen sources const sources = await desktopCapturer.getSources({ @@ -30,7 +51,13 @@ export async function captureAllDisplays(): Promise { if (!source) continue; const thumbnail = source.thumbnail; + // When Screen Recording permission is missing on macOS, desktopCapturer + // silently returns sources whose thumbnail is empty (size 0×0) rather + // than throwing. Skip those so we don't ship an empty base64 image to + // the LLM and trigger a 400. + if (thumbnail.isEmpty()) continue; const size = thumbnail.getSize(); + if (size.width === 0 || size.height === 0) continue; // On Windows/Linux with display scaling, the thumbnail is in physical // pixels but display.bounds is in logical pixels. We need to produce an @@ -76,3 +103,10 @@ export async function captureAllDisplays(): Promise { return captures; } + +/** + * Backwards-compatible alias for code that still imports the old name. + * New callers should use captureDisplays() with explicit options. + */ +export const captureAllDisplays = (): Promise => + captureDisplays({ cursorOnly: true }); diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index 8acbf97..6044b55 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -13,6 +13,7 @@ import type { StreamVisibility, StreamWindowBounds, LocalConnection, + PttMode, } from '../../shared/types'; /** @@ -38,6 +39,8 @@ export interface StoredSettings { isClickyCursorEnabled: boolean; launchAtLogin: boolean; pushToTalkShortcut: string; + pttMode: PttMode; + autoTypeEnabled: boolean; streamVisibility: StreamVisibility; streamWindowBounds: StreamWindowBounds | null; @@ -64,6 +67,10 @@ const DEFAULTS: StoredSettings = { isClickyCursorEnabled: true, launchAtLogin: false, pushToTalkShortcut: 'Ctrl+Alt+X', + // Default to 'toggle' on macOS because Electron's globalShortcut on + // darwin can't detect key-up; 'hold' would record forever there. + pttMode: process.platform === 'darwin' ? 'toggle' : 'hold', + autoTypeEnabled: false, streamVisibility: 'off', streamWindowBounds: null, diff --git a/src/main/services/transcription.ts b/src/main/services/transcription.ts index 90c1652..3d5ae8f 100644 --- a/src/main/services/transcription.ts +++ b/src/main/services/transcription.ts @@ -44,6 +44,25 @@ export class GroqWhisperProvider implements TranscriptionProvider { const arrayBuf = wavBuffer.buffer.slice(wavBuffer.byteOffset, wavBuffer.byteOffset + wavBuffer.byteLength) as ArrayBuffer; formData.append('file', new Blob([arrayBuf], { type: 'audio/wav' }), 'recording.wav'); formData.append('model', model); + // English-only locks Whisper out of language detection (which is the + // single biggest source of garbled output on short voice clips). If + // we ever want multilingual, lift this from the user's locale. + formData.append('language', 'en'); + // Temperature 0 = deterministic decoding. Higher temps invent words + // when the audio is unclear; for short commands we want fewer halluc- + // inations, even if it means cutting an unintelligible word. + formData.append('temperature', '0'); + // The "prompt" biases the model's vocab. Loading it with the kind + // of words a user actually says to a screen-aware assistant fixes a + // lot of the weirdness — proper-noun apps ("Slack", "Notion"), + // pointing verbs ("click", "highlight"), and generic UI nouns get + // far higher prior probability and stop being mis-transcribed as + // homophones (e.g. "click" → "clique"). Whisper accepts up to 224 + // tokens here; keep it short and dense. + formData.append( + 'prompt', + "Flicky, click, tap, open, close, switch, highlight, select, search, paste, file, folder, window, tab, button, link, screen, cursor, Slack, Chrome, Notion, VS Code, Figma, Gmail.", + ); const res = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', { method: 'POST', diff --git a/src/preload/index.ts b/src/preload/index.ts index 94fb8a5..8dcbe10 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,9 +9,11 @@ import type { FlickySettings, VoiceState, TranscriptionResult, - DetectedElement, + Walkthrough, + TypeRequest, ReasoningDepth, ReplyTone, + PttMode, MemoryStats, ChatEntry, StreamVisibility, @@ -47,6 +49,8 @@ const api = { ipcRenderer.send(IPC.SET_STREAM_WINDOW_BOUNDS, b), clearStream: (): void => ipcRenderer.send(IPC.CLEAR_STREAM), setPushToTalkShortcut: (accel: string): void => ipcRenderer.send(IPC.SET_PUSH_TO_TALK_SHORTCUT, accel), + setPttMode: (mode: PttMode): void => ipcRenderer.send(IPC.SET_PTT_MODE, mode), + setAutoTypeEnabled: (enabled: boolean): void => ipcRenderer.send(IPC.SET_AUTO_TYPE_ENABLED, enabled), suspendPushToTalkShortcut: (): void => ipcRenderer.send(IPC.SUSPEND_PUSH_TO_TALK_SHORTCUT), resumePushToTalkShortcut: (): void => ipcRenderer.send(IPC.RESUME_PUSH_TO_TALK_SHORTCUT), @@ -144,10 +148,22 @@ const api = { return () => ipcRenderer.removeListener(IPC.AI_RESPONSE_COMPLETE, handler); }, - onElementDetected: (cb: (element: DetectedElement | null) => void) => { - const handler = (_e: Electron.IpcRendererEvent, el: DetectedElement | null) => cb(el); - ipcRenderer.on(IPC.ELEMENT_DETECTED, handler); - return () => ipcRenderer.removeListener(IPC.ELEMENT_DETECTED, handler); + onWalkthrough: (cb: (walkthrough: Walkthrough | null) => void) => { + const handler = (_e: Electron.IpcRendererEvent, w: Walkthrough | null) => cb(w); + ipcRenderer.on(IPC.WALKTHROUGH, handler); + return () => ipcRenderer.removeListener(IPC.WALKTHROUGH, handler); + }, + + onWalkthroughStep: (cb: (index: number | null) => void) => { + const handler = (_e: Electron.IpcRendererEvent, i: number | null) => cb(i); + ipcRenderer.on(IPC.WALKTHROUGH_STEP, handler); + return () => ipcRenderer.removeListener(IPC.WALKTHROUGH_STEP, handler); + }, + + onTypeFulfilled: (cb: (req: TypeRequest) => void) => { + const handler = (_e: Electron.IpcRendererEvent, req: TypeRequest) => cb(req); + ipcRenderer.on(IPC.TYPE_FULFILLED, handler); + return () => ipcRenderer.removeListener(IPC.TYPE_FULFILLED, handler); }, onCursorPosition: (cb: (pos: { x: number; y: number }) => void) => { diff --git a/src/renderer/components/OverlayApp.tsx b/src/renderer/components/OverlayApp.tsx index 73fe5da..913a9eb 100644 --- a/src/renderer/components/OverlayApp.tsx +++ b/src/renderer/components/OverlayApp.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import type { VoiceState, DetectedElement } from '../../shared/types'; +import type { VoiceState, WalkthroughStep, TypeRequest } from '../../shared/types'; import { Waveform } from './Waveform'; // Offset the companion cursor ~1/5 inch (≈19px at 96dpi) down-right @@ -27,13 +27,16 @@ type CursorMode = 'following' | 'navigating' | 'holding' | 'returning'; export function OverlayApp() { const [voiceState, setVoiceState] = useState('idle'); const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 }); - const [detectedElement, setDetectedElement] = useState(null); + const [currentStep, setCurrentStep] = useState(null); const [pointingPhrase, setPointingPhrase] = useState(''); const [cursorMode, setCursorMode] = useState('following'); const [companionPos, setCompanionPos] = useState({ x: 0, y: 0 }); const [isCursorOnThisDisplay, setIsCursorOnThisDisplay] = useState(false); + const [typeToast, setTypeToast] = useState(null); + const typeToastTimerRef = useRef | null>(null); const displayRef = useRef<{ id: number; bounds: { x: number; y: number; width: number; height: number } } | null>(null); const holdTimerRef = useRef | null>(null); + const stepsRef = useRef([]); const returnAnimRef = useRef(null); const cursorPosRef = useRef({ x: 0, y: 0 }); const companionPosRef = useRef({ x: 0, y: 0 }); @@ -213,36 +216,53 @@ export function OverlayApp() { setCursorPos(pos); } }), - window.flicky.onElementDetected((el) => { - if (el) { - if (returnAnimRef.current) { - cancelAnimationFrame(returnAnimRef.current); - returnAnimRef.current = null; - } - if (holdTimerRef.current) { - clearTimeout(holdTimerRef.current); - holdTimerRef.current = null; - } - - setPointingPhrase(randomPhrase()); - setDetectedElement(el); - - const bounds = displayRef.current?.bounds; - const localTarget = { - x: el.x - (bounds?.x ?? 0), - y: el.y - (bounds?.y ?? 0), - }; - setCompanionPosSync(localTarget); - setCursorModeSync('navigating'); - - setTimeout(() => setCursorModeSync('holding'), 650); - } else { - setDetectedElement(null); + window.flicky.onWalkthrough((w) => { + // Cache the steps. Main drives advancement via WALKTHROUGH_STEP. + if (returnAnimRef.current) { + cancelAnimationFrame(returnAnimRef.current); + returnAnimRef.current = null; + } + if (holdTimerRef.current) { + clearTimeout(holdTimerRef.current); + holdTimerRef.current = null; + } + if (!w) { + stepsRef.current = []; + setCurrentStep(null); holdTimerRef.current = setTimeout(() => { holdTimerRef.current = null; startReturnAnimation(); - }, 3000); + }, 1500); + return; + } + stepsRef.current = w.steps; + }), + window.flicky.onTypeFulfilled((req) => { + if (typeToastTimerRef.current) clearTimeout(typeToastTimerRef.current); + setTypeToast(req); + typeToastTimerRef.current = setTimeout(() => { + setTypeToast(null); + typeToastTimerRef.current = null; + }, 5000); + }), + window.flicky.onWalkthroughStep((i) => { + if (i === null) { + // Walkthrough ending — the WALKTHROUGH(null) event will + // schedule the return animation. Just clear current step UI. + setCurrentStep(null); + return; } + const step = stepsRef.current[i]; + if (!step) return; + setPointingPhrase(randomPhrase()); + setCurrentStep(step); + const bounds = displayRef.current?.bounds; + setCompanionPosSync({ + x: step.x - (bounds?.x ?? 0), + y: step.y - (bounds?.y ?? 0), + }); + setCursorModeSync('navigating'); + setTimeout(() => setCursorModeSync('holding'), 650); }), ]; @@ -250,6 +270,7 @@ export function OverlayApp() { unsubDisplayInfo(); unsubs.forEach((u) => u()); if (holdTimerRef.current) clearTimeout(holdTimerRef.current); + if (typeToastTimerRef.current) clearTimeout(typeToastTimerRef.current); if (returnAnimRef.current) cancelAnimationFrame(returnAnimRef.current); }; }, [setCursorModeSync, setCompanionPosSync, startReturnAnimation]); @@ -258,7 +279,8 @@ export function OverlayApp() { if (voiceState === 'listening') { // User started a new turn — interrupt anything Flicky was saying. stopCurrentTts(); - setDetectedElement(null); + setCurrentStep(null); + stepsRef.current = []; if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; @@ -281,12 +303,20 @@ export function OverlayApp() { ? 'left 0.05s linear, top 0.05s linear' : 'none'; - void detectedElement; + const showAnnotation = (isNavigating || isHolding) && currentStep !== null; + const isMultiStep = (currentStep?.total ?? 0) > 1; return (
{showOnThisDisplay && ( <> + {showAnnotation && ( +
+ )} +
)} - {(isNavigating || isHolding) && pointingPhrase && ( + {showAnnotation && (
- {pointingPhrase} + {isMultiStep && ( + + {currentStep!.step} + /{currentStep!.total} + + )} + + {isMultiStep ? currentStep!.label : pointingPhrase} +
)} )} + + {typeToast && isCursorOnThisDisplay && ( +
+
+ 📋 +
+
+ Copied — press{' '} + {process.platform === 'darwin' ? '⌘V' : 'Ctrl+V'} to paste +
+
“{typeToast.preview}”
+
+
+
+ )}
); } diff --git a/src/renderer/components/StreamApp.tsx b/src/renderer/components/StreamApp.tsx index 26667d5..ce36231 100644 --- a/src/renderer/components/StreamApp.tsx +++ b/src/renderer/components/StreamApp.tsx @@ -1,5 +1,10 @@ import { useEffect, useRef, useState } from 'react'; -import type { ChatEntry, TranscriptionResult, VoiceState } from '../../shared/types'; +import type { + ChatEntry, + TranscriptionResult, + VoiceState, + Walkthrough, +} from '../../shared/types'; interface Turn { id: string; @@ -23,6 +28,8 @@ function makeId(): string { export function StreamApp() { const [turns, setTurns] = useState([]); const [voiceState, setVoiceState] = useState('idle'); + const [walkthrough, setWalkthrough] = useState(null); + const [activeStep, setActiveStep] = useState(null); const bodyRef = useRef(null); /** Tracks the in-progress turn so chunks can append to it. */ const currentIdRef = useRef(null); @@ -90,12 +97,23 @@ export function StreamApp() { currentIdRef.current = null; }); + const unsubWalkthrough = window.flicky.onWalkthrough((w) => { + setWalkthrough(w); + if (!w) setActiveStep(null); + }); + + const unsubWalkthroughStep = window.flicky.onWalkthroughStep((i) => { + setActiveStep(i); + }); + return () => { unsubState(); unsubTranscript(); unsubChunk(); unsubComplete(); unsubClear(); + unsubWalkthrough(); + unsubWalkthroughStep(); }; }, []); @@ -128,11 +146,43 @@ export function StreamApp() {
- {turns.length === 0 ? ( + {walkthrough && walkthrough.steps.length > 0 && ( +
+
+ Walkthrough + + {activeStep === null + ? `${walkthrough.steps.length} step${walkthrough.steps.length === 1 ? '' : 's'}` + : `step ${activeStep + 1} of ${walkthrough.steps.length}`} + +
+
    + {walkthrough.steps.map((s, i) => { + const state = + activeStep === null + ? 'pending' + : i < activeStep + ? 'done' + : i === activeStep + ? 'active' + : 'pending'; + return ( +
  1. + + {state === 'done' ? '✓' : i + 1} + + {s.label} +
  2. + ); + })} +
+
+ )} + {turns.length === 0 && !walkthrough ? (
Hold the push-to-talk shortcut and start talking. The live Q/A will appear here.
- ) : ( + ) : turns.length === 0 ? null : ( turns.map((t) => (
You
diff --git a/src/renderer/components/panel/GeneralTab.tsx b/src/renderer/components/panel/GeneralTab.tsx index 73dd425..73b3bb2 100644 --- a/src/renderer/components/panel/GeneralTab.tsx +++ b/src/renderer/components/panel/GeneralTab.tsx @@ -60,6 +60,7 @@ export function GeneralTab({ settings, memory }: GeneralTabProps) { pct < 60 ? 'var(--fl-ok)' : pct < 85 ? 'var(--fl-warn)' : 'var(--fl-danger)'; const shortcutKeys = settings.pushToTalkShortcut.split('+').filter(Boolean); + const isMac = process.platform === 'darwin'; return ( <> @@ -73,7 +74,11 @@ export function GeneralTab({ settings, memory }: GeneralTabProps) {
Push to talk
-
hold to speak from anywhere on your machine
+
+ {settings.pttMode === 'toggle' + ? 'tap once to start, tap again to stop' + : 'hold to speak, release to send'} +
{editingShortcut ? ( )}
+
+
+
Trigger style
+
+ {isMac + ? 'macOS only supports tap-toggle — Electron can’t see the key release for hold-to-talk.' + : 'pick how the shortcut behaves'} +
+
+
+ + +
+
@@ -154,6 +189,21 @@ export function GeneralTab({ settings, memory }: GeneralTabProps) { aria-label="Toggle cursor" />
+
+
+
Allow Flicky to type for you
+
+ when off (default), Flicky copies text to your clipboard and you press paste. + when on, Flicky will type directly into the focused field + {' '}(native typer coming soon — currently still uses clipboard). +
+
+
Launch at login
diff --git a/src/renderer/styles/overlay.css b/src/renderer/styles/overlay.css index e55e06e..610b36a 100644 --- a/src/renderer/styles/overlay.css +++ b/src/renderer/styles/overlay.css @@ -59,6 +59,9 @@ .pointing-bubble { position: absolute; + display: inline-flex; + align-items: center; + gap: 8px; padding: var(--space-sm) var(--space-md); background: var(--accent); color: white; @@ -68,4 +71,136 @@ white-space: nowrap; animation: bubble-in 0.2s ease-out; pointer-events: none; + box-shadow: 0 4px 14px rgba(29, 78, 216, 0.35); +} + +.pointing-bubble .bubble-text { line-height: 1.2; } + +.pointing-bubble .step-badge { + display: inline-flex; + align-items: baseline; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.2); + color: #fff; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.2px; +} + +.pointing-bubble .step-badge-total { + font-size: 10px; + font-weight: 500; + opacity: 0.75; + margin-left: 1px; +} + +@keyframes bubble-in { + from { opacity: 0; transform: translateY(-2px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +/* ── Target Halo ───────────────────────────────────────────────────── */ +/* Pulsing ring drawn at the target so the user's eye lands on it + even when the cursor itself is small. Sits underneath the cursor. */ + +.target-halo { + position: absolute; + width: 56px; + height: 56px; + margin-left: -28px; + margin-top: -28px; + border-radius: 50%; + border: 2.5px solid rgba(125, 211, 252, 0.85); + background: radial-gradient(closest-side, rgba(59, 130, 246, 0.18), rgba(59, 130, 246, 0)); + box-shadow: + 0 0 0 0 rgba(59, 130, 246, 0.55), + 0 0 22px rgba(59, 130, 246, 0.45); + animation: halo-pulse 1.4s ease-out infinite; + pointer-events: none; +} + +@keyframes halo-pulse { + 0% { transform: scale(0.7); opacity: 0.95; } + 70% { transform: scale(1.25); opacity: 0; box-shadow: 0 0 0 18px rgba(59, 130, 246, 0); } + 100% { transform: scale(1.25); opacity: 0; } +} + +/* ── Type-fulfilled Toast ─────────────────────────────────────────── */ +/* Floats near the bottom-center of the display when Flicky has copied + text to the clipboard for the user to paste. Auto-hides after a + few seconds. */ + +.type-toast { + position: fixed; + left: 50%; + bottom: 56px; + transform: translateX(-50%); + max-width: 480px; + padding: 12px 16px; + background: rgba(20, 22, 28, 0.92); + backdrop-filter: blur(18px) saturate(140%); + -webkit-backdrop-filter: blur(18px) saturate(140%); + border: 1px solid rgba(125, 211, 252, 0.45); + border-radius: 12px; + box-shadow: + 0 12px 32px rgba(0, 0, 0, 0.45), + 0 0 0 1px rgba(59, 130, 246, 0.15) inset; + color: #e7e7ea; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + font-size: 13px; + pointer-events: none; + animation: type-toast-in 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.type-toast-row { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.type-toast-icon { + font-size: 18px; + line-height: 1.2; + margin-top: 1px; +} + +.type-toast-text { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.type-toast-title { + font-weight: 600; + color: #fff; + font-size: 13px; +} + +.type-toast-title kbd { + font-family: 'Geist Mono', ui-monospace, monospace; + font-size: 11px; + padding: 1px 6px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; + margin: 0 1px; +} + +.type-toast-preview { + color: rgba(231, 231, 234, 0.7); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 440px; +} + +@keyframes type-toast-in { + from { opacity: 0; transform: translate(-50%, 8px) scale(0.96); } + to { opacity: 1; transform: translate(-50%, 0) scale(1); } } diff --git a/src/renderer/styles/panel.css b/src/renderer/styles/panel.css index 567e073..59bbd2b 100644 --- a/src/renderer/styles/panel.css +++ b/src/renderer/styles/panel.css @@ -511,6 +511,39 @@ button.provider-pick:hover { color: var(--fl-ink); } +/* ── PTT mode segmented control ─────────────────────────────────── */ +.ptt-mode-seg { + display: inline-flex; + padding: 3px; + background: var(--fl-surface-2); + border: 1px solid var(--fl-border); + border-radius: 10px; + gap: 2px; +} +.ptt-mode-seg .seg { + font-size: 12px; font-weight: 600; + color: var(--fl-muted); + background: transparent; + border: none; + padding: 6px 14px; + border-radius: 7px; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} +.ptt-mode-seg .seg:hover:not(:disabled):not(.on) { + color: var(--fl-ink-soft); + background: rgba(255,255,255,0.04); +} +.ptt-mode-seg .seg.on { + color: var(--fl-ink); + background: var(--fl-surface); + box-shadow: 0 0 0 1px var(--fl-border-strong); +} +.ptt-mode-seg .seg:disabled { + color: var(--fl-dim); + cursor: not-allowed; +} + /* ── Context meter ──────────────────────────────────────────────── */ .context-bar { padding: 14px; diff --git a/src/renderer/styles/stream.css b/src/renderer/styles/stream.css index e562d88..2b84c4d 100644 --- a/src/renderer/styles/stream.css +++ b/src/renderer/styles/stream.css @@ -121,3 +121,86 @@ html, body, #root { @keyframes stream-blink { to { visibility: hidden; } } + +/* ── Walkthrough card ───────────────────────────────────────────── */ +.walkthrough-card { + margin: 4px 0 14px; + padding: 12px 14px 10px; + background: rgba(37, 99, 235, 0.10); + border: 1px solid rgba(125, 211, 252, 0.35); + border-radius: 12px; + box-shadow: 0 8px 22px rgba(29, 78, 216, 0.18); +} +.walkthrough-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 8px; +} +.walkthrough-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.7px; + text-transform: uppercase; + color: #cfe5ff; +} +.walkthrough-progress { + font-size: 11px; + color: rgba(207, 229, 255, 0.65); +} +.walkthrough-steps { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} +.walkthrough-step { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 9px; + transition: background 0.18s ease, opacity 0.18s ease, transform 0.18s ease; +} +.walkthrough-step.pending { opacity: 0.55; } +.walkthrough-step.done { + opacity: 0.7; + background: rgba(255, 255, 255, 0.04); +} +.walkthrough-step.active { + background: rgba(59, 130, 246, 0.22); + box-shadow: inset 0 0 0 1px rgba(125, 211, 252, 0.55); + transform: translateX(2px); +} +.walkthrough-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + font-size: 11px; + font-weight: 700; + background: rgba(255, 255, 255, 0.08); + color: #fff; + flex-shrink: 0; +} +.walkthrough-step.done .walkthrough-num { + background: rgba(142, 189, 154, 0.35); + color: #d6f0dd; +} +.walkthrough-step.active .walkthrough-num { + background: #2563eb; + box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.3); +} +.walkthrough-label { + font-size: 13px; + color: #e7e7ea; + line-height: 1.35; +} +.walkthrough-step.done .walkthrough-label { + text-decoration: line-through; + text-decoration-color: rgba(255, 255, 255, 0.25); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index b20a7b7..1a4d9c1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -46,6 +46,9 @@ export type ReasoningDepth = 'off' | 'medium' | 'deep'; /** System-prompt variant. */ export type ReplyTone = 'concise' | 'friendly' | 'detailed'; +/** How the push-to-talk shortcut behaves. */ +export type PttMode = 'hold' | 'toggle'; + export interface ConversationTurn { role: 'user' | 'assistant'; content: string; @@ -60,6 +63,31 @@ export interface DetectedElement { screenIndex: number; } +export interface WalkthroughStep extends DetectedElement { + /** 1-based position in the walkthrough sequence. */ + step: number; + /** Total number of steps in the walkthrough. */ + total: number; +} + +export interface Walkthrough { + steps: WalkthroughStep[]; +} + +/** + * A request from the model to type text into whatever field the user + * has focused. Currently fulfilled via clipboard handoff (text copied + * to clipboard, user presses ⌘V); a future "auto-type" mode will use + * a native key-event hook to type directly when the user has opted in. + */ +export interface TypeRequest { + text: string; + /** What was typed/copied — surfaced in the toast UI for confirmation. */ + preview: string; + /** True when the text was actually auto-typed; false when copied. */ + autoTyped: boolean; +} + // ── Local Connections (Ollama / OpenAI-compatible local endpoints) ───── export interface OllamaModelInfo { @@ -178,6 +206,21 @@ export interface FlickySettings { isClickyCursorEnabled: boolean; launchAtLogin: boolean; pushToTalkShortcut: string; + /** + * How the push-to-talk shortcut behaves: + * 'hold' — record while the key is held, send on release + * (Windows/Linux only; macOS falls back to 'toggle' because + * Electron's globalShortcut exposes no key-up event there) + * 'toggle' — first tap starts recording, second tap stops and sends + */ + pttMode: PttMode; + /** + * If true, Flicky may type text directly into the focused field when + * the model emits a [TYPE:...] tag. Requires Accessibility permission + * on macOS and the native auto-typer module to be available; falls + * back to clipboard handoff in either case. Off by default. + */ + autoTypeEnabled: boolean; /** * Controls the transparent stream window: * - 'off' — never shown @@ -214,6 +257,8 @@ export const DEFAULT_SETTINGS: FlickySettings = { isClickyCursorEnabled: true, launchAtLogin: false, pushToTalkShortcut: 'Ctrl+Alt+X', + pttMode: 'hold', + autoTypeEnabled: false, streamVisibility: 'off', streamWindowBounds: null, @@ -232,6 +277,9 @@ export const IPC = { AI_RESPONSE_CHUNK: 'ai-response-chunk', AI_RESPONSE_COMPLETE: 'ai-response-complete', ELEMENT_DETECTED: 'element-detected', + WALKTHROUGH: 'walkthrough', + WALKTHROUGH_STEP: 'walkthrough-step', + TYPE_FULFILLED: 'type-fulfilled', CURSOR_POSITION: 'cursor-position', SETTINGS_CHANGED: 'settings-changed', PERMISSION_STATUS: 'permission-status', @@ -254,6 +302,8 @@ export const IPC = { TOGGLE_CURSOR: 'toggle-cursor', SET_LAUNCH_AT_LOGIN: 'set-launch-at-login', SET_PUSH_TO_TALK_SHORTCUT: 'set-push-to-talk-shortcut', + SET_PTT_MODE: 'set-ptt-mode', + SET_AUTO_TYPE_ENABLED: 'set-auto-type-enabled', SET_STREAM_VISIBILITY: 'set-stream-visibility', SET_STREAM_WINDOW_BOUNDS: 'set-stream-window-bounds', CLEAR_STREAM: 'clear-stream', From e1a913fbaac7222cf4524b0e96d8f345c90f64f8 Mon Sep 17 00:00:00 2001 From: jvaught01 Date: Thu, 30 Apr 2026 13:21:42 -0700 Subject: [PATCH 02/12] feat(typing): wire up native auto-typer behind opt-in toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the "Allow Flicky to type for you" setting is on, [TYPE:...] tags are now sent through @nut-tree-fork/nut-js, which fires real global keystrokes via libnut. The text shows up in whatever field the OS considers focused — no paste step. Graceful fallback: if the native module fails to load, the Accessibility permission isn't granted, or the type call throws, we silently fall back to clipboard handoff. The user is never left with no way to act on the request. Permissions: - Accessibility status is now part of getPermissions() on darwin. - Banner gets a third row ("Accessibility") that only shows when autoTypeEnabled is on AND the perm is missing — keeps the banner quiet for users who don't use the feature. - Flipping autoTypeEnabled on triggers the macOS Accessibility prompt right then; Grant button on the banner deeplinks to the Privacy_Accessibility pane and re-prompts in case the dialog was dismissed. UX: - Toast distinguishes auto-typed (⌨️ "Typed for you") from clipboard (📋 "Copied — press ⌘V to paste"). - General-tab copy updated to reflect that the feature is real, not "coming soon". Co-Authored-By: Claude Opus 4.7 (1M context) --- bun.lock | 239 +++++++++++++++++- package.json | 16 +- src/main/companion-manager.ts | 50 +++- src/main/services/auto-typer.ts | 73 ++++++ src/renderer/components/OverlayApp.tsx | 14 +- src/renderer/components/panel/GeneralTab.tsx | 4 +- .../components/panel/PermissionsBanner.tsx | 38 ++- 7 files changed, 408 insertions(+), 26 deletions(-) create mode 100644 src/main/services/auto-typer.ts diff --git a/bun.lock b/bun.lock index 0492ab0..247ae08 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "flicky", "dependencies": { + "@nut-tree-fork/nut-js": "^4.2.6", "posthog-node": "^4.0.0", "ws": "^8.18.0", }, @@ -165,6 +166,68 @@ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@jimp/bmp": ["@jimp/bmp@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "bmp-js": "^0.1.0" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-aeI64HD0npropd+AR76MCcvvRaa+Qck6loCOS03CkkxGHN5/r336qTM5HPUdHKMDOGzqknuVPA8+kK1t03z12g=="], + + "@jimp/core": ["@jimp/core@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "any-base": "^1.1.0", "buffer": "^5.2.0", "exif-parser": "^0.1.12", "file-type": "^16.5.4", "isomorphic-fetch": "^3.0.0", "pixelmatch": "^4.0.2", "tinycolor2": "^1.6.0" } }, "sha512-l0RR0dOPyzMKfjUW1uebzueFEDtCOj9fN6pyTYWWOM/VS4BciXQ1VVrJs8pO3kycGYZxncRKhCoygbNr8eEZQA=="], + + "@jimp/custom": ["@jimp/custom@0.22.12", "", { "dependencies": { "@jimp/core": "^0.22.12" } }, "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q=="], + + "@jimp/gif": ["@jimp/gif@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "gifwrap": "^0.10.1", "omggif": "^1.0.9" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-y6BFTJgch9mbor2H234VSjd9iwAhaNf/t3US5qpYIs0TSbAvM02Fbc28IaDETj9+4YB4676sz4RcN/zwhfu1pg=="], + + "@jimp/jpeg": ["@jimp/jpeg@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "jpeg-js": "^0.4.4" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-Rq26XC/uQWaQKyb/5lksCTCxXhtY01NJeBN+dQv5yNYedN0i7iYu+fXEoRsfaJ8xZzjoANH8sns7rVP4GE7d/Q=="], + + "@jimp/plugin-blit": ["@jimp/plugin-blit@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ=="], + + "@jimp/plugin-blur": ["@jimp/plugin-blur@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw=="], + + "@jimp/plugin-circle": ["@jimp/plugin-circle@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-SWVXx1yiuj5jZtMijqUfvVOJBwOifFn0918ou4ftoHgegc5aHWW5dZbYPjvC9fLpvz7oSlptNl2Sxr1zwofjTg=="], + + "@jimp/plugin-color": ["@jimp/plugin-color@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "tinycolor2": "^1.6.0" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA=="], + + "@jimp/plugin-contain": ["@jimp/plugin-contain@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-blit": ">=0.3.5", "@jimp/plugin-resize": ">=0.3.5", "@jimp/plugin-scale": ">=0.3.5" } }, "sha512-Eo3DmfixJw3N79lWk8q/0SDYbqmKt1xSTJ69yy8XLYQj9svoBbyRpSnHR+n9hOw5pKXytHwUW6nU4u1wegHNoQ=="], + + "@jimp/plugin-cover": ["@jimp/plugin-cover@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-crop": ">=0.3.5", "@jimp/plugin-resize": ">=0.3.5", "@jimp/plugin-scale": ">=0.3.5" } }, "sha512-z0w/1xH/v/knZkpTNx+E8a7fnasQ2wHG5ze6y5oL2dhH1UufNua8gLQXlv8/W56+4nJ1brhSd233HBJCo01BXA=="], + + "@jimp/plugin-crop": ["@jimp/plugin-crop@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw=="], + + "@jimp/plugin-displace": ["@jimp/plugin-displace@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-qpRM8JRicxfK6aPPqKZA6+GzBwUIitiHaZw0QrJ64Ygd3+AsTc7BXr+37k2x7QcyCvmKXY4haUrSIsBug4S3CA=="], + + "@jimp/plugin-dither": ["@jimp/plugin-dither@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-jYgGdSdSKl1UUEanX8A85v4+QUm+PE8vHFwlamaKk89s+PXQe7eVE3eNeSZX4inCq63EHL7cX580dMqkoC3ZLw=="], + + "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-LGuUTsFg+fOp6KBKrmLkX4LfyCy8IIsROwoUvsUPKzutSqMJnsm3JGDW2eOmWIS/jJpPaeaishjlxvczjgII+Q=="], + + "@jimp/plugin-flip": ["@jimp/plugin-flip@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-rotate": ">=0.3.5" } }, "sha512-m251Rop7GN8W0Yo/rF9LWk6kNclngyjIJs/VXHToGQ6EGveOSTSQaX2Isi9f9lCDLxt+inBIb7nlaLLxnvHX8Q=="], + + "@jimp/plugin-gaussian": ["@jimp/plugin-gaussian@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-sBfbzoOmJ6FczfG2PquiK84NtVGeScw97JsCC3rpQv1PHVWyW+uqWFF53+n3c8Y0P2HWlUjflEla2h/vWShvhg=="], + + "@jimp/plugin-invert": ["@jimp/plugin-invert@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-N+6rwxdB+7OCR6PYijaA/iizXXodpxOGvT/smd/lxeXsZ/empHmFFFJ/FaXcYh19Tm04dGDaXcNF/dN5nm6+xQ=="], + + "@jimp/plugin-mask": ["@jimp/plugin-mask@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-4AWZg+DomtpUA099jRV8IEZUfn1wLv6+nem4NRJC7L/82vxzLCgXKTxvNvBcNmJjT9yS1LAAmiJGdWKXG63/NA=="], + + "@jimp/plugin-normalize": ["@jimp/plugin-normalize@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-0So0rexQivnWgnhacX4cfkM2223YdExnJTTy6d06WbkfZk5alHUx8MM3yEzwoCN0ErO7oyqEWRnEkGC+As1FtA=="], + + "@jimp/plugin-print": ["@jimp/plugin-print@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "load-bmfont": "^1.4.1" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-blit": ">=0.3.5" } }, "sha512-c7TnhHlxm87DJeSnwr/XOLjJU/whoiKYY7r21SbuJ5nuH+7a78EW1teOaj5gEr2wYEd7QtkFqGlmyGXY/YclyQ=="], + + "@jimp/plugin-resize": ["@jimp/plugin-resize@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg=="], + + "@jimp/plugin-rotate": ["@jimp/plugin-rotate@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-blit": ">=0.3.5", "@jimp/plugin-crop": ">=0.3.5", "@jimp/plugin-resize": ">=0.3.5" } }, "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA=="], + + "@jimp/plugin-scale": ["@jimp/plugin-scale@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-resize": ">=0.3.5" } }, "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw=="], + + "@jimp/plugin-shadow": ["@jimp/plugin-shadow@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-blur": ">=0.3.5", "@jimp/plugin-resize": ">=0.3.5" } }, "sha512-FX8mTJuCt7/3zXVoeD/qHlm4YH2bVqBuWQHXSuBK054e7wFRnRnbSLPUqAwSeYP3lWqpuQzJtgiiBxV3+WWwTg=="], + + "@jimp/plugin-threshold": ["@jimp/plugin-threshold@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-color": ">=0.8.0", "@jimp/plugin-resize": ">=0.8.0" } }, "sha512-4x5GrQr1a/9L0paBC/MZZJjjgjxLYrqSmWd+e+QfAEPvmRxdRoQ5uKEuNgXnm9/weHQBTnQBQsOY2iFja+XGAw=="], + + "@jimp/plugins": ["@jimp/plugins@0.22.12", "", { "dependencies": { "@jimp/plugin-blit": "^0.22.12", "@jimp/plugin-blur": "^0.22.12", "@jimp/plugin-circle": "^0.22.12", "@jimp/plugin-color": "^0.22.12", "@jimp/plugin-contain": "^0.22.12", "@jimp/plugin-cover": "^0.22.12", "@jimp/plugin-crop": "^0.22.12", "@jimp/plugin-displace": "^0.22.12", "@jimp/plugin-dither": "^0.22.12", "@jimp/plugin-fisheye": "^0.22.12", "@jimp/plugin-flip": "^0.22.12", "@jimp/plugin-gaussian": "^0.22.12", "@jimp/plugin-invert": "^0.22.12", "@jimp/plugin-mask": "^0.22.12", "@jimp/plugin-normalize": "^0.22.12", "@jimp/plugin-print": "^0.22.12", "@jimp/plugin-resize": "^0.22.12", "@jimp/plugin-rotate": "^0.22.12", "@jimp/plugin-scale": "^0.22.12", "@jimp/plugin-shadow": "^0.22.12", "@jimp/plugin-threshold": "^0.22.12", "timm": "^1.6.1" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-yBJ8vQrDkBbTgQZLty9k4+KtUQdRjsIDJSPjuI21YdVeqZxYywifHl4/XWILoTZsjTUASQcGoH0TuC0N7xm3ww=="], + + "@jimp/png": ["@jimp/png@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "pngjs": "^6.0.0" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-Mrp6dr3UTn+aLK8ty/dSKELz+Otdz1v4aAXzV5q53UDD2rbB5joKVJ/ChY310B+eRzNxIovbUF1KVrUsYdE8Hg=="], + + "@jimp/tiff": ["@jimp/tiff@0.22.12", "", { "dependencies": { "utif2": "^4.0.1" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-E1LtMh4RyJsoCAfAkBRVSYyZDTtLq9p9LUiiYP0vPtXyxX4BiYBUYihTLSBlCQg5nF2e4OpQg7SPrLdJ66u7jg=="], + + "@jimp/types": ["@jimp/types@0.22.12", "", { "dependencies": { "@jimp/bmp": "^0.22.12", "@jimp/gif": "^0.22.12", "@jimp/jpeg": "^0.22.12", "@jimp/png": "^0.22.12", "@jimp/tiff": "^0.22.12", "timm": "^1.6.1" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-wwKYzRdElE1MBXFREvCto5s699izFHNVvALUv79GXNbsOVqlwlOxlWJ8DuyOGIXoLP4JW/m30YyuTtfUJgMRMA=="], + + "@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -183,6 +246,24 @@ "@npmcli/move-file": ["@npmcli/move-file@2.0.1", "", { "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ=="], + "@nut-tree-fork/default-clipboard-provider": ["@nut-tree-fork/default-clipboard-provider@4.2.6", "", { "dependencies": { "clipboardy": "2.3.0" } }, "sha512-Hzqj57rheIMGtsS4zK4//kOhaX5FxMluOiz+4TVaHXx+idZS/bPhZwd8e6o1w1GT0PVJOUIP+4CdUe//k5VRig=="], + + "@nut-tree-fork/libnut": ["@nut-tree-fork/libnut@4.2.6", "", { "dependencies": { "@nut-tree-fork/libnut-darwin": "2.7.5", "@nut-tree-fork/libnut-linux": "2.7.5", "@nut-tree-fork/libnut-win32": "2.7.5" } }, "sha512-2FCiTBokMGrMl4eL/trEIO+mtpkXpdPHoVKdTBmW8UBIbhCbrCKmnXb2skWGfVs+U3q7o5EYDjVTNUYaUWbaxQ=="], + + "@nut-tree-fork/libnut-darwin": ["@nut-tree-fork/libnut-darwin@2.7.5", "", { "dependencies": { "bindings": "1.5.0" }, "optionalDependencies": { "@nut-tree-fork/node-mac-permissions": "2.2.1" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-LbqtPtMPTJUcg4XoPP2jsU1wc8flBcGyKTerKsIfK9cD7nBHROnO0QksbrsbSWEpLym8T8fRtuU7XEY83l6Z2Q=="], + + "@nut-tree-fork/libnut-linux": ["@nut-tree-fork/libnut-linux@2.7.5", "", { "dependencies": { "bindings": "1.5.0" }, "optionalDependencies": { "@nut-tree-fork/node-mac-permissions": "2.2.1" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-uxaXEcRKnFObAljsoR6tLOBUU1dJ2sctloG6gFgCBGN7+k6Jdv6jZfOuNjd/fpdq2C5WPMm0rtn9EE7h5J3Jcg=="], + + "@nut-tree-fork/libnut-win32": ["@nut-tree-fork/libnut-win32@2.7.5", "", { "dependencies": { "bindings": "1.5.0" }, "optionalDependencies": { "@nut-tree-fork/node-mac-permissions": "2.2.1" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-yqC87zvmFcDPwFrRU40DYhN0xmEVM3aSkOuyF0IX+y1x+HWSu/i0PNklATpPBhGid3QVb/TOHuVoaraMrUFCNw=="], + + "@nut-tree-fork/node-mac-permissions": ["@nut-tree-fork/node-mac-permissions@2.2.1", "", { "dependencies": { "bindings": "1.5.0", "node-addon-api": "5.0.0" }, "os": "darwin" }, "sha512-iSfOTDiBZ7VDa17PoQje5rUaZSvSAaq+XEyXCmhPuQwV5XuNU02Grv6oFhsdpz89w7+UvB/8KX/cX5IYQ5o2Bw=="], + + "@nut-tree-fork/nut-js": ["@nut-tree-fork/nut-js@4.2.6", "", { "dependencies": { "@nut-tree-fork/default-clipboard-provider": "4.2.6", "@nut-tree-fork/libnut": "4.2.6", "@nut-tree-fork/provider-interfaces": "4.2.6", "@nut-tree-fork/shared": "4.2.6", "jimp": "0.22.10", "node-abort-controller": "3.1.1" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-aI/WCX7gE1HFGPH3EZP/UWqpNMM1NMoM/EkXqp7pKMgXFCi8e5+o5p+jd/QOYpmALv9bQg7+s69nI7FONbMqDg=="], + + "@nut-tree-fork/provider-interfaces": ["@nut-tree-fork/provider-interfaces@4.2.6", "", { "dependencies": { "@nut-tree-fork/shared": "4.2.6" } }, "sha512-brtRegDkLSV0sa5DUAigjWf6hCoamBNPb/hKK9AQlW+j3BxQ/8djaEdEB2cihqUh1ZjEtgPyXRqpCWSdKCX68A=="], + + "@nut-tree-fork/shared": ["@nut-tree-fork/shared@4.2.6", "", { "dependencies": { "jimp": "0.22.10", "node-abort-controller": "3.1.1" } }, "sha512-xZaa0YtJt/DDDq/i1vZkabjq8HOWzfhXieMai61cMbYD11J6VhAfhV23ZtQEM02WG7nc2LKjl4UwRnQCteikwA=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], @@ -241,6 +322,8 @@ "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -309,6 +392,8 @@ "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -327,12 +412,16 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + "app-builder-bin": ["app-builder-bin@5.0.0-alpha.10", "", {}, "sha512-Ev4jj3D7Bo+O0GPD2NMvJl+PGiBAfS7pUGawntBNpCbxtpncfUixqFj9z9Jme7V7s3LBGqsWZZP54fxBX3JKJw=="], "app-builder-lib": ["app-builder-lib@25.1.8", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.1", "@electron/rebuild": "3.6.1", "@electron/universal": "2.0.1", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "bluebird-lst": "^1.0.9", "builder-util": "25.1.7", "builder-util-runtime": "9.2.10", "chromium-pickle-js": "^0.2.0", "config-file-ts": "0.2.8-rc1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "25.1.7", "form-data": "^4.0.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "is-ci": "^3.0.0", "isbinaryfile": "^5.0.0", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.0", "resedit": "^1.7.0", "sanitize-filename": "^1.6.3", "semver": "^7.3.8", "tar": "^6.1.12", "temp-file": "^3.4.0" }, "peerDependencies": { "dmg-builder": "25.1.8", "electron-builder-squirrel-windows": "25.1.8" } }, "sha512-pCqe7dfsQFBABC1jeKZXQWhGcCPF3rPCXDdfqVKjIeWBcXzyC1iOWZdfFhGl+S9MyE/k//DFmC6FzuGAUudNDg=="], "aproba": ["aproba@2.1.0", "", {}, "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew=="], + "arch": ["arch@2.2.0", "", {}, "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ=="], + "archiver": ["archiver@5.3.2", "", { "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", "buffer-crc32": "^0.2.1", "readable-stream": "^3.6.0", "readdir-glob": "^1.1.2", "tar-stream": "^2.2.0", "zip-stream": "^4.1.0" } }, "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw=="], "archiver-utils": ["archiver-utils@2.1.0", "", { "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^2.0.0" } }, "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw=="], @@ -361,12 +450,16 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA=="], + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], "bluebird-lst": ["bluebird-lst@1.0.9", "", { "dependencies": { "bluebird": "^3.5.5" } }, "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw=="], + "bmp-js": ["bmp-js@0.1.0", "", {}, "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], @@ -377,6 +470,8 @@ "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + "buffer-equal": ["buffer-equal@0.0.1", "", {}, "sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "builder-util": ["builder-util@25.1.7", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.10", "bluebird-lst": "^1.0.9", "builder-util-runtime": "9.2.10", "chalk": "^4.1.2", "cross-spawn": "^7.0.3", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "is-ci": "^3.0.0", "js-yaml": "^4.1.0", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0" } }, "sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww=="], @@ -395,6 +490,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="], + "centra": ["centra@2.7.0", "", { "dependencies": { "follow-redirects": "^1.15.6" } }, "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], @@ -411,6 +508,8 @@ "cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], + "clipboardy": ["clipboardy@2.3.0", "", { "dependencies": { "arch": "^2.1.1", "execa": "^1.0.0", "is-wsl": "^2.1.1" } }, "sha512-mKhiIL2DrQIsuXMgBgnfEHOZOryC7kY7YO//TN6c63wlEm3NG5tz+YgY5rVi29KCmq/QQjKYvM7a19+MDOTHOQ=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], @@ -481,6 +580,8 @@ "dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], + "dom-walk": ["dom-walk@0.1.2", "", {}, "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], @@ -543,6 +644,14 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "execa": ["execa@1.0.0", "", { "dependencies": { "cross-spawn": "^6.0.0", "get-stream": "^4.0.0", "is-stream": "^1.1.0", "npm-run-path": "^2.0.0", "p-finally": "^1.0.0", "signal-exit": "^3.0.0", "strip-eof": "^1.0.0" } }, "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA=="], + + "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], @@ -561,6 +670,10 @@ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -599,10 +712,14 @@ "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "global": ["global@4.4.0", "", { "dependencies": { "min-document": "^2.19.0", "process": "^0.11.10" } }, "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -647,6 +764,8 @@ "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -663,28 +782,42 @@ "is-ci": ["is-ci@3.0.1", "", { "dependencies": { "ci-info": "^3.2.0" }, "bin": { "is-ci": "bin.js" } }, "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ=="], + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-function": ["is-function@1.0.2", "", {}, "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], "is-lambda": ["is-lambda@1.0.1", "", {}, "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ=="], + "is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="], + "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic-fetch": ["isomorphic-fetch@3.0.0", "", { "dependencies": { "node-fetch": "^2.6.1", "whatwg-fetch": "^3.4.1" } }, "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA=="], + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], + "jimp": ["jimp@0.22.10", "", { "dependencies": { "@jimp/custom": "^0.22.10", "@jimp/plugins": "^0.22.10", "@jimp/types": "^0.22.10", "regenerator-runtime": "^0.13.3" } }, "sha512-lCaHIJAgTOsplyJzC1w/laxSxrbSsEBw4byKwXgUdMmh+ayPsnidTblenQm+IvhIs44Gcuvlb6pd2LQ0wcKaKg=="], + + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -711,6 +844,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "load-bmfont": ["load-bmfont@1.4.2", "", { "dependencies": { "buffer-equal": "0.0.1", "mime": "^1.3.4", "parse-bmfont-ascii": "^1.0.3", "parse-bmfont-binary": "^1.0.5", "parse-bmfont-xml": "^1.1.4", "phin": "^3.7.1", "xhr": "^2.0.1", "xtend": "^4.0.0" } }, "sha512-qElWkmjW9Oq1F9EI5Gt7aD9zcdHb9spJCW1L/dmPf7KzCCEJxq8nhHz5eCgI9aMf7vrG/wyaCqdsI+Iy9ZTlog=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], @@ -749,6 +884,8 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "min-document": ["min-document@2.19.2", "", { "dependencies": { "dom-walk": "^0.1.0" } }, "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -777,12 +914,18 @@ "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + "nice-try": ["nice-try@1.0.5", "", {}, "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="], + "node-abi": ["node-abi@3.89.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA=="], + "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="], + "node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-gyp": ["node-gyp@9.4.1", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^7.1.4", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.0.3", "nopt": "^6.0.0", "npmlog": "^6.0.0", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.2", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ=="], "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], @@ -793,10 +936,14 @@ "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], + "npm-run-path": ["npm-run-path@2.0.2", "", { "dependencies": { "path-key": "^2.0.0" } }, "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw=="], + "npmlog": ["npmlog@6.0.2", "", { "dependencies": { "are-we-there-yet": "^3.0.0", "console-control-strings": "^1.1.0", "gauge": "^4.0.3", "set-blocking": "^2.0.0" } }, "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg=="], "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -807,6 +954,8 @@ "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], + "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], @@ -815,8 +964,18 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], + + "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], + + "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], + + "parse-headers": ["parse-headers@2.0.6", "", {}, "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], @@ -827,20 +986,30 @@ "pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + "phin": ["phin@3.7.1", "", { "dependencies": { "centra": "^2.7.0" } }, "sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "pixelmatch": ["pixelmatch@4.0.2", "", { "dependencies": { "pngjs": "^3.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA=="], + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], "posthog-node": ["posthog-node@4.18.0", "", { "dependencies": { "axios": "^1.8.2" } }, "sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], @@ -867,8 +1036,12 @@ "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], + "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], @@ -915,7 +1088,7 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], @@ -949,8 +1122,12 @@ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-eof": ["strip-eof@1.0.0", "", {}, "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], @@ -961,12 +1138,20 @@ "temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], + "timm": ["timm@1.7.1", "", {}, "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw=="], + + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], @@ -995,6 +1180,8 @@ "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], @@ -1005,6 +1192,12 @@ "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="], @@ -1019,8 +1212,16 @@ "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "xhr": ["xhr@2.6.0", "", { "dependencies": { "global": "~4.4.0", "is-function": "^1.0.1", "parse-headers": "^2.0.0", "xtend": "^4.0.0" } }, "sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA=="], + + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], + + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], @@ -1065,6 +1266,8 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@nut-tree-fork/node-mac-permissions/node-addon-api": ["node-addon-api@5.0.0", "", {}, "sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA=="], + "@types/fs-extra/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -1101,11 +1304,15 @@ "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "execa/cross-spawn": ["cross-spawn@6.0.6", "", { "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", "semver": "^5.5.0", "shebang-command": "^1.2.0", "which": "^1.2.9" } }, "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw=="], + + "execa/get-stream": ["get-stream@4.1.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w=="], + "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], - "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "gauge/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -1113,8 +1320,12 @@ "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "load-bmfont/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "make-fetch-happen/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], @@ -1139,13 +1350,17 @@ "node-gyp/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "npm-run-path/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "pixelmatch/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], + + "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], - "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -1157,6 +1372,8 @@ "temp-file/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="], "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], @@ -1181,6 +1398,14 @@ "cacache/glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "execa/cross-spawn/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], + + "execa/cross-spawn/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + + "execa/cross-spawn/shebang-command": ["shebang-command@1.2.0", "", { "dependencies": { "shebang-regex": "^1.0.0" } }, "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg=="], + + "execa/cross-spawn/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], + "filelist/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], "glob/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], @@ -1191,6 +1416,8 @@ "make-fetch-happen/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], "zip-stream/archiver-utils/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -1200,5 +1427,7 @@ "app-builder-lib/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "cacache/glob/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], + + "execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="], } } diff --git a/package.json b/package.json index 42b1004..d4aa818 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "postinstall": "electron-builder install-app-deps" }, "dependencies": { + "@nut-tree-fork/nut-js": "^4.2.6", "posthog-node": "^4.0.0", "ws": "^8.18.0" }, @@ -62,8 +63,14 @@ "category": "public.app-category.productivity", "icon": "assets/icon.icns", "target": [ - { "target": "dmg", "arch": ["x64", "arm64"] }, - { "target": "zip", "arch": ["x64", "arm64"] } + { + "target": "dmg", + "arch": ["x64", "arm64"] + }, + { + "target": "zip", + "arch": ["x64", "arm64"] + } ], "hardenedRuntime": true, "gatekeeperAssess": false, @@ -78,7 +85,10 @@ "win": { "icon": "assets/icon.ico", "target": [ - { "target": "nsis", "arch": ["x64", "arm64"] } + { + "target": "nsis", + "arch": ["x64", "arm64"] + } ] }, "nsis": { diff --git a/src/main/companion-manager.ts b/src/main/companion-manager.ts index 2756f74..0e01ab8 100644 --- a/src/main/companion-manager.ts +++ b/src/main/companion-manager.ts @@ -6,6 +6,7 @@ import { ElevenLabsTTS } from './services/elevenlabs-tts'; import { createTranscriptionProvider, type TranscriptionProvider } from './services/transcription'; import { captureAllDisplays } from './services/screen-capture'; import { parseAllPointTags, parseTypeTags, TAG_STRIP_REGEX } from './services/element-detector'; +import { typeText, isAccessibilityGranted, promptAccessibility } from './services/auto-typer'; import { ContextManager } from './services/context-manager'; import * as settingsStore from './services/settings-store'; import * as keyStore from './services/key-store'; @@ -199,6 +200,12 @@ export class CompanionManager { setAutoTypeEnabled(enabled: boolean): void { settingsStore.set('autoTypeEnabled', enabled); + // Flipping the toggle on is the right moment to nudge the user + // through the macOS Accessibility prompt — they just expressed + // intent to grant. No-op on other platforms / when already trusted. + if (enabled && !isAccessibilityGranted()) { + promptAccessibility(); + } this.emitSettings(); } @@ -298,13 +305,19 @@ export class CompanionManager { // ── Permissions ────────────────────────────────────────────────────── async getPermissions(): Promise> { - const perms: Record = { microphone: false, screen: false }; + const perms: Record = { + microphone: false, + screen: false, + accessibility: false, + }; if (process.platform === 'darwin') { perms.microphone = systemPreferences.getMediaAccessStatus('microphone') === 'granted'; perms.screen = systemPreferences.getMediaAccessStatus('screen') === 'granted'; + perms.accessibility = isAccessibilityGranted(); } else { perms.microphone = true; perms.screen = true; + perms.accessibility = true; } return perms; } @@ -326,6 +339,18 @@ export class CompanionManager { return; } + if (kind === 'accessibility') { + // Calling with `true` adds Flicky to the Accessibility list and + // surfaces the OS dialog. The user still has to flip the checkbox + // themselves; we deeplink to the right pane in case the dialog + // got dismissed. + promptAccessibility(); + shell.openExternal( + 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility', + ); + return; + } + if (kind === 'screen') { const status = systemPreferences.getMediaAccessStatus('screen'); if (status === 'not-determined') { @@ -549,17 +574,26 @@ export class CompanionManager { ); } - // [TYPE:...] tags. For now we always handle them via clipboard - // handoff regardless of the autoTypeEnabled setting, since the - // native auto-typer module isn't wired up yet. The setting is - // reserved for the upcoming follow-up. + // [TYPE:...] tags. If the user has opted into auto-typing AND + // the OS permission is in place, we send the keys directly via + // the native typer; otherwise we fall back to clipboard handoff. + // typeText() returns false on any failure so the user is never + // left with no way to act on the request. const typeTexts = parseTypeTags(fullText); for (const text of typeTexts) { if (!text) continue; - clipboard.writeText(text); const preview = text.length > 50 ? `${text.slice(0, 50)}…` : text; - console.log(`[Flicky] Type request → clipboard: "${preview}"`); - this.callbacks.onTypeFulfilled({ text, preview, autoTyped: false }); + let autoTyped = false; + if (settings.autoTypeEnabled) { + autoTyped = await typeText(text); + } + if (!autoTyped) { + clipboard.writeText(text); + } + console.log( + `[Flicky] Type request → ${autoTyped ? 'auto-typed' : 'clipboard'}: "${preview}"`, + ); + this.callbacks.onTypeFulfilled({ text, preview, autoTyped }); } if (settings.speakReplies && keyStore.getKeyStatus().elevenlabs) { diff --git a/src/main/services/auto-typer.ts b/src/main/services/auto-typer.ts new file mode 100644 index 0000000..2df2629 --- /dev/null +++ b/src/main/services/auto-typer.ts @@ -0,0 +1,73 @@ +import { systemPreferences } from 'electron'; + +/** + * Native auto-typer wrapper. The underlying module (`@nut-tree-fork/nut-js`) + * ships native bindings for libnut and emits global keyboard events through + * the OS. We load it lazily so a failed install doesn't crash the main + * process — every consumer goes through `typeText`, which returns `false` + * if the module or the required permission is unavailable, and the caller + * falls back to clipboard handoff. + */ + +type NutJs = typeof import('@nut-tree-fork/nut-js'); + +let nutJs: NutJs | null = null; +let loadAttempted = false; + +async function load(): Promise { + if (loadAttempted) return nutJs; + loadAttempted = true; + try { + nutJs = await import('@nut-tree-fork/nut-js'); + } catch (err) { + console.error('[Flicky] auto-typer native module unavailable:', err); + nutJs = null; + } + return nutJs; +} + +/** + * Whether the OS permission required for auto-typing is currently granted. + * On macOS this is Accessibility (Input Monitoring is not enough — typing + * keystrokes globally requires the Accessibility trust list). On other + * platforms there is no equivalent gate. + */ +export function isAccessibilityGranted(): boolean { + if (process.platform !== 'darwin') return true; + return systemPreferences.isTrustedAccessibilityClient(false); +} + +/** + * Surface the macOS Accessibility prompt and add Flicky to the trust + * list. The user still has to enable the checkbox themselves; the OS + * does not return a granted state until they do, but the dialog gives + * them the discovery path. + */ +export function promptAccessibility(): boolean { + if (process.platform !== 'darwin') return true; + return systemPreferences.isTrustedAccessibilityClient(true); +} + +/** + * Type `text` into whatever the OS considers the focused element. + * Returns true when the keys were sent successfully, false when the + * caller should fall back to clipboard handoff (module missing, + * permission missing, or libnut threw). + */ +export async function typeText(text: string): Promise { + if (!text) return false; + const lib = await load(); + if (!lib) return false; + if (!isAccessibilityGranted()) return false; + try { + // Default delay is fine for native apps; web inputs sometimes drop + // characters at zero delay, but raising this hurts the "magical" + // feel. If we see drops in practice we can bump to ~5–10ms. + lib.keyboard.config.autoDelayMs = 0; + await lib.keyboard.type(text); + return true; + } catch (err) { + console.error('[Flicky] auto-type failed:', err); + return false; + } +} diff --git a/src/renderer/components/OverlayApp.tsx b/src/renderer/components/OverlayApp.tsx index 913a9eb..1c24e0c 100644 --- a/src/renderer/components/OverlayApp.tsx +++ b/src/renderer/components/OverlayApp.tsx @@ -402,11 +402,19 @@ export function OverlayApp() { {typeToast && isCursorOnThisDisplay && (
- 📋 + + {typeToast.autoTyped ? '⌨️' : '📋'} +
- Copied — press{' '} - {process.platform === 'darwin' ? '⌘V' : 'Ctrl+V'} to paste + {typeToast.autoTyped ? ( + 'Typed for you' + ) : ( + <> + Copied — press{' '} + {process.platform === 'darwin' ? '⌘V' : 'Ctrl+V'} to paste + + )}
“{typeToast.preview}”
diff --git a/src/renderer/components/panel/GeneralTab.tsx b/src/renderer/components/panel/GeneralTab.tsx index 73b3bb2..b27de5a 100644 --- a/src/renderer/components/panel/GeneralTab.tsx +++ b/src/renderer/components/panel/GeneralTab.tsx @@ -194,8 +194,8 @@ export function GeneralTab({ settings, memory }: GeneralTabProps) {
Allow Flicky to type for you
when off (default), Flicky copies text to your clipboard and you press paste. - when on, Flicky will type directly into the focused field - {' '}(native typer coming soon — currently still uses clipboard). + when on, Flicky types directly into the focused field + {isMac && <> — requires Accessibility permission on macOS}.
From 2633e5a7f2eaa5ae217d5ee37bed679fb6a27343 Mon Sep 17 00:00:00 2001 From: jvaught01 Date: Thu, 30 Apr 2026 15:12:41 -0700 Subject: [PATCH 07/12] fix: revert sandbox: true on renderers (caused blank windows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 4's sandbox flip broke the renderer — all three windows came up as blank black screens. Likely cause is the relative preload require-resolution path under sandboxed mode; the preload's own imports (../shared/types via tsc-emitted CommonJS require) don't play nicely until the preload is bundled as a single self-contained file (e.g. via esbuild). Reverted sandbox to false on panel/overlay/stream. Other Group 4 changes (walkthrough cleanup, display diff, ptt resync, posthog lazy-import, ws removal, clearStream cleanup) are unaffected and remain in place. If we want sandbox later, the prerequisite is a build step that bundles preload/index.ts into a single .js with its dependencies inlined. Out of scope for this audit pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/windows.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/windows.ts b/src/main/windows.ts index 331215e..c9e0992 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -43,10 +43,11 @@ export function createPanelWindow(): BrowserWindow { preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, - // Preload only uses contextBridge + ipcRenderer, both available - // in a sandboxed renderer. Sandboxing closes off Node API surface - // and gives the renderer process tighter OS-level isolation. - sandbox: true, + // sandbox: true caused renderers to fail to render (blank screen) + // — likely a require-resolution issue with our relative preload + // path. Reverted; we'd need to bundle the preload as a single + // self-contained file (esbuild) before flipping this on safely. + sandbox: false, }, }); @@ -88,7 +89,7 @@ export function createOverlayWindow(display: Display): BrowserWindow { preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, - sandbox: true, + sandbox: false, }, }); @@ -159,7 +160,7 @@ export function createStreamWindow( preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, - sandbox: true, + sandbox: false, }, }); From 6f416f9c98716a72480ab69787fb6839f1c6f9b3 Mon Sep 17 00:00:00 2001 From: jvaught01 Date: Thu, 30 Apr 2026 15:13:56 -0700 Subject: [PATCH 08/12] fix: don't read webContents.id in overlay closed handler The overlay's `closed` listener used `win.webContents.id` to clean up the overlayDisplayByWebContents map, but by the time `closed` fires the window's webContents has already been destroyed and property access on it throws "Object has been destroyed". Crashed on app quit. Capture the id at construction time and close over the primitive. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/windows.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/windows.ts b/src/main/windows.ts index c9e0992..6535064 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -105,10 +105,14 @@ export function createOverlayWindow(display: Display): BrowserWindow { loadPage(win, 'overlay'); // Track which display this overlay covers so we can answer the - // renderer's `get-display-info` invocation from main. - overlayDisplayByWebContents.set(win.webContents.id, display); + // renderer's `get-display-info` invocation from main. Capture the + // webContents id up front — by the time `closed` fires, the + // BrowserWindow's webContents has been destroyed and accessing + // `.id` on it throws "Object has been destroyed". + const wcId = win.webContents.id; + overlayDisplayByWebContents.set(wcId, display); win.on('closed', () => { - overlayDisplayByWebContents.delete(win.webContents.id); + overlayDisplayByWebContents.delete(wcId); }); // Push display info eagerly too — when the renderer is fast enough From bf7fb46de3328d4e4de07873b6fc8be092f5c70c Mon Sep 17 00:00:00 2001 From: jvaught01 Date: Thu, 30 Apr 2026 15:18:50 -0700 Subject: [PATCH 09/12] fix(capture): fall back to all displays when cursor-only filter empties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 1's cursor-only screenshot was a real win for token cost, but the filter is brittle — if `screen.getCursorScreenPoint()` returns coords that don't fall inside any reported display bounds (mid resolution change, post-wake, or topology states we don't recognize), the filter excludes every display and we fall through to the "no captures" error path, telling the user we can't see their screen even when permissions are fine. Now: if cursor-only excludes everything, log the cursor + bounds to the console (so we can diagnose if this happens again) and fall back to all displays. The model still gets a screenshot to work with; it just isn't pre-narrowed to one screen for that turn. Also: the "can't see your screen" path used to update the chat text but skip TTS, so users with their attention elsewhere got silence and no audible signal anything went wrong. Now the error message goes through TTS too when speakReplies + ElevenLabs are configured. And added a console.warn when captureDisplays returns zero screenshots, with display + source ids, so future regressions in this path are diagnosable from one log line. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/companion-manager.ts | 18 ++++++++++- src/main/services/screen-capture.ts | 48 ++++++++++++++++++++++------- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/main/companion-manager.ts b/src/main/companion-manager.ts index 71431ea..36d7103 100644 --- a/src/main/companion-manager.ts +++ b/src/main/companion-manager.ts @@ -512,12 +512,28 @@ export class CompanionManager { // Almost always means Screen Recording permission is missing on // macOS — desktopCapturer returns empty thumbnails in that case. // Surface a friendly response instead of letting an empty image - // 400 the upstream LLM call. + // 400 the upstream LLM call. Synthesize TTS too so the user + // hears the error even if their attention is on a different + // window than the panel. + const settings = settingsStore.getAll(); const msg = process.platform === 'darwin' ? "i can't see your screen right now — give flicky screen recording permission in system settings, then quit and reopen the app." : "i can't see your screen right now — screen capture failed."; this.callbacks.onAiResponseChunk(msg); this.callbacks.onAiResponseComplete(msg); + if (settings.speakReplies && keyStore.getKeyStatus().elevenlabs) { + this.setVoiceState('responding'); + try { + const audioBuffer = await this.tts.synthesize(msg, { + voiceId: settings.voiceId, + speed: settings.voiceSpeed, + stability: settings.voiceStability, + }); + this.callbacks.onPlayAudio(audioBuffer); + } catch (err) { + console.error('TTS error on screen-capture failure path:', err); + } + } this.setVoiceState('idle'); return; } diff --git a/src/main/services/screen-capture.ts b/src/main/services/screen-capture.ts index 444f823..780c9e7 100644 --- a/src/main/services/screen-capture.ts +++ b/src/main/services/screen-capture.ts @@ -21,17 +21,34 @@ export async function captureDisplays( const { cursorOnly = true } = opts; const allDisplays = screen.getAllDisplays(); const cursorPoint = screen.getCursorScreenPoint(); - const displays = cursorOnly - ? allDisplays.filter((d) => { - const b = d.bounds; - return ( - cursorPoint.x >= b.x && - cursorPoint.x < b.x + b.width && - cursorPoint.y >= b.y && - cursorPoint.y < b.y + b.height - ); - }) - : allDisplays; + let displays = allDisplays; + if (cursorOnly) { + const onCursor = allDisplays.filter((d) => { + const b = d.bounds; + return ( + cursorPoint.x >= b.x && + cursorPoint.x < b.x + b.width && + cursorPoint.y >= b.y && + cursorPoint.y < b.y + b.height + ); + }); + // Fall back to all displays if the cursor-only filter excludes + // everything (which can happen if the cursor is mid-transition, + // the OS returned a stale point right after a resolution change, + // or there's a multi-monitor topology we don't recognize). Better + // to send the model a screenshot of *some* screen than to bail + // and tell the user we can't see their screen at all. + if (onCursor.length > 0) { + displays = onCursor; + } else { + console.warn( + '[Flicky] cursor-only filter excluded every display ' + + `(cursor at ${cursorPoint.x},${cursorPoint.y}, ` + + `displays: ${allDisplays.map((d) => `${d.id}@${JSON.stringify(d.bounds)}`).join(' ')}). ` + + 'Falling back to all displays.', + ); + } + } // Get all screen sources const sources = await desktopCapturer.getSources({ @@ -101,6 +118,15 @@ export async function captureDisplays( // Sort so cursor screen is first (primary focus) captures.sort((a, b) => (b.isCursorScreen ? 1 : 0) - (a.isCursorScreen ? 1 : 0)); + if (captures.length === 0) { + console.warn( + '[Flicky] captureDisplays produced zero screenshots. ' + + `displays=${displays.length}, sources=${sources.length}, ` + + `sourceIds=[${sources.map((s) => s.display_id || '""').join(',')}], ` + + `displayIds=[${displays.map((d) => d.id).join(',')}]`, + ); + } + return captures; } From 3a8fbe2cedd380d4c0b76b3e4eebd790d4f0b1fa Mon Sep 17 00:00:00 2001 From: jvaught01 Date: Thu, 30 Apr 2026 15:24:27 -0700 Subject: [PATCH 10/12] fix: regressions introduced by perf audit (capture + ptt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real bugs from the audit-fix loop, found via the user's diagnostic log: [Flicky] captureDisplays produced zero screenshots. displays=1, sources=1, sourceIds=[1], displayIds=[1] Screen capture (group 1) - Added `thumbnail.isEmpty()` and `size === 0` guards in screen- capture.ts to defend against the macOS "permission denied → empty thumbnail" failure mode I'd theorized. The user's log confirms the source matched correctly and *something* came back, but those guards still rejected the thumbnail. The hypothesis was wrong, the guard was overzealous, and it broke capture on at least one valid-permission configuration. - Removed the over-broad guards. Replaced with a narrower one that only bails when `toJPEG()` actually returns 0 bytes — the precise condition that triggers Anthropic's "image cannot be empty" 400 — and logs the thumbnail size, target size, and scale factor so a future regression in this path is diagnosable from one log line. PTT after first turn (group 2) - The warm-mic-stack rewrite reset `micStopRequestedRef` only inside `ensureGraph`. On press 1, the graph is built and the flag is reset. On press 1's release, stopMic sets the flag to true. On press 2, ensureGraph short-circuits with the existing graph and never resets the flag, so startMic's guard (`if (... || micStopRequestedRef.current) return`) bails and the worklet never gets the 'start' message. Mic stayed muted from press 2 onward. - Reset the flag at the top of startMic so every press gets a fresh start, while preserving the start/stop race guarantees (a stop landing during the await still wins because it sets the flag back to true after we cleared it). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/services/screen-capture.ts | 22 +++++++++++++++------- src/renderer/components/OverlayApp.tsx | 6 ++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/main/services/screen-capture.ts b/src/main/services/screen-capture.ts index 780c9e7..25e84f1 100644 --- a/src/main/services/screen-capture.ts +++ b/src/main/services/screen-capture.ts @@ -65,16 +65,13 @@ export async function captureDisplays( return s.display_id === String(display.id); }) ?? sources[captures.length]; // Fallback to index-based matching - if (!source) continue; + if (!source) { + console.warn(`[Flicky] no source matched display ${display.id}`); + continue; + } const thumbnail = source.thumbnail; - // When Screen Recording permission is missing on macOS, desktopCapturer - // silently returns sources whose thumbnail is empty (size 0×0) rather - // than throwing. Skip those so we don't ship an empty base64 image to - // the LLM and trigger a 400. - if (thumbnail.isEmpty()) continue; const size = thumbnail.getSize(); - if (size.width === 0 || size.height === 0) continue; // On Windows/Linux with display scaling, the thumbnail is in physical // pixels but display.bounds is in logical pixels. We need to produce an @@ -96,6 +93,17 @@ export async function captureDisplays( const resized = thumbnail.resize({ width: targetWidth, height: targetHeight }); const jpegBuffer = resized.toJPEG(JPEG_QUALITY); + // Guard only against the case that actually causes Anthropic 400s: + // a zero-byte JPEG. Earlier broader checks (`isEmpty()`, `size===0`) + // were rejecting valid thumbnails on some macOS configurations. + if (jpegBuffer.length === 0) { + console.warn( + `[Flicky] display ${display.id}: JPEG encoded to 0 bytes ` + + `(thumbSize=${size.width}x${size.height}, target=${targetWidth}x${targetHeight}, ` + + `scaleFactor=${scaleFactor}); skipping`, + ); + continue; + } // Determine if cursor is on this display const bounds = display.bounds; diff --git a/src/renderer/components/OverlayApp.tsx b/src/renderer/components/OverlayApp.tsx index ce9d251..1709dbc 100644 --- a/src/renderer/components/OverlayApp.tsx +++ b/src/renderer/components/OverlayApp.tsx @@ -116,6 +116,12 @@ export function OverlayApp() { }; const startMic = async () => { + // Reset the stop flag at the top so subsequent presses always + // get a fresh start signal. On the very first press, ensureGraph + // also resets this; on press 2+, ensureGraph short-circuits with + // the existing node and would never clear the flag — leaving it + // stuck `true` and silently bailing every subsequent call. + micStopRequestedRef.current = false; const node = await ensureGraph(); // If a stop landed between ensureGraph resolving and now, don't // open the gate — the worklet stays muted. From 7746836eb62417e2f2df6c53fb731ff5db92ac65 Mon Sep 17 00:00:00 2001 From: jvaught01 Date: Mon, 18 May 2026 23:12:33 -0700 Subject: [PATCH 11/12] fix(capture): retry once when first attempt yields zero screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, desktopCapturer's source thumbnails can come back empty on the very first call shortly after app launch — the capture pipeline hasn't warmed up yet, so the first PTT after relaunch shipped no screenshot to the model. A single 300ms-delayed retry reliably hands back populated thumbnails on the second attempt. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/services/screen-capture.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/services/screen-capture.ts b/src/main/services/screen-capture.ts index 25e84f1..78a62f3 100644 --- a/src/main/services/screen-capture.ts +++ b/src/main/services/screen-capture.ts @@ -17,6 +17,20 @@ const JPEG_QUALITY = 82; */ export async function captureDisplays( opts: { cursorOnly?: boolean } = {}, +): Promise { + const first = await captureOnce(opts); + if (first.length > 0) return first; + // On macOS, desktopCapturer's source thumbnails can be empty on the + // very first call shortly after app launch — the capture pipeline + // hasn't warmed up yet. A single short-delayed retry reliably hands + // back populated thumbnails without bothering the user. + console.warn('[Flicky] capture returned zero on first try; retrying after 300ms'); + await new Promise((r) => setTimeout(r, 300)); + return captureOnce(opts); +} + +async function captureOnce( + opts: { cursorOnly?: boolean } = {}, ): Promise { const { cursorOnly = true } = opts; const allDisplays = screen.getAllDisplays(); From d6030ef98d29a25c55e360af752b81b3c4c454c3 Mon Sep 17 00:00:00 2001 From: jvaught01 Date: Thu, 10 Sep 2026 12:59:38 -0700 Subject: [PATCH 12/12] fix(windows): hold-to-talk, error surfacing, mic permission; add verified setup wizard Windows fixes: - hold-to-talk stopped after 250ms because the OS key-repeat delay (500ms-1s) hadn't elapsed yet; first fire now gets a 1.1s grace window - transcription/model/mic failures were console-only; now surfaced in the panel + stream, and a failed transcription no longer leaves voice state stuck on 'listening' - query Windows microphone privacy status and deep-link to ms-settings - second-instance launch shows the panel instead of silently exiting - tray click can hide the panel again; overlays follow display-metrics changes; .ico tray icon; hide stock menu bar - shortcut capture uses e.code so Shift combos / non-US layouts bind - OpenAI whisper provider used the Anthropic key; version label hardcoded Setup wizard (first run, re-runnable from General): permissions, provider keys validated by a real round-trip before saving, shortcut press check, live mic level check, and an end-to-end trial turn. Co-Authored-By: Claude Fable 5.1 --- package.json | 2 +- src/main/companion-manager.ts | 93 +- src/main/index.ts | 125 ++- src/main/services/key-validation.ts | 123 +++ src/main/services/transcription.ts | 6 +- src/main/windows.ts | 3 + src/preload/index.ts | 43 +- src/renderer/components/OverlayApp.tsx | 35 + src/renderer/components/PanelApp.tsx | 45 +- src/renderer/components/panel/GeneralTab.tsx | 9 + src/renderer/components/panel/HomeTab.tsx | 37 +- src/renderer/components/panel/KeyEntry.tsx | 110 +++ src/renderer/components/panel/Onboarding.tsx | 835 ++++++++++++++++++ .../components/panel/PermissionsBanner.tsx | 29 +- src/renderer/components/panel/ProviderKey.tsx | 36 +- .../components/panel/ShortcutCapture.tsx | 13 +- src/renderer/panel.tsx | 1 + src/renderer/styles/onboarding.css | 194 ++++ src/shared/types.ts | 38 + 19 files changed, 1686 insertions(+), 91 deletions(-) create mode 100644 src/main/services/key-validation.ts create mode 100644 src/renderer/components/panel/KeyEntry.tsx create mode 100644 src/renderer/components/panel/Onboarding.tsx create mode 100644 src/renderer/styles/onboarding.css diff --git a/package.json b/package.json index a8aeabd..9311bc4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flicky", - "version": "1.2.0", + "version": "1.2.1", "description": "Cross-platform AI companion — voice-driven, screen-aware assistant with a pointing cursor overlay", "main": "dist/main/main/index.js", "author": { diff --git a/src/main/companion-manager.ts b/src/main/companion-manager.ts index 36d7103..a75c74a 100644 --- a/src/main/companion-manager.ts +++ b/src/main/companion-manager.ts @@ -31,6 +31,7 @@ import type { ChatEntry, StreamVisibility, StreamWindowBounds, + PermissionStatus, } from '../shared/types'; export interface CompanionCallbacks { @@ -38,6 +39,13 @@ export interface CompanionCallbacks { onTranscriptUpdate: (result: TranscriptionResult) => void; onAiResponseChunk: (chunk: string) => void; onAiResponseComplete: (fullText: string) => void; + /** + * A turn failed somewhere in mic → transcription → model → TTS. + * Previously these only went to the console, which on a packaged + * Windows build means nobody ever saw them — the app just went + * quiet. Surfaced to the panel / stream so the user learns *why*. + */ + onError: (message: string) => void; onWalkthrough: (walkthrough: Walkthrough | null) => void; /** Active step index (0-based) inside the current walkthrough, or null when idle. */ onWalkthroughStep: (index: number | null) => void; @@ -88,6 +96,12 @@ export class CompanionManager { * leave the mic running forever. */ private pendingStart: Promise | null = null; + /** + * Setup's mic check runs capture without a transcription provider. + * While true, audio chunks are dropped here; the overlay still emits + * level events that the panel visualises. + */ + private micTestActive = false; constructor(callbacks: CompanionCallbacks) { this.callbacks = callbacks; @@ -310,25 +324,45 @@ export class CompanionManager { // ── Permissions ────────────────────────────────────────────────────── - async getPermissions(): Promise> { - const perms: Record = { - microphone: false, - screen: false, - accessibility: false, + async getPermissions(): Promise { + const perms: PermissionStatus = { + microphone: true, + screen: true, + accessibility: true, + microphoneStatus: 'unknown', }; if (process.platform === 'darwin') { - perms.microphone = systemPreferences.getMediaAccessStatus('microphone') === 'granted'; + const mic = systemPreferences.getMediaAccessStatus('microphone'); + perms.microphoneStatus = mic; + perms.microphone = mic === 'granted'; perms.screen = systemPreferences.getMediaAccessStatus('screen') === 'granted'; perms.accessibility = isAccessibilityGranted(); - } else { - perms.microphone = true; - perms.screen = true; - perms.accessibility = true; + } else if (process.platform === 'win32') { + // Windows 10/11 gate desktop-app microphone access under + // Settings → Privacy → Microphone. When it's off, getUserMedia in + // the overlay fails and Flicky silently hears nothing — the #1 + // "it doesn't work" report. Electron exposes the same status + // query on Windows, so surface it. + try { + const mic = systemPreferences.getMediaAccessStatus('microphone'); + perms.microphoneStatus = mic; + perms.microphone = mic === 'granted' || mic === 'not-determined'; + } catch (err) { + console.error('[Flicky] mic status probe failed:', err); + } } return perms; } async requestPermission(kind: string): Promise { + if (process.platform === 'win32') { + if (kind === 'microphone') { + // Deeplink straight to the privacy pane; the toggles there are + // "Microphone access" and "Let desktop apps access your microphone". + void shell.openExternal('ms-settings:privacy-microphone'); + } + return; + } if (process.platform !== 'darwin') return; if (kind === 'microphone') { @@ -407,6 +441,22 @@ export class CompanionManager { await this.stopRecordingAndProcess(); } + // ── Mic check (setup) ──────────────────────────────────────────────── + + startMicTest(): void { + if (this.isRecording || this.micTestActive) return; + this.micTestActive = true; + this.callbacks.onStartAudioCapture(); + } + + stopMicTest(): void { + if (!this.micTestActive) return; + this.micTestActive = false; + // Don't yank the mic out from under a real PTT turn that started + // while the test was running. + if (!this.isRecording) this.callbacks.onStopAudioCapture(); + } + private clearWalkthroughTimers(): void { for (const t of this.walkthroughTimers) clearTimeout(t); this.walkthroughTimers = []; @@ -472,11 +522,16 @@ export class CompanionManager { try { await this.transcriptionProvider.start(); + // A setup mic check may already hold the capture open; starting + // again is harmless (the overlay just re-opens the gate). + this.micTestActive = false; this.callbacks.onStartAudioCapture(); } catch (err) { console.error('Failed to start transcription:', err); this.setVoiceState('idle'); this.isRecording = false; + this.transcriptionProvider = null; + this.callbacks.onError(err instanceof Error ? err.message : String(err)); } } @@ -490,7 +545,21 @@ export class CompanionManager { return; } - const result = await this.transcriptionProvider.stop(); + // A failed upload (bad Groq key, offline, 4xx) used to throw straight + // out of here — nothing caught it, so the voice state stayed stuck on + // 'listening' and the next PTT press did nothing. Contain it. + let result: TranscriptionResult; + try { + result = await this.transcriptionProvider.stop(); + } catch (err) { + console.error('Transcription failed:', err); + this.transcriptionProvider = null; + this.setVoiceState('idle'); + this.callbacks.onError( + `couldn't transcribe that — ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } this.transcriptionProvider = null; if (!result.text.trim()) { @@ -644,6 +713,7 @@ export class CompanionManager { console.error('Mind provider error:', err); analytics.trackResponseError(err.message); this.setVoiceState('idle'); + this.callbacks.onError(err.message); }, }; @@ -705,6 +775,7 @@ export class CompanionManager { } handleAudioChunk(buffer: Buffer): void { + if (!this.isRecording) return; this.transcriptionProvider?.sendAudio(buffer); } diff --git a/src/main/index.ts b/src/main/index.ts index 3067d62..2d02edd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,6 +12,7 @@ import { AUDIO_IPC } from './services/audio-capture'; import * as chatHistory from './services/chat-history-store'; import * as settingsStore from './services/settings-store'; import { setApiKey, getApiKey, deleteApiKey } from './services/key-store'; +import { validateApiKey, validateStoredApiKey } from './services/key-validation'; import { OllamaAPI } from './services/ollama-api'; import { randomUUID } from 'crypto'; @@ -20,6 +21,20 @@ const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { app.quit(); } +// Flicky lives in the tray, so on Windows the natural thing to do when +// you can't find it is to double-click the shortcut again. Without this +// handler that second launch just exited and nothing visible happened — +// which reads as "the app is broken". Surface the panel instead. +app.on('second-instance', () => { + if (!companion) return; + if (panelWindow && !panelWindow.isDestroyed()) { + if (panelWindow.isMinimized()) panelWindow.restore(); + panelWindow.show(); + panelWindow.focus(); + } else { + togglePanel(); + } +}); let tray: Tray | null = null; let panelWindow: BrowserWindow | null = null; @@ -62,9 +77,15 @@ function createTrayIcon(): Electron.NativeImage { const size32 = path.join(assetRoot, 'icons', '32x32.png'); const size16 = path.join(assetRoot, 'icons', '16x16.png'); - const primary = process.platform === 'darwin' ? size32 : size16; - try { + // Windows renders tray icons from a multi-size .ico crisply at any + // DPI; a 16px PNG gets upscaled and blurry at 125%/150% scaling. + if (process.platform === 'win32') { + const ico = nativeImage.createFromPath(path.join(assetRoot, 'icon.ico')); + if (!ico.isEmpty()) return ico; + } + + const primary = process.platform === 'darwin' ? size32 : size16; const img = nativeImage.createFromPath(primary); if (img.isEmpty()) throw new Error('empty tray icon image'); @@ -175,6 +196,10 @@ app.whenReady().then(() => { sendToPanel(IPC.AI_RESPONSE_COMPLETE, text); sendToStream(IPC.AI_RESPONSE_COMPLETE, text); }, + onError: (message) => { + sendToPanel(IPC.AI_ERROR, message); + sendToStream(IPC.AI_ERROR, message); + }, onWalkthrough: (w) => { walkthroughActive = !!w; // The walkthrough plays on exactly one display — the cursor @@ -260,6 +285,14 @@ app.whenReady().then(() => { rebuildOverlays(); screen.on('display-added', () => syncOverlaysToDisplays()); screen.on('display-removed', () => syncOverlaysToDisplays()); + // Resolution / DPI / arrangement changes (docking a laptop, changing + // scaling in Settings) keep the same display ids but move the bounds. + // Without this the overlay stayed at the stale rect, so the blue + // cursor pointed at the wrong spot or lived off-screen entirely. + screen.on('display-metrics-changed', (_e, display, changed) => { + if (!changed.some((c) => c === 'bounds' || c === 'scaleFactor' || c === 'workArea')) return; + syncOverlayBounds(display); + }); // Stream window is created lazily — the default `streamVisibility:'off'` // means a fresh-install user used to have an entire Chromium renderer @@ -269,10 +302,14 @@ app.whenReady().then(() => { // Sync the OS login-item state with our stored preference. Handles // the case where the user disables the login item externally (e.g. // via System Settings) — next launch reconciles the two. - try { - app.setLoginItemSettings({ openAtLogin: companion.getSettings().launchAtLogin }); - } catch (err) { - console.error('[Flicky] initial setLoginItemSettings failed:', err); + // Skipped in dev: unpackaged it would register the bare electron + // binary as a startup item. + if (app.isPackaged) { + try { + app.setLoginItemSettings({ openAtLogin: companion.getSettings().launchAtLogin }); + } catch (err) { + console.error('[Flicky] initial setLoginItemSettings failed:', err); + } } // Register global push-to-talk shortcut. @@ -290,9 +327,30 @@ app.whenReady().then(() => { const isMac = process.platform === 'darwin'; let pttDebounceTimer: ReturnType | null = null; let pttActive = false; + /** Number of accelerator fires seen during the current hold. */ + let pttFireCount = 0; let currentShortcut = ''; + /** Setup's "press your shortcut" check — see IPC.PTT_TEST_START. */ + let pttTestMode = false; + + // 'hold' timing. The OS doesn't start auto-repeating a held key until + // its repeat-delay elapses — on Windows that's 250 ms at the fastest + // setting and 1 s at the slowest (default ≈ 500 ms). The old fixed + // 250 ms silence window therefore expired *before the first repeat + // ever arrived*: recording stopped after a quarter second, a useless + // sliver of audio went to Whisper, then the repeat kicked in and + // started a brand-new turn — over and over while the key was held. + // Give the first repeat a generous window; once repeats are flowing + // (~30 Hz) a tight window is plenty. + const PTT_HOLD_INITIAL_GRACE_MS = 1100; + const PTT_HOLD_REPEAT_GRACE_MS = 250; const pttHandler = () => { + // Setup verification: prove the binding reaches us without + // actually opening the mic. + sendToPanel(IPC.PTT_SHORTCUT_FIRED); + if (pttTestMode) return; + const mode = isMac ? 'toggle' : companion.getSettings().pttMode; if (mode === 'toggle') { @@ -321,15 +379,22 @@ app.whenReady().then(() => { } if (!pttActive) { pttActive = true; - companion.startPushToTalk(); + pttFireCount = 0; + void companion.startPushToTalk(); } + pttFireCount += 1; + const grace = pttFireCount === 1 ? PTT_HOLD_INITIAL_GRACE_MS : PTT_HOLD_REPEAT_GRACE_MS; pttDebounceTimer = setTimeout(() => { pttActive = false; + pttFireCount = 0; pttDebounceTimer = null; - companion.stopPushToTalk(); - }, 250); + void companion.stopPushToTalk(); + }, grace); }; + ipcMain.on(IPC.PTT_TEST_START, () => { pttTestMode = true; }); + ipcMain.on(IPC.PTT_TEST_STOP, () => { pttTestMode = false; }); + function registerPttShortcut(accelerator: string): boolean { const previous = currentShortcut; try { @@ -389,6 +454,20 @@ app.whenReady().then(() => { ipcMain.handle(IPC.GET_SETTINGS, () => companion.getSettings()); ipcMain.handle(IPC.GET_PERMISSIONS, () => companion.getPermissions()); + ipcMain.handle(IPC.GET_APP_VERSION, () => app.getVersion()); + ipcMain.handle(IPC.VALIDATE_API_KEY, (_e, name, key: string) => validateApiKey(name, key)); + ipcMain.handle(IPC.VALIDATE_STORED_API_KEY, (_e, name) => validateStoredApiKey(name)); + + // Setup mic check: capture runs, levels flow to the panel, nothing is + // transcribed. The overlay owns the mic; relay its telemetry here. + ipcMain.on(IPC.MIC_TEST_START, () => companion.startMicTest()); + ipcMain.on(IPC.MIC_TEST_STOP, () => companion.stopMicTest()); + ipcMain.on(IPC.MIC_LEVEL, (_e, level: number) => sendToPanel(IPC.MIC_LEVEL, level)); + ipcMain.on(IPC.MIC_ERROR, (_e, message: string) => { + console.error('[Flicky] mic capture error from overlay:', message); + sendToPanel(IPC.MIC_ERROR, message); + sendToPanel(IPC.AI_ERROR, `microphone unavailable — ${message}`); + }); ipcMain.on(IPC.SET_MODEL, (_e, model) => companion.setModel(model)); ipcMain.on(IPC.SET_OPENAI_MODEL, (_e, model) => companion.setOpenAIModel(model)); @@ -560,18 +639,28 @@ app.on('window-all-closed', () => { // ── Window Management ────────────────────────────────────────────────── +/** When the panel last lost focus — see togglePanel. */ +let panelBlurredAt = 0; + function togglePanel(): void { if (panelWindow && !panelWindow.isDestroyed()) { - if (panelWindow.isVisible() && panelWindow.isFocused()) { + // On Windows, clicking the tray icon blurs the panel *before* our + // click handler runs, so `isFocused()` was always false and the tray + // could only ever show the panel, never hide it. Treat a blur in the + // last few hundred ms as "was focused when you clicked". + const recentlyFocused = Date.now() - panelBlurredAt < 400; + if (panelWindow.isVisible() && !panelWindow.isMinimized() && (panelWindow.isFocused() || recentlyFocused)) { panelWindow.hide(); return; } + if (panelWindow.isMinimized()) panelWindow.restore(); panelWindow.show(); panelWindow.focus(); return; } panelWindow = createPanelWindow(); + panelWindow.on('blur', () => { panelBlurredAt = Date.now(); }); panelWindow.webContents.on('did-fail-load', (_e, code, desc, url) => { console.error('[Flicky] Panel FAILED to load:', code, desc, url); }); @@ -643,6 +732,22 @@ function syncOverlaysToDisplays(): void { applyOverlayVisibility(companion.getSettings().isClickyCursorEnabled); } +/** Move an existing overlay to its display's new bounds after a metrics change. */ +function syncOverlayBounds(display: Electron.Display): void { + for (const win of overlayWindows) { + if (win.isDestroyed()) continue; + const tracked = overlayDisplayByWebContents.get(win.webContents.id); + if (!tracked || tracked.id !== display.id) continue; + win.setBounds(display.bounds); + overlayDisplayByWebContents.set(win.webContents.id, display); + win.webContents.send('display-info', { + id: display.id, + bounds: display.bounds, + scaleFactor: display.scaleFactor, + }); + } +} + function applyOverlayVisibility(enabled: boolean): void { for (const win of overlayWindows) { if (win.isDestroyed()) continue; diff --git a/src/main/services/key-validation.ts b/src/main/services/key-validation.ts new file mode 100644 index 0000000..a5dd327 --- /dev/null +++ b/src/main/services/key-validation.ts @@ -0,0 +1,123 @@ +import type { ApiKeyName, ApiKeyValidation } from '../../shared/types'; +import { getApiKey } from './key-store'; + +/** + * Prove a key is accepted by its provider before we rely on it. + * + * For the two reasoning providers we make a *real* one-token completion + * rather than listing models: a list call authenticates fine on an + * account with no credit, and the user then hits "credit balance too + * low" on their first actual question. The completion costs a fraction + * of a cent and catches billing, quota and model-access problems too. + * Transcription / TTS use free whoami-style endpoints. + */ + +const TIMEOUT_MS = 15_000; + +interface Probe { + url: string; + method: 'GET' | 'POST'; + headers: Record; + body?: string; +} + +const PROBES: Record Probe> = { + anthropic: (key) => ({ + url: 'https://api.anthropic.com/v1/messages', + method: 'POST', + headers: { + 'x-api-key': key, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + }), + openai: (key) => ({ + url: 'https://api.openai.com/v1/chat/completions', + method: 'POST', + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + }), + elevenlabs: (key) => ({ + url: 'https://api.elevenlabs.io/v1/user', + method: 'GET', + headers: { 'xi-api-key': key }, + }), + groq: (key) => ({ + url: 'https://api.groq.com/openai/v1/models', + method: 'GET', + headers: { Authorization: `Bearer ${key}` }, + }), +}; + +/** Pull the human-readable message out of a provider error body. */ +function extractMessage(text: string): string { + try { + const j = JSON.parse(text) as { error?: { message?: string } | string; message?: string; detail?: { message?: string } | string }; + if (typeof j.error === 'string') return j.error; + if (j.error?.message) return j.error.message; + if (typeof j.detail === 'string') return j.detail; + if (j.detail && typeof j.detail === 'object' && j.detail.message) return j.detail.message; + if (j.message) return j.message; + } catch { /* not JSON */ } + return text.slice(0, 200); +} + +export async function validateApiKey(name: ApiKeyName, key: string): Promise { + const trimmed = key.trim(); + if (!trimmed) return { ok: false, error: 'Key is empty.' }; + const build = PROBES[name]; + if (!build) return { ok: false, error: `Unknown provider "${name}".` }; + + const probe = build(trimmed); + try { + const res = await fetch(probe.url, { + method: probe.method, + headers: probe.headers, + body: probe.body, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (res.ok) return { ok: true }; + + let detail = ''; + try { detail = extractMessage(await res.text()); } catch { /* ignore */ } + + if (res.status === 401 || res.status === 403) { + return { ok: false, error: 'The provider rejected this key. Double-check it was copied in full.' }; + } + if (res.status === 429) { + // Rate-limit vs. out-of-quota both come back as 429 from OpenAI; + // the body tells them apart. + if (/quota|billing|credit/i.test(detail)) { + return { ok: false, error: `Key works but the account can't be billed: ${detail}` }; + } + return { ok: true }; + } + return { ok: false, error: `Provider returned HTTP ${res.status}${detail ? `: ${detail}` : ''}` }; + } catch (err) { + const e = err as Error & { name?: string; cause?: { code?: string } }; + if (e.name === 'TimeoutError' || e.name === 'AbortError') { + return { ok: false, error: 'Timed out reaching the provider. Check your internet connection or firewall.' }; + } + const code = e.cause?.code; + if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') { + return { ok: false, error: 'Could not resolve the provider host — are you offline?' }; + } + return { ok: false, error: `Network error: ${e.message}` }; + } +} + +/** Validate whatever key is currently saved for `name`. */ +export async function validateStoredApiKey(name: ApiKeyName): Promise { + const key = getApiKey(name); + if (!key) return { ok: false, error: 'No key saved.' }; + return validateApiKey(name, key); +} diff --git a/src/main/services/transcription.ts b/src/main/services/transcription.ts index 3d5ae8f..dd899dd 100644 --- a/src/main/services/transcription.ts +++ b/src/main/services/transcription.ts @@ -86,8 +86,8 @@ export class OpenAIWhisperProvider implements TranscriptionProvider { onPartialTranscript?: (text: string) => void; async start(): Promise { - const apiKey = getApiKey('anthropic'); // Uses OpenAI-compatible key — user may supply separately - if (!apiKey) throw new Error('API key not configured for transcription.'); + const apiKey = getApiKey('openai'); + if (!apiKey) throw new Error('OpenAI API key not configured. Add it in the Flicky panel.'); this.audioChunks = []; } @@ -108,7 +108,7 @@ export class OpenAIWhisperProvider implements TranscriptionProvider { const res = await fetch('https://api.openai.com/v1/audio/transcriptions', { method: 'POST', headers: { - Authorization: `Bearer ${getApiKey('anthropic')}`, // Would need a separate OpenAI key in production + Authorization: `Bearer ${getApiKey('openai')}`, }, body: formData, }); diff --git a/src/main/windows.ts b/src/main/windows.ts index 6535064..996ecdd 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -39,6 +39,9 @@ export function createPanelWindow(): BrowserWindow { transparent: false, backgroundColor: '#0f0f11', title: 'Flicky', + // Windows/Linux otherwise show Electron's stock "File Edit View + // Window Help" bar above the panel. Alt still reveals it. + autoHideMenuBar: true, webPreferences: { preload: getPreloadPath(), contextIsolation: true, diff --git a/src/preload/index.ts b/src/preload/index.ts index 308ef76..2c62afb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -21,6 +21,8 @@ import type { LocalConnection, OllamaModelInfo, OllamaPullProgress, + ApiKeyValidation, + PermissionStatus, } from '../shared/types'; import type { OllamaTestResult } from '../main/services/ollama-api'; @@ -62,7 +64,7 @@ const api = { playVoicePreview: (voiceId: string): void => ipcRenderer.send(IPC.PLAY_VOICE_PREVIEW, voiceId), // ── Permissions ──────────────────────────────────────────────────── - getPermissions: (): Promise> => ipcRenderer.invoke(IPC.GET_PERMISSIONS), + getPermissions: (): Promise => ipcRenderer.invoke(IPC.GET_PERMISSIONS), requestPermission: (kind: string): void => ipcRenderer.send(IPC.REQUEST_PERMISSION, kind), // ── API Keys ─────────────────────────────────────────────────────── @@ -70,6 +72,41 @@ const api = { deleteApiKey: (name: ApiKeyName): void => ipcRenderer.send(IPC.DELETE_API_KEY, name), getApiKeyStatus: (): Promise> => ipcRenderer.invoke(IPC.GET_API_KEY_STATUS), + validateApiKey: (name: ApiKeyName, value: string): Promise => + ipcRenderer.invoke(IPC.VALIDATE_API_KEY, name, value), + validateStoredApiKey: (name: ApiKeyName): Promise => + ipcRenderer.invoke(IPC.VALIDATE_STORED_API_KEY, name), + + // ── Setup verification ───────────────────────────────────────────── + getAppVersion: (): Promise => ipcRenderer.invoke(IPC.GET_APP_VERSION), + startPttTest: (): void => ipcRenderer.send(IPC.PTT_TEST_START), + stopPttTest: (): void => ipcRenderer.send(IPC.PTT_TEST_STOP), + startMicTest: (): void => ipcRenderer.send(IPC.MIC_TEST_START), + stopMicTest: (): void => ipcRenderer.send(IPC.MIC_TEST_STOP), + /** Overlay → main: current input level (0..1). */ + reportMicLevel: (level: number): void => ipcRenderer.send(IPC.MIC_LEVEL, level), + /** Overlay → main: getUserMedia / AudioContext failure. */ + reportMicError: (message: string): void => ipcRenderer.send(IPC.MIC_ERROR, message), + onPttShortcutFired: (cb: () => void) => { + const handler = () => cb(); + ipcRenderer.on(IPC.PTT_SHORTCUT_FIRED, handler); + return () => ipcRenderer.removeListener(IPC.PTT_SHORTCUT_FIRED, handler); + }, + onMicLevel: (cb: (level: number) => void) => { + const handler = (_e: Electron.IpcRendererEvent, level: number) => cb(level); + ipcRenderer.on(IPC.MIC_LEVEL, handler); + return () => ipcRenderer.removeListener(IPC.MIC_LEVEL, handler); + }, + onMicError: (cb: (message: string) => void) => { + const handler = (_e: Electron.IpcRendererEvent, message: string) => cb(message); + ipcRenderer.on(IPC.MIC_ERROR, handler); + return () => ipcRenderer.removeListener(IPC.MIC_ERROR, handler); + }, + onAiError: (cb: (message: string) => void) => { + const handler = (_e: Electron.IpcRendererEvent, message: string) => cb(message); + ipcRenderer.on(IPC.AI_ERROR, handler); + return () => ipcRenderer.removeListener(IPC.AI_ERROR, handler); + }, // ── Memory / context ─────────────────────────────────────────────── getMemoryStats: (): Promise => ipcRenderer.invoke(IPC.GET_MEMORY_STATS), @@ -183,8 +220,8 @@ const api = { return () => ipcRenderer.removeListener(IPC.SETTINGS_CHANGED, handler); }, - onPermissionStatus: (cb: (perms: Record) => void) => { - const handler = (_e: Electron.IpcRendererEvent, perms: Record) => cb(perms); + onPermissionStatus: (cb: (perms: PermissionStatus) => void) => { + const handler = (_e: Electron.IpcRendererEvent, perms: PermissionStatus) => cb(perms); ipcRenderer.on(IPC.PERMISSION_STATUS, handler); return () => ipcRenderer.removeListener(IPC.PERMISSION_STATUS, handler); }, diff --git a/src/renderer/components/OverlayApp.tsx b/src/renderer/components/OverlayApp.tsx index 1709dbc..4f77de8 100644 --- a/src/renderer/components/OverlayApp.tsx +++ b/src/renderer/components/OverlayApp.tsx @@ -59,6 +59,9 @@ export function OverlayApp() { const micStartingRef = useRef(false); /** set by stopMic so a pending start can bail before attaching. */ const micStopRequestedRef = useRef(false); + /** Peak RMS since the last level report; reported ~20×/s to main. */ + const micPeakRef = useRef(0); + const micLevelSentAtRef = useRef(0); // ── TTS playback (cancelable) ─────────────────────────────────────── const ttsRef = useRef<{ audio: HTMLAudioElement; url: string } | null>(null); @@ -95,6 +98,25 @@ export function OverlayApp() { const source = ctx.createMediaStreamSource(stream); const node = new AudioWorkletNode(ctx, 'capture-processor'); node.port.onmessage = (e: MessageEvent) => { + // Cheap RMS so the panel's mic check (and any future meter) + // can show that sound is actually arriving. Throttled to + // ~20 Hz; the chunk itself is forwarded untouched. + const pcm = new Int16Array(e.data); + let sum = 0; + for (let i = 0; i < pcm.length; i++) { + const s = pcm[i] / 32768; + sum += s * s; + } + const rms = pcm.length ? Math.sqrt(sum / pcm.length) : 0; + if (rms > micPeakRef.current) micPeakRef.current = rms; + const now = performance.now(); + if (now - micLevelSentAtRef.current > 50) { + micLevelSentAtRef.current = now; + // Speech RMS sits around 0.05–0.3; scale so normal talking + // fills most of the meter. + window.flicky.reportMicLevel(Math.min(1, micPeakRef.current * 4)); + micPeakRef.current = 0; + } window.flicky.sendAudioChunk(e.data); }; // Pull-graph: source → worklet → destination. The worklet @@ -109,6 +131,19 @@ export function OverlayApp() { return node; } catch (err) { console.error('[Flicky] Mic capture init failed:', err); + // Nobody reads the overlay's devtools console on a packaged + // build. Translate the DOMException into something a person can + // act on and hand it to main, which shows it in the panel. + const e = err as { name?: string; message?: string }; + const friendly = + e.name === 'NotAllowedError' || e.name === 'SecurityError' + ? 'microphone access is blocked for Flicky. Allow it in your OS privacy settings.' + : e.name === 'NotFoundError' || e.name === 'OverconstrainedError' + ? 'no microphone was found. Plug one in or pick a default input device in your sound settings.' + : e.name === 'NotReadableError' + ? 'the microphone is busy or unreadable — another app may be holding it.' + : `${e.name ?? 'Error'}: ${e.message ?? String(err)}`; + window.flicky.reportMicError(friendly); return null; } finally { micStartingRef.current = false; diff --git a/src/renderer/components/PanelApp.tsx b/src/renderer/components/PanelApp.tsx index 2b79c2b..1d8e33a 100644 --- a/src/renderer/components/PanelApp.tsx +++ b/src/renderer/components/PanelApp.tsx @@ -7,6 +7,7 @@ import { VoiceTab } from './panel/VoiceTab'; import { EarTab } from './panel/EarTab'; import { GeneralTab } from './panel/GeneralTab'; import { PermissionsBanner } from './panel/PermissionsBanner'; +import { Onboarding } from './panel/Onboarding'; import { CursorIcon } from './CursorIcon'; type Tab = 'home' | 'chats' | 'mind' | 'voice' | 'ear' | 'general'; @@ -16,22 +17,47 @@ export function PanelApp() { const [settings, setSettings] = useState(null); const [memory, setMemory] = useState(null); const [tab, setTab] = useState('home'); + const [version, setVersion] = useState(''); + const [lastError, setLastError] = useState(null); useEffect(() => { window.flicky.getSettings().then(setSettings); window.flicky.getMemoryStats().then(setMemory); + window.flicky.getAppVersion().then(setVersion).catch(() => {}); + let errTimer: ReturnType | null = null; const unsubs = [ window.flicky.onVoiceStateChanged(setVoiceState), window.flicky.onSettingsChanged(setSettings), window.flicky.onMemoryStats(setMemory), + // Turn failures used to vanish into the main-process console. Show + // them in a dismissable strip so "nothing happened" becomes "here's + // why nothing happened". + window.flicky.onAiError((m) => { + setLastError(m); + if (errTimer) clearTimeout(errTimer); + errTimer = setTimeout(() => setLastError(null), 12_000); + }), ]; - return () => unsubs.forEach((u) => u()); + return () => { + unsubs.forEach((u) => u()); + if (errTimer) clearTimeout(errTimer); + }; }, []); if (!settings) return null; + if (!settings.onboardingComplete) { + return ; + } + const { apiKeyStatus } = settings; + const mindNeeds = + settings.mindProvider === 'openai' + ? !apiKeyStatus.openai + : settings.mindProvider === 'ollama' + ? !(settings.localConnections ?? []).some((c) => c.enabled) + : !apiKeyStatus.anthropic; const navItem = ( id: Tab, @@ -62,13 +88,8 @@ export function PanelApp() { {navItem('chats', 'Chats')}
Providers
- {navItem('mind', 'Mind', { - needs: - settings.mindProvider === 'openai' - ? !apiKeyStatus.openai - : !apiKeyStatus.anthropic, - })} - {navItem('voice', 'Voice', { needs: !apiKeyStatus.elevenlabs })} + {navItem('mind', 'Mind', { needs: mindNeeds })} + {navItem('voice', 'Voice', { needs: settings.speakReplies && !apiKeyStatus.elevenlabs })} {navItem('ear', 'Ear', { needs: !apiKeyStatus.groq })}
System
@@ -79,12 +100,18 @@ export function PanelApp() { -
v0.1.0
+
{version ? `v${version}` : ''}
+ {lastError && ( +
+ {lastError} + +
+ )} {tab === 'home' && (
+
+
+
Setup
+
re-check permissions, keys, the shortcut and your mic step by step
+
+ +
Stream window
diff --git a/src/renderer/components/panel/HomeTab.tsx b/src/renderer/components/panel/HomeTab.tsx index 9fd935a..078d1d5 100644 --- a/src/renderer/components/panel/HomeTab.tsx +++ b/src/renderer/components/panel/HomeTab.tsx @@ -17,12 +17,19 @@ function formatTokens(n: number): string { export function HomeTab({ voiceState, settings, memory, onNavigate }: HomeTabProps) { const { apiKeyStatus, mindProvider } = settings; + const localConn = (settings.localConnections ?? []).find((c) => c.enabled); const mindReady = - mindProvider === 'openai' ? apiKeyStatus.openai : apiKeyStatus.anthropic; - const connectedCount = [mindReady, apiKeyStatus.elevenlabs, apiKeyStatus.groq].filter( - Boolean, - ).length; - const ready = connectedCount === 3; + mindProvider === 'openai' + ? apiKeyStatus.openai + : mindProvider === 'ollama' + ? !!localConn + : apiKeyStatus.anthropic; + // Voice is only a requirement when the user wants spoken replies. + const voiceRequired = settings.speakReplies; + const required = [mindReady, apiKeyStatus.groq, ...(voiceRequired ? [apiKeyStatus.elevenlabs] : [])]; + const connectedCount = required.filter(Boolean).length; + const ready = connectedCount === required.length; + const total = required.length; const modelLabel = mindProvider === 'openai' @@ -31,9 +38,11 @@ export function HomeTab({ voiceState, settings, memory, onNavigate }: HomeTabPro : settings.selectedOpenAIModel === 'gpt-5-mini' ? 'GPT-5 mini' : 'GPT-4o' - : settings.selectedModel === 'claude-sonnet-4-6' - ? 'Claude Sonnet 4.6' - : 'Claude Opus 4.6'; + : mindProvider === 'ollama' + ? (localConn?.activeModelId ?? localConn?.modelIds[0] ?? 'Local model') + : settings.selectedModel === 'claude-sonnet-4-6' + ? 'Claude Sonnet 4.6' + : 'Claude Opus 4.6'; const pct = memory ? Math.round((memory.tokens / memory.tokenBudget) * 100) : 0; @@ -47,7 +56,7 @@ export function HomeTab({ voiceState, settings, memory, onNavigate }: HomeTabPro

{ready ? 'Hold the push-to-talk shortcut from anywhere and Flicky will listen, think, and reply.' - : `${connectedCount} of 3 providers connected. Add the remaining keys to start talking.`} + : `${connectedCount} of ${total} providers connected. Add the remaining keys to start talking.`}

@@ -88,7 +97,7 @@ export function HomeTab({ voiceState, settings, memory, onNavigate }: HomeTabPro ? 'Ready' : 'Setup needed'}
-
{ready ? 'all providers connected' : `${connectedCount} of 3 connected`}
+
{ready ? 'all providers connected' : `${connectedCount} of ${total} connected`}
@@ -119,11 +128,11 @@ export function HomeTab({ voiceState, settings, memory, onNavigate }: HomeTabPro

Connected providers

-
- {mindProvider === 'openai' ? 'Ai' : 'A'} +
+ {mindProvider === 'openai' ? 'Ai' : mindProvider === 'ollama' ? '⬡' : 'A'}
- {mindProvider === 'openai' ? 'OpenAI' : 'Anthropic'}{' '} + {mindProvider === 'openai' ? 'OpenAI' : mindProvider === 'ollama' ? 'Local' : 'Anthropic'}{' '} · reasoning
{mindReady ? ( @@ -139,6 +148,8 @@ export function HomeTab({ voiceState, settings, memory, onNavigate }: HomeTabPro
{apiKeyStatus.elevenlabs ? ( Connected + ) : !voiceRequired ? ( + off — text only ) : ( )} diff --git a/src/renderer/components/panel/KeyEntry.tsx b/src/renderer/components/panel/KeyEntry.tsx new file mode 100644 index 0000000..69b7a5f --- /dev/null +++ b/src/renderer/components/panel/KeyEntry.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import type { ApiKeyName } from '../../../shared/types'; + +interface KeyEntryProps { + name: ApiKeyName; + placeholder: string; + /** Fires after the key was stored (validated or force-saved). */ + onSaved?: () => void; + onCancel?: () => void; + autoFocus?: boolean; + /** Label for the primary button. */ + saveLabel?: string; +} + +type Phase = + | { kind: 'idle' } + | { kind: 'testing' } + | { kind: 'failed'; error: string }; + +/** + * Password field + "Test & save". Round-trips the key against the + * provider before persisting so a mistyped key is caught here instead + * of surfacing as a silent failure on the first push-to-talk. If the + * check fails the user can still force-save (offline, proxy, etc.). + */ +export function KeyEntry({ + name, + placeholder, + onSaved, + onCancel, + autoFocus = true, + saveLabel = 'Test & save', +}: KeyEntryProps) { + const [value, setValue] = useState(''); + const [phase, setPhase] = useState({ kind: 'idle' }); + + const commit = () => { + window.flicky.setApiKey(name, value.trim()); + setValue(''); + setPhase({ kind: 'idle' }); + onSaved?.(); + }; + + const testAndSave = async () => { + const v = value.trim(); + if (!v || phase.kind === 'testing') return; + setPhase({ kind: 'testing' }); + const res = await window.flicky.validateApiKey(name, v); + if (res.ok) { + commit(); + } else { + setPhase({ kind: 'failed', error: res.error ?? 'Validation failed.' }); + } + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') void testAndSave(); + else if (e.key === 'Escape') { + setValue(''); + setPhase({ kind: 'idle' }); + onCancel?.(); + } + }; + + const busy = phase.kind === 'testing'; + + return ( +
+
+ { + setValue(e.target.value); + if (phase.kind === 'failed') setPhase({ kind: 'idle' }); + }} + onKeyDown={onKeyDown} + spellCheck={false} + autoComplete="off" + /> + + {onCancel && ( + + )} +
+ {phase.kind === 'failed' && ( +
+ {phase.error} + +
+ )} +
+ ); +} diff --git a/src/renderer/components/panel/Onboarding.tsx b/src/renderer/components/panel/Onboarding.tsx new file mode 100644 index 0000000..ba64a48 --- /dev/null +++ b/src/renderer/components/panel/Onboarding.tsx @@ -0,0 +1,835 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { + ApiKeyName, + FlickySettings, + MindProvider, + PermissionStatus, + VoiceState, +} from '../../../shared/types'; +import { CursorIcon } from '../CursorIcon'; +import { KeyEntry } from './KeyEntry'; +import { ShortcutCapture } from './ShortcutCapture'; + +/** + * First-run setup. Each step verifies the thing it configures — a key + * is round-tripped against its provider, the shortcut has to actually + * fire, the mic has to actually produce level, and the final step runs + * a real end-to-end turn — so a user who reaches "Done" has a working + * Flicky, not just a filled-in form. + */ + +type StepId = + | 'welcome' + | 'permissions' + | 'mind' + | 'ear' + | 'voice' + | 'shortcut' + | 'mic' + | 'try' + | 'done'; + +interface StepMeta { + id: StepId; + title: string; +} + +const platform = window.flicky.platform; +const isMac = platform === 'darwin'; +const isWin = platform === 'win32'; + +const ALL_STEPS: StepMeta[] = [ + { id: 'welcome', title: 'Welcome' }, + { id: 'permissions', title: 'Permissions' }, + { id: 'mind', title: 'Mind' }, + { id: 'ear', title: 'Ear' }, + { id: 'voice', title: 'Voice' }, + { id: 'shortcut', title: 'Shortcut' }, + { id: 'mic', title: 'Mic check' }, + { id: 'try', title: 'Try it' }, + { id: 'done', title: 'Done' }, +]; + +// Linux has no OS-level mic/screen gate we can query; skip the step. +const STEPS = ALL_STEPS.filter((s) => s.id !== 'permissions' || isMac || isWin); + +interface OnboardingProps { + settings: FlickySettings; + voiceState: VoiceState; +} + +export function Onboarding({ settings, voiceState }: OnboardingProps) { + const [index, setIndex] = useState(0); + const step = STEPS[index]; + const next = useCallback(() => setIndex((i) => Math.min(STEPS.length - 1, i + 1)), []); + const back = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []); + + return ( +
+ + +
+ {step.id === 'welcome' && } + {step.id === 'permissions' && } + {step.id === 'mind' && } + {step.id === 'ear' && } + {step.id === 'voice' && } + {step.id === 'shortcut' && } + {step.id === 'mic' && } + {step.id === 'try' && ( + + )} + {step.id === 'done' && } +
+
+ ); +} + +// ── Shared bits ──────────────────────────────────────────────────────── + +function Keys({ shortcut }: { shortcut: string }) { + const keys = shortcut.split('+').filter(Boolean); + return ( + + {keys.map((k, i) => ( + + {k} + {i < keys.length - 1 && +} + + ))} + + ); +} + +interface FooterProps { + onBack?: () => void; + onNext?: () => void; + nextLabel?: string; + nextDisabled?: boolean; + nextHint?: string; + secondary?: React.ReactNode; +} + +function Footer({ onBack, onNext, nextLabel = 'Continue', nextDisabled, nextHint, secondary }: FooterProps) { + return ( +
+ {onBack ? ( + + ) : } +
+ {nextHint && {nextHint}} + {secondary} + {onNext && ( + + )} +
+
+ ); +} + +function Status({ kind, children }: { kind: 'ok' | 'warn' | 'err' | 'wait'; children: React.ReactNode }) { + return ( +
+ + {kind === 'ok' ? '✓' : kind === 'warn' ? '!' : kind === 'err' ? '×' : } + + {children} +
+ ); +} + +/** + * A key that's already in the store. Re-validated live on mount so a + * key saved last month that has since been revoked (or an account that + * ran out of credit) is caught here, not on the first real question. + */ +function SavedKey({ name, label, onReplace, extra }: { + name: ApiKeyName; + label: string; + onReplace: () => void; + extra?: React.ReactNode; +}) { + const [state, setState] = useState<{ kind: 'wait' } | { kind: 'ok' } | { kind: 'err'; error: string }>({ kind: 'wait' }); + const run = useCallback(async () => { + setState({ kind: 'wait' }); + const res = await window.flicky.validateStoredApiKey(name); + setState(res.ok ? { kind: 'ok' } : { kind: 'err', error: res.error ?? 'Validation failed.' }); + }, [name]); + useEffect(() => { void run(); }, [run]); + + return ( + <> +
{label}
+ {state.kind === 'wait' && testing the saved key against the provider…} + {state.kind === 'ok' && connected — the provider accepted this key} + {state.kind === 'err' && {state.error}} +
+ + + {extra} +
+ + ); +} + +// ── 1. Welcome ───────────────────────────────────────────────────────── + +function WelcomeStep({ onNext }: { onNext: () => void }) { + return ( + <> +
+

Hi, I'm Flicky.

+

+ A voice assistant that can see your screen. Hold a shortcut, ask a question, and a + little blue cursor flies over to point at whatever I'm talking about. +

+
    +
  • + Hold, talk, release. No wake word, no window to click into — it works from + any app. +
  • +
  • + I see what you see. A screenshot of your current screen goes with every + question so I can answer “what does this error mean?” or “where's + the export button?” +
  • +
  • + Bring your own keys. You connect your own accounts for reasoning, + transcription and voice. Keys are encrypted on this machine and only ever sent to + the provider they belong to. +
  • +
+

+ Setup takes about three minutes. Each step checks itself, so when you reach the end + everything actually works. +

+