diff --git a/app/components/home/contents/HomeContent.tsx b/app/components/home/contents/HomeContent.tsx index 026baa7..712867f 100644 --- a/app/components/home/contents/HomeContent.tsx +++ b/app/components/home/contents/HomeContent.tsx @@ -12,7 +12,7 @@ import { EXTERNAL_LINKS } from '@/lib/constants/external-links' import { useSettingsStore } from '../../../store/useSettingsStore' import { Tooltip, TooltipTrigger, TooltipContent } from '../../ui/tooltip' import { useAuthStore } from '@/app/store/useAuthStore' -import { Interaction } from '@/lib/main/sqlite/models' +import { InteractionSummary } from '@/lib/main/sqlite/models' import { TotalWordsIcon } from '../../icons/TotalWordsIcon' import { SpeedIcon } from '../../icons/SpeedIcon' import { @@ -77,7 +77,7 @@ export default function HomeContent({ const { user } = useAuthStore() const firstName = user?.name?.split(' ')[0] const platform = usePlatform() - const [interactions, setInteractions] = useState([]) + const [interactions, setInteractions] = useState([]) const [loading, setLoading] = useState(true) const [playingAudio, setPlayingAudio] = useState(null) const [audioInstances, setAudioInstances] = useState< @@ -177,7 +177,7 @@ export default function HomeContent({ // Calculate statistics from interactions const calculateStats = useCallback( - (interactions: Interaction[]): InteractionStats => { + (interactions: InteractionSummary[]): InteractionStats => { if (interactions.length === 0) { return { streakDays: 0, totalWords: 0, averageWPM: 0 } } @@ -196,11 +196,11 @@ export default function HomeContent({ [], ) - const calculateStreak = (interactions: Interaction[]): number => { + const calculateStreak = (interactions: InteractionSummary[]): number => { if (interactions.length === 0) return 0 // Group interactions by date - const dateGroups = new Map() + const dateGroups = new Map() interactions.forEach(interaction => { const date = new Date(interaction.created_at).toDateString() if (!dateGroups.has(date)) { @@ -233,7 +233,7 @@ export default function HomeContent({ return streak } - const calculateTotalWords = (interactions: Interaction[]): number => { + const calculateTotalWords = (interactions: InteractionSummary[]): number => { return interactions.reduce((total, interaction) => { const transcript = interaction.asr_output?.transcript?.trim() if (transcript) { @@ -245,7 +245,7 @@ export default function HomeContent({ }, 0) } - const calculateAverageWPM = (interactions: Interaction[]): number => { + const calculateAverageWPM = (interactions: InteractionSummary[]): number => { const validInteractions = interactions.filter( interaction => interaction.asr_output?.transcript?.trim() && interaction.duration_ms, @@ -292,7 +292,7 @@ export default function HomeContent({ // Sort by creation date (newest first) - remove the slice(0, 10) to show all interactions const sortedInteractions = allInteractions.sort( - (a: Interaction, b: Interaction) => + (a: InteractionSummary, b: InteractionSummary) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), ) setInteractions(sortedInteractions) @@ -372,8 +372,8 @@ export default function HomeContent({ .toUpperCase() } - const groupInteractionsByDate = (interactions: Interaction[]) => { - const groups: { [key: string]: Interaction[] } = {} + const groupInteractionsByDate = (interactions: InteractionSummary[]) => { + const groups: { [key: string]: InteractionSummary[] } = {} interactions.forEach(interaction => { const dateKey = formatDate(interaction.created_at) @@ -386,7 +386,7 @@ export default function HomeContent({ return groups } - const getDisplayText = (interaction: Interaction) => { + const getDisplayText = (interaction: InteractionSummary) => { // Check for errors first if (interaction.asr_output?.error) { // Prefer precise error code mapping when available @@ -435,7 +435,7 @@ export default function HomeContent({ } } - const handleAudioPlayStop = async (interaction: Interaction) => { + const handleAudioPlayStop = async (interaction: InteractionSummary) => { try { // If this interaction is currently playing, stop it if (playingAudio === interaction.id) { @@ -463,7 +463,7 @@ export default function HomeContent({ } } - if (!interaction.raw_audio) { + if (!interaction.has_audio) { console.warn('No audio data available for this interaction') return } @@ -475,7 +475,17 @@ export default function HomeContent({ let audio = audioInstances.get(interaction.id) if (!audio) { - const pcmData = new Uint8Array(interaction.raw_audio) + // The list rows don't carry audio (it can be megabytes per row); + // fetch the full record on demand. + const fullInteraction = await window.api.interactions.getById( + interaction.id, + ) + if (!fullInteraction?.raw_audio) { + console.warn('No audio data available for this interaction') + setPlayingAudio(null) + return + } + const pcmData = new Uint8Array(fullInteraction.raw_audio) try { // Convert raw PCM (mono, typically 16 kHz) to 48 kHz stereo WAV for smoother playback const wavBuffer = createStereo48kWavFromMonoPCM( @@ -546,14 +556,23 @@ export default function HomeContent({ } } - const handleAudioDownload = async (interaction: Interaction) => { + const handleAudioDownload = async (interaction: InteractionSummary) => { try { - if (!interaction.raw_audio) { + if (!interaction.has_audio) { + console.warn('No audio data available for download') + return + } + + // Fetch the full record on demand — list rows don't carry audio. + const fullInteraction = await window.api.interactions.getById( + interaction.id, + ) + if (!fullInteraction?.raw_audio) { console.warn('No audio data available for download') return } - const pcmData = new Uint8Array(interaction.raw_audio) + const pcmData = new Uint8Array(fullInteraction.raw_audio) // Convert raw PCM to WAV format const wavBuffer = createStereo48kWavFromMonoPCM( pcmData, @@ -787,7 +806,7 @@ export default function HomeContent({ )} {/* Download button */} - {interaction.raw_audio && ( + {interaction.has_audio && ( handleAudioPlayStop(interaction)} - disabled={!interaction.raw_audio} + disabled={!interaction.has_audio} > {playingAudio === interaction.id ? ( @@ -841,7 +860,7 @@ export default function HomeContent({ - {!interaction.raw_audio + {!interaction.has_audio ? 'No audio available' : playingAudio === interaction.id ? 'Stop' diff --git a/app/components/home/contents/settings/AudioSettingsContent.tsx b/app/components/home/contents/settings/AudioSettingsContent.tsx index db3348b..c42e505 100644 --- a/app/components/home/contents/settings/AudioSettingsContent.tsx +++ b/app/components/home/contents/settings/AudioSettingsContent.tsx @@ -1,6 +1,7 @@ import { Switch } from '@/app/components/ui/switch' import { MicrophoneSelector } from '@/app/components/ui/microphone-selector' import { useSettingsStore } from '@/app/store/useSettingsStore' +import { usePlatform } from '@/app/hooks/usePlatform' export default function AudioSettingsContent() { const { @@ -12,6 +13,7 @@ export default function AudioSettingsContent() { // setInteractionSounds, setMuteAudioWhenDictating, } = useSettingsStore() + const platform = usePlatform() return (
@@ -30,20 +32,24 @@ export default function AudioSettingsContent() { />
*/} -
-
-
- Mute audio when dictating -
-
- Automatically silence other active audio during dictation. + {/* System-audio mute is implemented via osascript (macOS only); + hide the toggle elsewhere instead of showing a silent no-op. */} + {platform === 'darwin' && ( +
+
+
+ Mute audio when dictating +
+
+ Automatically silence other active audio during dictation. +
+
- -
+ )}
@@ -51,7 +57,8 @@ export default function AudioSettingsContent() { Select default microphone
- Select the microphone Scriba will use by default for audio input. + Select the microphone Scriba will use by default for audio + input.
{ + appIsQuitting = true +}) + export function getPillWindow(): BrowserWindow | null { return pillWindow } @@ -61,13 +68,19 @@ export function createAppWindow(): BrowserWindow { }, ) + // On Windows, closing the dashboard hides it to the tray — the app (pill, + // hotkeys, dictation) keeps running, mirroring the macOS behavior. A real + // quit is still available from the tray menu. + mainWindow.on('close', event => { + if (process.platform === 'win32' && !appIsQuitting) { + event.preventDefault() + mainWindow?.hide() + } + }) + // Clean up the reference when the window is closed. mainWindow.on('closed', () => { mainWindow = null - // On Windows, closing the main window should quit the entire app - if (process.platform === 'win32') { - app.quit() - } }) // HMR for renderer base on electron-vite cli. diff --git a/lib/main/scribaSessionManager.test.ts b/lib/main/scribaSessionManager.test.ts index c6eec3c..f141170 100644 --- a/lib/main/scribaSessionManager.test.ts +++ b/lib/main/scribaSessionManager.test.ts @@ -120,7 +120,9 @@ mock.module('./grammar/GrammarRulesService', () => ({ const mockGetAdvancedSettings = mock(() => ({ grammarServiceEnabled: false, })) -const mockGetSnippets = mock((): Array<{ trigger: string; expansion: string }> => []) +const mockGetSnippets = mock( + (): Array<{ trigger: string; expansion: string }> => [], +) mock.module('./store', () => ({ getAdvancedSettings: mockGetAdvancedSettings, getSnippets: mockGetSnippets, @@ -151,7 +153,9 @@ describe('scribaSessionManager', () => { Object.values(mockRecordingStateNotifier).forEach(mockFn => mockFn.mockClear(), ) - Object.values(mockScribaStreamController).forEach(mockFn => mockFn.mockClear()) + Object.values(mockScribaStreamController).forEach(mockFn => + mockFn.mockClear(), + ) Object.values(mockTextInserter).forEach(mockFn => mockFn.mockClear()) Object.values(mockInteractionManager).forEach(mockFn => mockFn.mockClear()) Object.values(mockContextGrabber).forEach(mockFn => mockFn.mockClear()) @@ -224,6 +228,52 @@ describe('scribaSessionManager', () => { ).toHaveBeenCalledTimes(1) }) + test('startSession waits for an in-flight stop teardown (stop→start same tick)', async () => { + const { ScribaSessionManager } = await import('./scribaSessionManager') + const session = new ScribaSessionManager() + + // Session A is running. + await session.startSession(ScribaMode.TRANSCRIBE) + + const order: string[] = [] + let releaseTeardown: () => void = () => {} + mockVoiceInputService.stopAudioRecording.mockImplementation( + () => + new Promise(resolve => { + releaseTeardown = () => { + order.push('teardown-finished') + resolve() + } + }), + ) + mockScribaStreamController.initialize.mockImplementation(() => { + order.push('initialize-B') + return Promise.resolve(true) + }) + + // Same-tick stop + start, as when a pending tap is interrupted by a new + // shortcut: B must not initialize while A is still tearing down. + const completePromise = session.completeSession() + const startPromise = session.startSession(ScribaMode.EDIT) + + // Flush microtasks: B should still be parked behind A's teardown. + for (let i = 0; i < 10; i++) await Promise.resolve() + expect(order).not.toContain('initialize-B') + + releaseTeardown() + await Promise.all([completePromise, startPromise]) + + expect(order.indexOf('teardown-finished')).toBeLessThan( + order.indexOf('initialize-B'), + ) + + // Restore the default implementation for subsequent tests (mockClear in + // beforeEach does not undo mockImplementation). + mockVoiceInputService.stopAudioRecording.mockImplementation(() => + Promise.resolve(), + ) + }) + test('should fetch and send context in background', async () => { const { ScribaSessionManager } = await import('./scribaSessionManager') const session = new ScribaSessionManager() @@ -294,7 +344,9 @@ describe('scribaSessionManager', () => { await session.setMode(ScribaMode.EDIT) - expect(mockScribaStreamController.setMode).toHaveBeenCalledWith(ScribaMode.EDIT) + expect(mockScribaStreamController.setMode).toHaveBeenCalledWith( + ScribaMode.EDIT, + ) expect( mockRecordingStateNotifier.notifyRecordingStarted, ).toHaveBeenCalledWith(ScribaMode.EDIT) @@ -327,7 +379,9 @@ describe('scribaSessionManager', () => { await modeP // Once streaming, the EDIT switch goes through. - expect(mockScribaStreamController.setMode).toHaveBeenCalledWith(ScribaMode.EDIT) + expect(mockScribaStreamController.setMode).toHaveBeenCalledWith( + ScribaMode.EDIT, + ) }) test('re-fetches context when switching into EDIT mode mid-session', async () => { @@ -393,7 +447,9 @@ describe('scribaSessionManager', () => { await session.cancelSession() await completing - expect(mockScribaStreamController.cancelTranscription).not.toHaveBeenCalled() + expect( + mockScribaStreamController.cancelTranscription, + ).not.toHaveBeenCalled() expect(mockTextInserter.insertText).toHaveBeenCalledWith(mockTranscript) }) @@ -788,7 +844,9 @@ describe('scribaSessionManager', () => { // The cancel observed the in-flight session and aborted it rather than // no-opping and leaving the recording running. - expect(mockScribaStreamController.cancelTranscription).toHaveBeenCalledTimes(1) + expect( + mockScribaStreamController.cancelTranscription, + ).toHaveBeenCalledTimes(1) expect(mockVoiceInputService.stopAudioRecording).toHaveBeenCalled() }) }) diff --git a/lib/main/scribaSessionManager.ts b/lib/main/scribaSessionManager.ts index 2435743..df7dfcd 100644 --- a/lib/main/scribaSessionManager.ts +++ b/lib/main/scribaSessionManager.ts @@ -26,6 +26,32 @@ export class ScribaSessionManager { // await this so a fast key-up can't tear down a session whose start hasn't yet // assigned `streamResponsePromise` (which would otherwise orphan the recording). private startPromise: Promise | null = null + // Resolves when an in-flight stop has finished tearing down the shared + // controller/recorder (NOT when the whole stop — which also waits on the + // server response — is done). startSession waits on this, so a stop and a + // start issued in the same tick (e.g. a pending tap interrupted by a new + // shortcut) can't interleave initialize() with the previous teardown. + private stopTeardownPromise: Promise | null = null + + /** + * Marks a stop's teardown phase as in flight. Must be called synchronously at + * the top of cancel/complete so a startSession issued in the same tick sees + * it. Returns a release function that is idempotent and only clears the + * shared slot if it still belongs to this stop. + */ + private beginStopTeardown(): () => void { + let release: () => void = () => {} + const teardownPromise = new Promise(resolve => { + release = resolve + }) + this.stopTeardownPromise = teardownPromise + return () => { + if (this.stopTeardownPromise === teardownPromise) { + this.stopTeardownPromise = null + } + release() + } + } public async startSession(mode: ScribaMode) { // Re-entrancy guard: ignore overlapping start requests (a rapid hotkey @@ -48,6 +74,13 @@ export class ScribaSessionManager { releaseStart = resolve }) try { + // If the previous session's stop is still tearing down the shared + // controller/recorder, wait for it — otherwise initialize() would reset + // state the teardown is still reading (e.g. the audio duration check). + if (this.stopTeardownPromise) { + await this.stopTeardownPromise + } + console.log('[scribaSessionManager] Starting session with mode:', mode) // Reuse existing global interaction ID if present, otherwise create a new one @@ -168,50 +201,68 @@ export class ScribaSessionManager { } public async cancelSession() { - // Let any in-flight start finish first, so we don't capture a null - // streamResponsePromise and silently leave the recording running. - await this.waitForStartToSettle() - - // Capture the promise in a local variable immediately so new sessions can start - const responsePromise = this.streamResponsePromise - this.streamResponsePromise = null + const finishTeardown = this.beginStopTeardown() + try { + // Let any in-flight start finish first, so we don't capture a null + // streamResponsePromise and silently leave the recording running. + await this.waitForStartToSettle() + + // Capture the promise in a local variable immediately so new sessions can start + const responsePromise = this.streamResponsePromise + this.streamResponsePromise = null + + // If another stop (a completeSession, or a duplicate cancel) already took + // ownership of this session, do nothing. Re-running teardown here could + // cancel an in-flight transcription that completeSession is about to handle + // (dropping a valid transcript) or double-stop the recorder. + if (!responsePromise) { + console.log( + '[scribaSessionManager] cancelSession ignored: no active session to cancel', + ) + return + } - // If another stop (a completeSession, or a duplicate cancel) already took - // ownership of this session, do nothing. Re-running teardown here could - // cancel an in-flight transcription that completeSession is about to handle - // (dropping a valid transcript) or double-stop the recorder. - if (!responsePromise) { - console.log( - '[scribaSessionManager] cancelSession ignored: no active session to cancel', - ) - return - } + // Clear timing for the interaction on cancel + timingCollector.clearInteraction() - // Clear timing for the interaction on cancel - timingCollector.clearInteraction() + // Cancel the transcription (will not create interaction) + scribaStreamController.cancelTranscription() + interactionManager.clearCurrentInteraction() - // Cancel the transcription (will not create interaction) - scribaStreamController.cancelTranscription() - interactionManager.clearCurrentInteraction() + // Stop audio recording + await voiceInputService.stopAudioRecording() - // Stop audio recording - await voiceInputService.stopAudioRecording() + // Update UI state + recordingStateNotifier.notifyRecordingStopped() - // Update UI state - recordingStateNotifier.notifyRecordingStopped() + // Shared resources are released; only the stream rejection remains. + finishTeardown() - // Wait for the stream promise to reject with cancellation error - if (responsePromise) { + // Wait for the stream promise to reject with cancellation error try { await responsePromise } catch (error) { // Expected cancellation error, log and ignore - console.log('[scribaSessionManager] Stream cancelled as expected:', error) + console.log( + '[scribaSessionManager] Stream cancelled as expected:', + error, + ) } + } finally { + finishTeardown() } } public async completeSession() { + const finishTeardown = this.beginStopTeardown() + try { + await this.completeSessionInner(finishTeardown) + } finally { + finishTeardown() + } + } + + private async completeSessionInner(finishTeardown: () => void) { // Let any in-flight start finish first, so we observe the streamResponsePromise // it assigns instead of capturing null and dropping the dictation. await this.waitForStartToSettle() @@ -245,18 +296,17 @@ export class ScribaSessionManager { ) scribaStreamController.cancelTranscription() recordingStateNotifier.notifyRecordingStopped() + finishTeardown() // Wait for the stream promise to reject with cancellation error - if (responsePromise) { - try { - await responsePromise - } catch (error) { - // Expected cancellation error, log and ignore - console.log( - '[scribaSessionManager] Stream cancelled as expected:', - error, - ) - } + try { + await responsePromise + } catch (error) { + // Expected cancellation error, log and ignore + console.log( + '[scribaSessionManager] Stream cancelled as expected:', + error, + ) } return } @@ -270,6 +320,10 @@ export class ScribaSessionManager { // Notify processing started recordingStateNotifier.notifyProcessingStarted() + // Shared recorder/controller teardown is done; the remaining work only + // waits on the server response, which may overlap a new session. + finishTeardown() + // Wait for the stream response and handle it if (responsePromise) { console.log( @@ -315,7 +369,9 @@ export class ScribaSessionManager { recordingStateNotifier.notifyProcessingStopped() } } else { - console.warn('[scribaSessionManager] No stream response promise to wait for') + console.warn( + '[scribaSessionManager] No stream response promise to wait for', + ) recordingStateNotifier.notifyProcessingStopped() } } @@ -373,7 +429,9 @@ export class ScribaSessionManager { if (!inserted) { try { clipboard.writeText(textToInsert) - recordingStateNotifier.notifyError('Insert failed — copied to clipboard') + recordingStateNotifier.notifyError( + 'Insert failed — copied to clipboard', + ) } catch (clipboardError) { log.error( '[scribaSessionManager] Clipboard fallback failed:', @@ -419,7 +477,10 @@ export class ScribaSessionManager { } /** Maps a server-returned protobuf ClientError to a short user-facing message. */ - private friendlyResponseError(error: { code?: string; message?: string }): string { + private friendlyResponseError(error: { + code?: string + message?: string + }): string { switch (error?.code) { case 'CLIENT_NO_SPEECH_DETECTED': return 'No speech detected' diff --git a/lib/main/sqlite/models.ts b/lib/main/sqlite/models.ts index e0f3097..cb47a6d 100644 --- a/lib/main/sqlite/models.ts +++ b/lib/main/sqlite/models.ts @@ -13,6 +13,13 @@ export interface Interaction { deleted_at: string | null } +// List row without the raw_audio BLOB (which can be megabytes per dictation — +// shipping it for every row on every list refresh is wasteful). `has_audio` +// says whether the audio can be fetched lazily via findById. +export type InteractionSummary = Omit & { + has_audio: boolean +} + export interface Note { id: string user_id: string diff --git a/lib/main/sqlite/repo.ts b/lib/main/sqlite/repo.ts index 4aef78f..dffdf6d 100644 --- a/lib/main/sqlite/repo.ts +++ b/lib/main/sqlite/repo.ts @@ -1,5 +1,11 @@ import { run, get, all } from './utils' -import type { Interaction, Note, DictionaryItem, UserMetadata } from './models' +import type { + Interaction, + InteractionSummary, + Note, + DictionaryItem, + UserMetadata, +} from './models' import { PaidStatus } from './models' import { v4 as uuidv4 } from 'uuid' @@ -122,14 +128,25 @@ export class InteractionsTable { return row ? parseInteractionJsonFields(row) : undefined } - static async findAll(user_id?: string): Promise { + // Deliberately excludes the raw_audio BLOB: the list is refetched after + // every dictation, and audio is only needed on demand (findById). + static async findAllSummaries( + user_id?: string, + ): Promise { + const columns = + 'id, user_id, title, asr_output, llm_output, raw_audio_id, duration_ms, sample_rate, created_at, updated_at, deleted_at, raw_audio IS NOT NULL AS has_audio' const query = user_id - ? 'SELECT * FROM interactions WHERE user_id = ? AND deleted_at IS NULL ORDER BY created_at DESC' - : 'SELECT * FROM interactions WHERE user_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC' + ? `SELECT ${columns} FROM interactions WHERE user_id = ? AND deleted_at IS NULL ORDER BY created_at DESC` + : `SELECT ${columns} FROM interactions WHERE user_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC` const params = user_id ? [user_id] : [] - const rows = await all(query, params) - - return rows.map(parseInteractionJsonFields) + const rows = await all(query, params) + + return rows.map(row => { + row.asr_output = parseJsonField(row.asr_output) + row.llm_output = parseJsonField(row.llm_output) + row.has_audio = Boolean(row.has_audio) // SQLite returns 0/1 + return row + }) } static async softDelete(id: string): Promise { diff --git a/lib/media/audio.test.ts b/lib/media/audio.test.ts index d0f3207..e090669 100644 --- a/lib/media/audio.test.ts +++ b/lib/media/audio.test.ts @@ -389,6 +389,9 @@ describe('AudioRecorderService', () => { describe('Volume Calculation Business Logic', () => { beforeEach(() => { audioRecorderService.initialize() + // Volume emissions are throttled (~15Hz); starting a recording resets the + // throttle so each test's first chunk emits immediately. + audioRecorderService.startRecording('test-device') }) test('should calculate volume correctly for various inputs', async () => { @@ -411,6 +414,9 @@ describe('AudioRecorderService', () => { expect(volume!).toBe(0) + // Reset the volume throttle so the next chunk emits immediately. + audioRecorderService.startRecording('test-device') + // Test maximum volume audio const maxAudio = Buffer.alloc(1024) for (let i = 0; i < maxAudio.length; i += 2) { diff --git a/lib/media/audio.ts b/lib/media/audio.ts index 23a9941..bf47718 100644 --- a/lib/media/audio.ts +++ b/lib/media/audio.ts @@ -7,6 +7,9 @@ import { getNativeBinaryPath } from './native-interface' const MSG_TYPE_JSON = 1 const MSG_TYPE_AUDIO = 2 +// Emit volume-meter updates at most this often (~15Hz is plenty for the UI). +const VOLUME_EMIT_INTERVAL_MS = 66 + interface Message { type: 'json' | 'audio' payload: Buffer @@ -20,9 +23,13 @@ class AudioRecorderService extends EventEmitter { reject: (reason?: any) => void } | null = null #drainPromise: { + promise: Promise resolve: () => void reject: (reason?: any) => void } | null = null + // Volume meter updates drive ~30-50 IPC messages/sec if emitted per chunk; + // the UI meters only need ~15Hz, so skip the RMS pass entirely in between. + #lastVolumeEmitAt = 0 constructor() { super() @@ -85,6 +92,8 @@ class AudioRecorderService extends EventEmitter { * Sends a command to start recording from a specific device. */ public startRecording(deviceName: string): void { + // Fresh recording → emit the first meter update immediately. + this.#lastVolumeEmitAt = 0 this.#sendCommand({ command: 'start', device_name: deviceName }) console.log(`[AudioService] Recording started on device: ${deviceName}`) } @@ -228,46 +237,55 @@ class AudioRecorderService extends EventEmitter { } } } else if (message.type === 'audio') { - const volume = this.#calculateVolume(message.payload) - - this.emit('volume-update', volume) + const now = Date.now() + if (now - this.#lastVolumeEmitAt >= VOLUME_EMIT_INTERVAL_MS) { + this.#lastVolumeEmitAt = now + this.emit('volume-update', this.#calculateVolume(message.payload)) + } this.emit('audio-chunk', message.payload) } } public awaitDrainComplete(timeoutMs: number = 500): Promise { + // A drain is already pending (overlapping stops): share its outcome. + // Replacing it would leave the first caller's promise unsettled forever, + // hanging that stop flow. if (this.#drainPromise) { - return new Promise((resolve, reject) => { - this.once('error', reject) - this.#drainPromise = { resolve, reject } - }) + return this.#drainPromise.promise } - return new Promise((resolve, reject) => { - let settled = false - const onTimeout = setTimeout(() => { + + let settled = false + let resolveOut: () => void = () => {} + let rejectOut: (reason?: any) => void = () => {} + const promise = new Promise((resolve, reject) => { + resolveOut = resolve + rejectOut = reject + }) + const onTimeout = setTimeout(() => { + if (!settled) { + settled = true + this.#drainPromise = null + resolveOut() // fallback: do not hang the stop flow + } + }, timeoutMs) + this.#drainPromise = { + promise, + resolve: () => { if (!settled) { settled = true - this.#drainPromise = null - resolve() // fallback: do not hang the stop flow + clearTimeout(onTimeout) + resolveOut() } - }, timeoutMs) - this.#drainPromise = { - resolve: () => { - if (!settled) { - settled = true - clearTimeout(onTimeout) - resolve() - } - }, - reject: (err?: any) => { - if (!settled) { - settled = true - clearTimeout(onTimeout) - reject(err) - } - }, - } - }) + }, + reject: (err?: any) => { + if (!settled) { + settled = true + clearTimeout(onTimeout) + rejectOut(err) + } + }, + } + return promise } #sendCommand(command: object): void { diff --git a/lib/media/keyboard.test.ts b/lib/media/keyboard.test.ts index 6842bd2..5a0ef17 100644 --- a/lib/media/keyboard.test.ts +++ b/lib/media/keyboard.test.ts @@ -83,6 +83,17 @@ mock.module('electron', () => ({ BrowserWindow: mockBrowserWindow, })) +// Mock WebContents factory for setKeyEventForwarding() subscribers. +let nextMockWebContentsId = 1 +function createMockWebContents(destroyed = false) { + return { + id: nextMockWebContentsId++, + send: mock(), + isDestroyed: mock(() => destroyed), + once: mock(), + } as any +} + const mockAudioRecorderService = { stopRecording: mock(), } @@ -227,8 +238,12 @@ describe('Keyboard Module', () => { describe('Message Parsing Business Logic', () => { test('should handle fragmented JSON from stdout', async () => { - const { startKeyListener } = await import('./keyboard') + const { startKeyListener, setKeyEventForwarding } = await import( + './keyboard' + ) + const subscriber = createMockWebContents() + setKeyEventForwarding(subscriber, true) startKeyListener() const keyEvent = { @@ -247,15 +262,16 @@ describe('Keyboard Module', () => { mockChildProcess.stdout.emit('data', Buffer.from(fragment2)) // Should still process the complete event - expect(mockWindow.webContents.send).toHaveBeenCalledWith( - 'key-event', - keyEvent, - ) + expect(subscriber.send).toHaveBeenCalledWith('key-event', keyEvent) }) test('should handle multiple events in single data chunk', async () => { - const { startKeyListener } = await import('./keyboard') + const { startKeyListener, setKeyEventForwarding } = await import( + './keyboard' + ) + const subscriber = createMockWebContents() + setKeyEventForwarding(subscriber, true) startKeyListener() const event1 = { @@ -276,15 +292,9 @@ describe('Keyboard Module', () => { mockChildProcess.stdout.emit('data', Buffer.from(combinedData)) // Should process both events - expect(mockWindow.webContents.send).toHaveBeenCalledTimes(2) - expect(mockWindow.webContents.send).toHaveBeenCalledWith( - 'key-event', - event1, - ) - expect(mockWindow.webContents.send).toHaveBeenCalledWith( - 'key-event', - event2, - ) + expect(subscriber.send).toHaveBeenCalledTimes(2) + expect(subscriber.send).toHaveBeenCalledWith('key-event', event1) + expect(subscriber.send).toHaveBeenCalledWith('key-event', event2) }) test('should handle malformed JSON gracefully', async () => { @@ -303,89 +313,89 @@ describe('Keyboard Module', () => { }) }) - describe('Window Event Broadcasting Business Logic', () => { - test('should broadcast events to all non-destroyed windows', async () => { - const { startKeyListener } = await import('./keyboard') + describe('Key-Event Forwarding Business Logic', () => { + const keyEvent = { + type: 'keydown', + key: 'KeyA', + timestamp: '2024-01-01T00:00:00.000Z', + raw_code: 65, + } - // Create multiple windows - const window1 = { - webContents: { - send: mock(), - isDestroyed: mock(() => false), - }, - } - const window2 = { - webContents: { - send: mock(), - isDestroyed: mock(() => false), - }, - } - mockBrowserWindow.getAllWindows.mockReturnValue([window1, window2]) + test('should forward events only to subscribed webContents', async () => { + const { startKeyListener, setKeyEventForwarding } = await import( + './keyboard' + ) - startKeyListener() + const subscriber = createMockWebContents() + const nonSubscriber = createMockWebContents() + setKeyEventForwarding(subscriber, true) - const keyEvent = { - type: 'keydown', - key: 'KeyA', - timestamp: '2024-01-01T00:00:00.000Z', - raw_code: 65, - } + startKeyListener() mockChildProcess.stdout.emit( 'data', Buffer.from(JSON.stringify(keyEvent) + '\n'), ) - // Should send to both windows - expect(window1.webContents.send).toHaveBeenCalledWith( - 'key-event', - keyEvent, + expect(subscriber.send).toHaveBeenCalledWith('key-event', keyEvent) + expect(nonSubscriber.send).not.toHaveBeenCalled() + }) + + test('should stop forwarding after unsubscribe', async () => { + const { startKeyListener, setKeyEventForwarding } = await import( + './keyboard' ) - expect(window2.webContents.send).toHaveBeenCalledWith( - 'key-event', - keyEvent, + + const subscriber = createMockWebContents() + setKeyEventForwarding(subscriber, true) + setKeyEventForwarding(subscriber, false) + + startKeyListener() + mockChildProcess.stdout.emit( + 'data', + Buffer.from(JSON.stringify(keyEvent) + '\n'), ) + + expect(subscriber.send).not.toHaveBeenCalled() }) - test('should skip destroyed windows when broadcasting events', async () => { - const { startKeyListener } = await import('./keyboard') + test('should keep forwarding while another subscription from the same webContents is active', async () => { + const { startKeyListener, setKeyEventForwarding } = await import( + './keyboard' + ) - // Create windows with one destroyed - const window1 = { - webContents: { - send: mock(), - isDestroyed: mock(() => false), - }, - } - const destroyedWindow = { - webContents: { - send: mock(), - isDestroyed: mock(() => true), - }, - } - mockBrowserWindow.getAllWindows.mockReturnValue([ - window1, - destroyedWindow, - ]) + // Two editors in the same window subscribe; one unsubscribes. + const subscriber = createMockWebContents() + setKeyEventForwarding(subscriber, true) + setKeyEventForwarding(subscriber, true) + setKeyEventForwarding(subscriber, false) startKeyListener() - - const keyEvent = { - type: 'keydown', - key: 'KeyA', - timestamp: '2024-01-01T00:00:00.000Z', - raw_code: 65, - } mockChildProcess.stdout.emit( 'data', Buffer.from(JSON.stringify(keyEvent) + '\n'), ) - // Should only send to non-destroyed window - expect(window1.webContents.send).toHaveBeenCalledWith( - 'key-event', - keyEvent, + expect(subscriber.send).toHaveBeenCalledWith('key-event', keyEvent) + }) + + test('should skip destroyed webContents when forwarding events', async () => { + const { startKeyListener, setKeyEventForwarding } = await import( + './keyboard' + ) + + const subscriber = createMockWebContents() + const destroyedSubscriber = createMockWebContents(true) + setKeyEventForwarding(subscriber, true) + setKeyEventForwarding(destroyedSubscriber, true) + + startKeyListener() + mockChildProcess.stdout.emit( + 'data', + Buffer.from(JSON.stringify(keyEvent) + '\n'), ) - expect(destroyedWindow.webContents.send).not.toHaveBeenCalled() + + expect(subscriber.send).toHaveBeenCalledWith('key-event', keyEvent) + expect(destroyedSubscriber.send).not.toHaveBeenCalled() }) }) @@ -582,53 +592,37 @@ describe('Keyboard Module', () => { Buffer.from(JSON.stringify(otherKey) + '\n'), ) - expect(mockScribaSessionManager.completeSession).toHaveBeenCalled() + // Disabling dictation aborts the in-flight recording (no text insertion). + expect(mockScribaSessionManager.cancelSession).toHaveBeenCalled() + expect(mockScribaSessionManager.completeSession).not.toHaveBeenCalled() expect(console.info).toHaveBeenCalledWith( - 'Shortcut DEACTIVATED, stopping recording...', + 'Shortcut DEACTIVATED, cancelling recording...', ) }) test('should ignore fast fn key events', async () => { - // Create fresh mock objects for this test to avoid isolation issues - const freshMockWindow = { - webContents: { - send: mock(), - isDestroyed: mock(() => false), - }, - } + const { startKeyListener, setKeyEventForwarding } = await import( + './keyboard' + ) - const freshMockBrowserWindow = { - getAllWindows: mock(() => [freshMockWindow]), - } + const subscriber = createMockWebContents() + setKeyEventForwarding(subscriber, true) + startKeyListener() - // Temporarily override the electron mock for this test - const originalGetAllWindows = mockBrowserWindow.getAllWindows - mockBrowserWindow.getAllWindows = freshMockBrowserWindow.getAllWindows - - try { - const { startKeyListener } = await import('./keyboard') - startKeyListener() - - const fastFnEvent = { - type: 'keydown', - key: 'Unknown(179)', - timestamp: '2024-01-01T00:00:00.000Z', - raw_code: 179, - } - mockChildProcess.stdout.emit( - 'data', - Buffer.from(JSON.stringify(fastFnEvent) + '\n'), - ) - - // Should still forward to windows but not affect shortcut state - expect(freshMockWindow.webContents.send).toHaveBeenCalledWith( - 'key-event', - fastFnEvent, - ) - } finally { - // Restore original mock - mockBrowserWindow.getAllWindows = originalGetAllWindows + const fastFnEvent = { + type: 'keydown', + key: 'Unknown(179)', + timestamp: '2024-01-01T00:00:00.000Z', + raw_code: 179, } + mockChildProcess.stdout.emit( + 'data', + Buffer.from(JSON.stringify(fastFnEvent) + '\n'), + ) + + // Should still forward to subscribers but not affect shortcut state + expect(subscriber.send).toHaveBeenCalledWith('key-event', fastFnEvent) + expect(mockScribaSessionManager.startSession).not.toHaveBeenCalled() }) test('should handle complex multi-key shortcuts', async () => { @@ -1736,7 +1730,11 @@ describe('Keyboard Module', () => { isShortcutGloballyEnabled: true, handsFreeEnabled: true, keyboardShortcuts: [ - { id: 'hf-test', keys: ['command', 'space'], mode: ScribaMode.TRANSCRIBE }, + { + id: 'hf-test', + keys: ['command', 'space'], + mode: ScribaMode.TRANSCRIBE, + }, ], } const emit = (event: object) => @@ -1827,7 +1825,11 @@ describe('Keyboard Module', () => { isShortcutGloballyEnabled: true, handsFreeEnabled: true, keyboardShortcuts: [ - { id: 'hf-a', keys: ['command', 'space'], mode: ScribaMode.TRANSCRIBE }, + { + id: 'hf-a', + keys: ['command', 'space'], + mode: ScribaMode.TRANSCRIBE, + }, { id: 'hf-b', keys: ['shift-left'], mode: ScribaMode.EDIT }, ], }) @@ -1848,5 +1850,118 @@ describe('Keyboard Module', () => { ScribaMode.EDIT, ) }) + + test('an accidental double-tap on stop does not start a spurious session', async () => { + mockMainStore.get.mockReturnValue(handsFreeSettings) + const { startKeyListener } = await import('./keyboard') + startKeyListener() + + // Enter hands-free, then stop it with a tap. + tap() + tap() + down('MetaLeft') + down('Space') + expect(mockScribaSessionManager.completeSession).toHaveBeenCalledTimes(1) + up('MetaLeft') + up('Space') + + // An immediate second tap (accidental double-tap on "stop") must be + // swallowed instead of starting a near-empty recording. + clock.tick(100) + tap() + expect(mockScribaSessionManager.startSession).toHaveBeenCalledTimes(1) + + // After the window has passed, dictation works again. + clock.tick(400) + tap() + expect(mockScribaSessionManager.startSession).toHaveBeenCalledTimes(2) + }) + + test('toggling hands-free off mid hands-free session completes it on the next key event', async () => { + let handsFreeEnabled = true + mockMainStore.get.mockImplementation(() => ({ + ...handsFreeSettings, + handsFreeEnabled, + })) + const { startKeyListener } = await import('./keyboard') + startKeyListener() + + // Enter hands-free (keys released, session recording). + tap() + tap() + expect(mockScribaSessionManager.completeSession).not.toHaveBeenCalled() + + handsFreeEnabled = false + + // Any key event reconciles the orphaned hands-free session. + down('KeyA') + expect(mockScribaSessionManager.completeSession).toHaveBeenCalledTimes(1) + up('KeyA') + + // The shortcut now behaves as plain push-to-talk: press starts a session, + // it is NOT misread as a "stop hands-free" tap. + down('MetaLeft') + down('Space') + expect(mockScribaSessionManager.startSession).toHaveBeenCalledTimes(2) + clock.tick(300) // hold + up('MetaLeft') + up('Space') + expect(mockScribaSessionManager.completeSession).toHaveBeenCalledTimes(2) + }) + + test('toggling hands-free off with a pending tap settles it before the next session', async () => { + let handsFreeEnabled = true + mockMainStore.get.mockImplementation(() => ({ + ...handsFreeSettings, + handsFreeEnabled, + })) + const { startKeyListener } = await import('./keyboard') + startKeyListener() + + // Quick tap → completion pending on the double-tap timer. + tap() + expect(mockScribaSessionManager.startSession).toHaveBeenCalledTimes(1) + expect(mockScribaSessionManager.completeSession).not.toHaveBeenCalled() + + handsFreeEnabled = false + + // Pressing the shortcut again completes the pending tap and starts a + // fresh push-to-talk session — never a hands-free upgrade. + down('MetaLeft') + down('Space') + expect(mockScribaSessionManager.completeSession).toHaveBeenCalledTimes(1) + expect(mockScribaSessionManager.startSession).toHaveBeenCalledTimes(2) + + // The old pending timer must be dead: advancing time completes nothing new. + clock.tick(400) + expect(mockScribaSessionManager.completeSession).toHaveBeenCalledTimes(1) + }) + + test('Escape during the pending-tap window cancels instead of completing', async () => { + mockMainStore.get.mockReturnValue(handsFreeSettings) + const { startKeyListener } = await import('./keyboard') + startKeyListener() + + tap() + down('Escape') + expect(mockScribaSessionManager.cancelSession).toHaveBeenCalledTimes(1) + + // The pending timer was cleared: no completion fires later. + clock.tick(400) + expect(mockScribaSessionManager.completeSession).not.toHaveBeenCalled() + }) + + test('stopKeyListener with a pending tap cancels the orphaned session', async () => { + mockMainStore.get.mockReturnValue(handsFreeSettings) + const { startKeyListener, stopKeyListener } = await import('./keyboard') + startKeyListener() + + tap() + stopKeyListener() + expect(mockScribaSessionManager.cancelSession).toHaveBeenCalledTimes(1) + + clock.tick(400) + expect(mockScribaSessionManager.completeSession).not.toHaveBeenCalled() + }) }) }) diff --git a/lib/media/keyboard.ts b/lib/media/keyboard.ts index 68dea4a..c005d90 100644 --- a/lib/media/keyboard.ts +++ b/lib/media/keyboard.ts @@ -2,7 +2,7 @@ import { spawn } from 'child_process' import store, { KeyboardShortcutConfig } from '../main/store' import { STORE_KEYS } from '../constants/store-keys' import { getNativeBinaryPath } from './native-interface' -import { BrowserWindow } from 'electron' +import { WebContents } from 'electron' import { scribaSessionManager } from '../main/scribaSessionManager' import { KeyName, keyNameMap, normalizeLegacyKey } from '../types/keyboard' @@ -51,6 +51,9 @@ let pendingTapShortcutId: string | null = null const HOLD_THRESHOLD_MS = 250 // A follow-up press within this of a quick-tap release counts as a double-tap. const DOUBLE_TAP_WINDOW_MS = 350 +// After a tap stops a hands-free session, ignore shortcut presses briefly so an +// accidental double-tap on "stop" doesn't start a spurious ~350ms recording. +let handsFreeStoppedAt: number | null = null function clearPendingTap() { if (pendingTapTimer) { @@ -60,6 +63,43 @@ function clearPendingTap() { pendingTapShortcutId = null } +// --- Key-event forwarding to renderers --- +// Raw global key events are only needed by the shortcut-editor UI while it is +// capturing a new combo. Forward them only to webContents that subscribed (the +// preload toggles this around `onKeyEvent`), so normally no keystroke data +// crosses the IPC boundary and idle windows aren't woken twice per key press. +const keyEventSubscribers = new Map< + number, + { webContents: WebContents; count: number } +>() + +export function setKeyEventForwarding( + webContents: WebContents, + enabled: boolean, +) { + const entry = keyEventSubscribers.get(webContents.id) + if (enabled) { + if (entry) { + entry.count++ + return + } + keyEventSubscribers.set(webContents.id, { webContents, count: 1 }) + webContents.once('destroyed', () => + keyEventSubscribers.delete(webContents.id), + ) + } else if (entry && --entry.count <= 0) { + keyEventSubscribers.delete(webContents.id) + } +} + +function forwardKeyEventToSubscribers(event: KeyEvent) { + for (const { webContents } of keyEventSubscribers.values()) { + if (!webContents.isDestroyed()) { + webContents.send('key-event', event) + } + } +} + // Heartbeat monitoring state let lastHeartbeatReceived = Date.now() let heartbeatCheckTimer: NodeJS.Timeout | null = null @@ -74,7 +114,9 @@ export const resetForTesting = () => { dictationSuppressed = false handsFreeActive = false activationAt = 0 + handsFreeStoppedAt = null clearPendingTap() + keyEventSubscribers.clear() pressedKeys.clear() keyPressTimestamps.clear() stopStuckKeyChecker() @@ -220,12 +262,18 @@ function handleKeyEventInMain(event: KeyEvent) { if (!isShortcutGloballyEnabled) { // check to see if we should stop an in-progress recording (incl. hands-free) - if (activeShortcutId !== null || handsFreeActive || pendingTapTimer !== null) { + if ( + activeShortcutId !== null || + handsFreeActive || + pendingTapTimer !== null + ) { activeShortcutId = null handsFreeActive = false clearPendingTap() - console.info('Shortcut DEACTIVATED, stopping recording...') - scribaSessionManager.completeSession() + // Disabling dictation is an abort: discard the audio rather than + // transcribing and typing text into whatever field happens to be focused. + console.info('Shortcut DEACTIVATED, cancelling recording...') + scribaSessionManager.cancelSession() } // Reset the pressed-key state so re-enabling shortcuts starts from a clean // slate; otherwise keys held while disabled stay "pressed" and corrupt the @@ -235,6 +283,21 @@ function handleKeyEventInMain(event: KeyEvent) { return } + // Hands-free was toggled off while a tap was pending or a hands-free session + // was recording: nothing would ever stop that session through the (now + // disabled) hands-free paths below, so settle it here. The user spoke + // intentionally, so complete (transcribe) rather than cancel. If the keys are + // still held right after a double-tap, just downgrade to push-to-talk: the + // release branch will complete it normally. + if (!handsFreeEnabled && (handsFreeActive || pendingTapTimer !== null)) { + clearPendingTap() + handsFreeActive = false + if (activeShortcutId === null) { + console.info('lib Hands-free disabled mid-session, completing...') + scribaSessionManager.completeSession() + } + } + const normalizedKey = normalizeKey(event.key) // Ignore the "fast fn" event which can be noisy. @@ -308,10 +371,17 @@ function handleKeyEventInMain(event: KeyEvent) { // A press while a hands-free session is recording STOPS it. if (handsFreeActive) { handsFreeActive = false - activeShortcutId = currentlyHeldShortcut.id + handsFreeStoppedAt = Date.now() console.info('lib Hands-free STOPPED by tap, completing...') scribaSessionManager.completeSession() - activeShortcutId = null + return + } + // An accidental second tap right after stopping hands-free would start + // a spurious sub-400ms recording; swallow it. + if ( + handsFreeStoppedAt !== null && + Date.now() - handsFreeStoppedAt < DOUBLE_TAP_WINDOW_MS + ) { return } if (pendingTapTimer !== null) { @@ -443,12 +513,9 @@ export const startKeyListener = () => { // Process the event here in the main process for hotkey detection. handleKeyEventInMain(event) - // Broadcast the raw event to all renderer windows for UI updates. - BrowserWindow.getAllWindows().forEach(window => { - if (!window.webContents.isDestroyed()) { - window.webContents.send('key-event', event) - } - }) + // Forward the raw event only to renderers that subscribed + // (shortcut editors capturing a new combo). + forwardKeyEventToSubscribers(event) } } catch (e) { console.error('Failed to parse key process event:', line, e) @@ -560,7 +627,10 @@ const getKeysToRegister = (shortcut?: KeyboardShortcutConfig): string[] => { } // Also block the special "fast fn" key if fn is part of the shortcut. - if (shortcut.keys.includes('fn')) { + // macOS only: code 179 is the fast-path fn key there, but on Windows it is + // the media Play/Pause key and must not be registered (the listener would + // swallow it). + if (process.platform === 'darwin' && shortcut.keys.includes('fn')) { keys.push('Unknown(179)') } @@ -574,7 +644,11 @@ export const stopKeyListener = () => { // restart, or quit) so the key-up that would stop it will never arrive. // Cancel the orphaned session instead of leaving the recording stuck on. // Covers a hands-free session and a pending quick tap too. - if (activeShortcutId !== null || handsFreeActive || pendingTapTimer !== null) { + if ( + activeShortcutId !== null || + handsFreeActive || + pendingTapTimer !== null + ) { console.warn( '[Key listener] Stopping with an active session; cancelling it to avoid an orphaned recording.', ) diff --git a/lib/media/text-writer.ts b/lib/media/text-writer.ts index daad66a..371142a 100644 --- a/lib/media/text-writer.ts +++ b/lib/media/text-writer.ts @@ -1,4 +1,5 @@ import { execFile } from 'child_process' +import { clipboard } from 'electron' import { platform, arch } from 'os' import { getNativeBinaryPath } from './native-interface' @@ -9,6 +10,42 @@ interface TextWriterOptions { const nativeModuleName = 'text-writer' +// The native binary pastes via the clipboard and exits immediately; the +// user's clipboard is restored here after the target app has had time to +// consume the paste. Keeping the restore in the main process means the +// binary never blocks the dictation hot path. +const CLIPBOARD_RESTORE_DELAY_MS = 1000 + +let clipboardRestoreTimer: NodeJS.Timeout | null = null +let savedClipboardText = '' + +function usesClipboardPaste(): boolean { + return platform() === 'darwin' || platform() === 'win32' +} + +function saveClipboardForRestore(): void { + if (clipboardRestoreTimer) { + // A restore is already pending: keep the originally saved contents so + // back-to-back dictations don't "restore" the previous transcript. + clearTimeout(clipboardRestoreTimer) + clipboardRestoreTimer = null + return + } + savedClipboardText = clipboard.readText() +} + +function scheduleClipboardRestore(): void { + clipboardRestoreTimer = setTimeout(() => { + clipboardRestoreTimer = null + // Only restore text contents; an empty save means the clipboard held + // no text (or nothing), and writing '' would clobber non-text contents. + if (savedClipboardText) { + clipboard.writeText(savedClipboardText) + } + savedClipboardText = '' + }, CLIPBOARD_RESTORE_DELAY_MS) +} + export function setFocusedText( text: string, options: TextWriterOptions = { delay: 0, charDelay: 0 }, @@ -35,7 +72,16 @@ export function setFocusedText( // Add the text as the final argument with -- separator to prevent flag parsing args.push('--', text) + if (usesClipboardPaste()) { + saveClipboardForRestore() + } + execFile(binaryPath, args, (err, _stdout, stderr) => { + if (usesClipboardPaste()) { + // Restore even on failure: the binary may have replaced the + // clipboard before erroring out. + scheduleClipboardRestore() + } if (err) { console.error('text-writer error:', stderr) return resolve(false) diff --git a/lib/preload/api.ts b/lib/preload/api.ts index e35142f..89c5cc8 100644 --- a/lib/preload/api.ts +++ b/lib/preload/api.ts @@ -53,7 +53,13 @@ const api = { onKeyEvent: (callback: (event: any) => void) => { const handler = (_: any, event: any) => callback(event) ipcRenderer.on('key-event', handler) - return () => ipcRenderer.removeListener('key-event', handler) + // The main process only forwards global key events to webContents that + // asked for them (perf + privacy), so opt in while subscribed. + ipcRenderer.invoke('set-key-event-forwarding', true) + return () => { + ipcRenderer.removeListener('key-event', handler) + ipcRenderer.invoke('set-key-event-forwarding', false) + } }, // Auth methods generateNewAuthState: () => ipcRenderer.invoke('generate-new-auth-state'), diff --git a/lib/preload/index.d.ts b/lib/preload/index.d.ts index 8c40ef2..b5684ba 100644 --- a/lib/preload/index.d.ts +++ b/lib/preload/index.d.ts @@ -61,7 +61,7 @@ declare global { blockKeys: (keys: string[]) => Promise unblockKey: (key: string) => Promise getBlockedKeys: () => Promise - onKeyEvent: (callback: (event: KeyEvent) => void) => void + onKeyEvent: (callback: (event: KeyEvent) => void) => () => void send: (channel: string, data: any) => void on: (channel: string, callback: (...args: any[]) => void) => () => void setPillMouseEvents: ( diff --git a/lib/utils/crossPlatform.ts b/lib/utils/crossPlatform.ts index a4dabcc..0cd13b0 100644 --- a/lib/utils/crossPlatform.ts +++ b/lib/utils/crossPlatform.ts @@ -33,7 +33,15 @@ export async function checkMicrophonePermission( return systemPreferences.getMediaAccessStatus('microphone') === 'granted' } - // Windows - microphone permissions are handled by the OS - // and don't require explicit permission checks in Electron apps + if (process.platform === 'win32') { + // Windows can't prompt programmatically, but getMediaAccessStatus reflects + // the Settings → Privacy → Microphone toggle. Without this check a denied + // mic silently records nothing. 'not-determined' counts as granted because + // Windows only reports 'denied' once the user has actively disabled it. + const status = systemPreferences.getMediaAccessStatus('microphone') + return status === 'granted' || status === 'not-determined' + } + + // Linux: no permission API; assume granted. return true } diff --git a/lib/window/ipcEvents.ts b/lib/window/ipcEvents.ts index 756da3d..31959c0 100644 --- a/lib/window/ipcEvents.ts +++ b/lib/window/ipcEvents.ts @@ -17,6 +17,7 @@ import { KeyListenerProcess, stopKeyListener, registerAllHotkeys, + setKeyEventForwarding, } from '../media/keyboard' import { getPillWindow, mainWindow } from '../main/app' import { @@ -142,6 +143,9 @@ export function registerIPC() { }) handleIPC('stop-key-listener', () => stopKeyListener()) handleIPC('register-hotkeys', () => registerAllHotkeys()) + handleIPC('set-key-event-forwarding', (e, enabled: boolean) => + setKeyEventForwarding(e.sender, enabled), + ) handleIPC('start-native-recording-service', () => scribaSessionManager.startSession(ScribaMode.TRANSCRIBE), ) @@ -443,7 +447,9 @@ export function registerIPC() { handleIPC( 'billing:confirm-session', async (_e, { sessionId }: { sessionId: string }) => { - return scribaHttpClient.post('/billing/confirm', { session_id: sessionId }) + return scribaHttpClient.post('/billing/confirm', { + session_id: sessionId, + }) }, ) @@ -581,7 +587,7 @@ export function registerIPC() { // Interactions handleIPC('interactions:get-all', () => { const user_id = getCurrentUserId() - return InteractionsTable.findAll(user_id) + return InteractionsTable.findAllSummaries(user_id) }) handleIPC('interactions:get-by-id', async (_e, id) => InteractionsTable.findById(id), diff --git a/native/global-key-listener/src/main.rs b/native/global-key-listener/src/main.rs index 91742c9..7d7fc14 100644 --- a/native/global-key-listener/src/main.rs +++ b/native/global-key-listener/src/main.rs @@ -142,6 +142,22 @@ fn should_block() -> bool { } } +/// On macOS, rdev reports the fast-path `fn` key as `Unknown(179)`; normalize +/// it to `Function` for hotkey detection. On other platforms code 179 is a +/// real key (Windows: media Play/Pause), so it must NOT be treated as `fn`. +fn normalize_key_name(key_name: &str) -> String { + #[cfg(target_os = "macos")] + if key_name == "Unknown(179)" { + return "Function".to_string(); + } + key_name.to_string() +} + +/// True only on macOS where `Unknown(179)` is the fast-path `fn` key. +fn is_fast_fn_key(key_name: &str) -> bool { + cfg!(target_os = "macos") && key_name == "Unknown(179)" +} + fn callback(event: Event) -> Option { match event.event_type { EventType::KeyPress(key) => { @@ -160,12 +176,7 @@ fn callback(event: Event) -> Option { } // Update pressed keys BEFORE checking if we should block - // Normalize Unknown(179) to Function for detection purposes - let normalized_key = if key_name == "Unknown(179)" { - "Function".to_string() - } else { - key_name.clone() - }; + let normalized_key = normalize_key_name(&key_name); unsafe { if !CURRENTLY_PRESSED.contains(&normalized_key) { @@ -208,14 +219,14 @@ fn callback(event: Event) -> Option { } } None // Block the event from reaching the OS - } else if key_name == "Unknown(179)" + } else if is_fast_fn_key(&key_name) && unsafe { REGISTERED_HOTKEYS .iter() .any(|hotkey| hotkey.keys.contains(&"Function".to_string())) } { - None // Block Unknown(179) if any hotkey uses Function + None // Block the fast fn key if any hotkey uses Function } else { Some(event) // Let it through } @@ -223,12 +234,7 @@ fn callback(event: Event) -> Option { EventType::KeyRelease(key) => { let key_name = format!("{:?}", key); - // Normalize Unknown(179) to Function for detection purposes - let normalized_key = if key_name == "Unknown(179)" { - "Function".to_string() - } else { - key_name.clone() - }; + let normalized_key = normalize_key_name(&key_name); // Update pressed keys unsafe { diff --git a/native/selected-text-reader/src/windows.rs b/native/selected-text-reader/src/windows.rs index dff0384..972a588 100644 --- a/native/selected-text-reader/src/windows.rs +++ b/native/selected-text-reader/src/windows.rs @@ -19,9 +19,15 @@ pub fn get_selected_text() -> Result> { pub fn copy_selected_text() -> Result<(), Box> { use enigo::{Direction, Enigo, Key, Keyboard, Settings}; + // VK_C (0x43) rather than Key::Unicode('c'): Unicode resolves through the + // active keyboard layout, and on layouts without a 'c' key enigo falls + // back to sending text — the Ctrl+C chord never happens and the copy + // silently fails. + const VK_C: u32 = 0x43; + let mut enigo = Enigo::new(&Settings::default())?; enigo.key(Key::Control, Direction::Press)?; - enigo.key(Key::Unicode('c'), Direction::Click)?; + enigo.key(Key::Other(VK_C), Direction::Click)?; enigo.key(Key::Control, Direction::Release)?; Ok(()) diff --git a/native/text-writer/src/macos_writer.rs b/native/text-writer/src/macos_writer.rs index 6969f12..b1d54b5 100644 --- a/native/text-writer/src/macos_writer.rs +++ b/native/text-writer/src/macos_writer.rs @@ -18,9 +18,6 @@ pub fn type_text_macos(text: &str, _char_delay: u64) -> Result<(), String> { // Get the general pasteboard let pasteboard = NSPasteboard::generalPasteboard(nil); - // Store current clipboard contents to restore later - let old_contents = pasteboard.stringForType(NSPasteboardTypeString); - // Clear the pasteboard and set our text pasteboard.clearContents(); let ns_string = NSString::alloc(nil).init_str(text); @@ -67,23 +64,9 @@ pub fn type_text_macos(text: &str, _char_delay: u64) -> Result<(), String> { thread::sleep(Duration::from_millis(10)); key_v_up.post(core_graphics::event::CGEventTapLocation::HID); - // Restore old clipboard contents in background after delay in separate thread - // to not block - if old_contents != nil { - // Convert Objective-C string to Rust String to make it Send-safe - let old_contents_str = { - let c_str = cocoa::foundation::NSString::UTF8String(old_contents); - std::ffi::CStr::from_ptr(c_str) - .to_string_lossy() - .into_owned() - }; - - thread::sleep(Duration::from_secs(1)); - let pasteboard = NSPasteboard::generalPasteboard(nil); - pasteboard.clearContents(); - let ns_string = NSString::alloc(nil).init_str(&old_contents_str); - pasteboard.setString_forType(ns_string, NSPasteboardTypeString); - } + // Clipboard restore is handled by the Electron main process: this + // one-shot binary must exit promptly, so it cannot wait ~1s for the + // target app to consume the paste before restoring. Ok(()) } diff --git a/native/text-writer/src/windows_writer.rs b/native/text-writer/src/windows_writer.rs index 8f68975..7bb871c 100644 --- a/native/text-writer/src/windows_writer.rs +++ b/native/text-writer/src/windows_writer.rs @@ -8,9 +8,6 @@ use std::time::Duration; /// This mimics the macOS implementation to avoid character-by-character typing /// issues pub fn type_text_windows(text: &str, _char_delay: u64) -> Result<(), String> { - // Store current clipboard contents to restore later - let old_contents: Result = get_clipboard(formats::Unicode); - // Set our text to clipboard set_clipboard(formats::Unicode, text) .map_err(|e| format!("Failed to set clipboard: {:?}", e))?; @@ -34,7 +31,14 @@ pub fn type_text_windows(text: &str, _char_delay: u64) -> Result<(), String> { let mut enigo = Enigo::new(&Settings::default()) .map_err(|e| format!("Failed to initialize enigo: {}", e))?; - // Simulate Ctrl+V (paste) + // Simulate Ctrl+V (paste). + // Use the virtual-key code (VK_V = 0x56) rather than Key::Unicode('v'): + // Unicode resolves via the active keyboard layout (VkKeyScanW), and on + // layouts with no 'v' key (Cyrillic, Greek, ...) enigo falls back to + // sending plain text — no Ctrl+V chord is ever delivered and the paste + // silently never happens. + const VK_V: u32 = 0x56; + // Press Ctrl enigo .key(Key::Control, enigo::Direction::Press) @@ -42,7 +46,7 @@ pub fn type_text_windows(text: &str, _char_delay: u64) -> Result<(), String> { // Press V enigo - .key(Key::Unicode('v'), enigo::Direction::Press) + .key(Key::Other(VK_V), enigo::Direction::Press) .map_err(|e| format!("Failed to press V: {}", e))?; // Small delay to ensure the key press is registered @@ -50,7 +54,7 @@ pub fn type_text_windows(text: &str, _char_delay: u64) -> Result<(), String> { // Release V enigo - .key(Key::Unicode('v'), enigo::Direction::Release) + .key(Key::Other(VK_V), enigo::Direction::Release) .map_err(|e| format!("Failed to release V: {}", e))?; // Release Ctrl @@ -58,10 +62,9 @@ pub fn type_text_windows(text: &str, _char_delay: u64) -> Result<(), String> { .key(Key::Control, enigo::Direction::Release) .map_err(|e| format!("Failed to release Ctrl: {}", e))?; - if let Ok(old_text) = old_contents { - thread::sleep(Duration::from_secs(1)); - let _ = set_clipboard(formats::Unicode, &old_text); - } + // Clipboard restore is handled by the Electron main process: this + // one-shot binary must exit promptly, so it cannot wait ~1s for the + // target app to consume the paste before restoring. Ok(()) }