listener-ai 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -234,7 +234,7 @@ class AgentService {
234
234
  const tools = buildTools(opts.scope, !!opts.confirm);
235
235
  // Load the single-meeting record once if needed; title + primer derive from it.
236
236
  const singleData = opts.scope.kind === 'single' && isValidFolderName(opts.scope.folderName)
237
- ? (0, outputService_1.readTranscription)(path.join((0, outputService_1.getTranscriptionsDir)(this.dataPath), opts.scope.folderName))
237
+ ? await (0, outputService_1.readTranscription)(path.join((0, outputService_1.getTranscriptionsDir)(this.dataPath), opts.scope.folderName))
238
238
  : null;
239
239
  const systemInstruction = systemInstructionFor(opts.scope, singleData?.title);
240
240
  const history = opts.history ? [...opts.history] : [];
@@ -305,7 +305,7 @@ class AgentService {
305
305
  const limit = typeof args.limit === 'number' ? args.limit : 5;
306
306
  const includeTranscript = args.include_transcript === true;
307
307
  const fields = includeTranscript ? [...searchService_1.ALL_FIELDS] : ['title', 'summary', 'keyPoints', 'actionItems'];
308
- const hits = (0, searchService_1.searchTranscriptions)(this.dataPath, { query, fields, limit });
308
+ const hits = await (0, searchService_1.searchTranscriptions)(this.dataPath, { query, fields, limit });
309
309
  return {
310
310
  hits: hits.map((h) => ({
311
311
  folder_name: h.entry.folderName,
@@ -319,7 +319,7 @@ class AgentService {
319
319
  }
320
320
  case 'list_recent_transcriptions': {
321
321
  const limit = typeof args.limit === 'number' ? args.limit : 10;
322
- const entries = (0, outputService_1.listTranscriptions)(this.dataPath, limit);
322
+ const entries = await (0, outputService_1.listTranscriptions)(this.dataPath, limit);
323
323
  return {
324
324
  entries: entries.map((e) => ({
325
325
  folder_name: e.folderName,
@@ -333,7 +333,7 @@ class AgentService {
333
333
  if (!isValidFolderName(folderName))
334
334
  return { error: 'folder_name must be a bare folder name returned by search/list (no slashes, no ..)' };
335
335
  const folderPath = path.join((0, outputService_1.getTranscriptionsDir)(this.dataPath), folderName);
336
- const data = (0, outputService_1.readTranscription)(folderPath);
336
+ const data = await (0, outputService_1.readTranscription)(folderPath);
337
337
  if (!data)
338
338
  return { error: `transcription not found: ${folderName}` };
339
339
  const result = {
package/dist/cli.js CHANGED
@@ -155,10 +155,10 @@ function handleConfig(subArgs) {
155
155
  process.stderr.write(`Error: Unknown config command: ${sub}\n`);
156
156
  usage();
157
157
  }
158
- function resolveRef(ref, dataPath) {
158
+ async function resolveRef(ref, dataPath) {
159
159
  if (/^\d+$/.test(ref)) {
160
160
  const index = parseInt(ref, 10);
161
- const entries = (0, outputService_1.listTranscriptions)(dataPath, 0);
161
+ const entries = await (0, outputService_1.listTranscriptions)(dataPath, 0);
162
162
  if (entries.length === 0) {
163
163
  process.stderr.write('Error: No transcriptions found.\n');
164
164
  process.exit(1);
@@ -176,7 +176,7 @@ function resolveRef(ref, dataPath) {
176
176
  }
177
177
  return folderPath;
178
178
  }
179
- function handleList(args) {
179
+ async function handleList(args) {
180
180
  const dataPath = (0, dataPath_1.getDataPath)();
181
181
  let limit;
182
182
  for (let i = 0; i < args.length; i++) {
@@ -188,7 +188,7 @@ function handleList(args) {
188
188
  }
189
189
  }
190
190
  }
191
- const entries = (0, outputService_1.listTranscriptions)(dataPath, limit);
191
+ const entries = await (0, outputService_1.listTranscriptions)(dataPath, limit);
192
192
  if (entries.length === 0) {
193
193
  process.stderr.write('No transcriptions found.\n');
194
194
  return;
@@ -202,14 +202,14 @@ function handleList(args) {
202
202
  process.stdout.write(`${num} ${date} ${title}\n`);
203
203
  }
204
204
  }
205
- function handleShow(args) {
205
+ async function handleShow(args) {
206
206
  const ref = args[0];
207
207
  if (!ref) {
208
208
  process.stderr.write('Error: Missing ref. Usage: listener show <ref>\n');
209
209
  process.exit(1);
210
210
  }
211
211
  const dataPath = (0, dataPath_1.getDataPath)();
212
- const folderPath = resolveRef(ref, dataPath);
212
+ const folderPath = await resolveRef(ref, dataPath);
213
213
  const summaryPath = path.join(folderPath, 'summary.md');
214
214
  if (!fs.existsSync(summaryPath)) {
215
215
  process.stderr.write(`Error: summary.md not found in ${folderPath}\n`);
@@ -219,7 +219,7 @@ function handleShow(args) {
219
219
  const { body } = (0, outputService_1.parseFrontmatter)(content);
220
220
  process.stdout.write(body);
221
221
  }
222
- function handleExport(args) {
222
+ async function handleExport(args) {
223
223
  let ref;
224
224
  let targetPath;
225
225
  let json = false;
@@ -251,7 +251,7 @@ function handleExport(args) {
251
251
  process.exit(1);
252
252
  }
253
253
  const dataPath = (0, dataPath_1.getDataPath)();
254
- const folderPath = resolveRef(ref, dataPath);
254
+ const folderPath = await resolveRef(ref, dataPath);
255
255
  const summaryPath = path.join(folderPath, 'summary.md');
256
256
  if (!fs.existsSync(summaryPath)) {
257
257
  process.stderr.write(`Error: summary.md not found in ${folderPath}\n`);
@@ -310,7 +310,7 @@ function handleExport(args) {
310
310
  }
311
311
  }
312
312
  }
313
- function handleSearch(args) {
313
+ async function handleSearch(args) {
314
314
  const VALID_FIELDS = [...searchService_1.ALL_FIELDS, 'all'];
315
315
  let query;
316
316
  let limit = 20;
@@ -357,7 +357,7 @@ function handleSearch(args) {
357
357
  }
358
358
  const dataPath = (0, dataPath_1.getDataPath)();
359
359
  const fields = (0, searchService_1.resolveFields)({ field, includeTranscript });
360
- const hits = (0, searchService_1.searchTranscriptions)(dataPath, { query, fields, limit });
360
+ const hits = await (0, searchService_1.searchTranscriptions)(dataPath, { query, fields, limit });
361
361
  if (hits.length === 0) {
362
362
  process.stderr.write('No results.\n');
363
363
  return;
@@ -421,7 +421,7 @@ async function handleAsk(args) {
421
421
  }
422
422
  let scope = { kind: 'all' };
423
423
  if (ref) {
424
- const folderPath = resolveRef(ref, dataPath);
424
+ const folderPath = await resolveRef(ref, dataPath);
425
425
  scope = { kind: 'single', folderName: path.basename(folderPath) };
426
426
  }
427
427
  const agent = new agentService_1.AgentService({ apiKey, dataPath, configService: config });
@@ -448,19 +448,19 @@ async function main() {
448
448
  return;
449
449
  }
450
450
  if (args[0] === 'list') {
451
- handleList(args.slice(1));
451
+ await handleList(args.slice(1));
452
452
  return;
453
453
  }
454
454
  if (args[0] === 'show') {
455
- handleShow(args.slice(1));
455
+ await handleShow(args.slice(1));
456
456
  return;
457
457
  }
458
458
  if (args[0] === 'export') {
459
- handleExport(args.slice(1));
459
+ await handleExport(args.slice(1));
460
460
  return;
461
461
  }
462
462
  if (args[0] === 'search') {
463
- handleSearch(args.slice(1));
463
+ await handleSearch(args.slice(1));
464
464
  return;
465
465
  }
466
466
  if (args[0] === 'ask') {
@@ -194,6 +194,9 @@ class ConfigService {
194
194
  this.config.minRecordingSeconds = Math.max(0, Math.floor(seconds));
195
195
  this.saveConfig();
196
196
  }
197
+ getAudioDeviceId() {
198
+ return this.config.audioDeviceId;
199
+ }
197
200
  getLastSeenVersion() {
198
201
  return this.config.lastSeenVersion;
199
202
  }
@@ -232,6 +235,7 @@ class ConfigService {
232
235
  maxRecordingMinutes: this.getMaxRecordingMinutes(),
233
236
  recordingReminderMinutes: this.getRecordingReminderMinutes(),
234
237
  minRecordingSeconds: this.getMinRecordingSeconds(),
238
+ audioDeviceId: this.getAudioDeviceId(),
235
239
  lastSeenVersion: this.getLastSeenVersion()
236
240
  };
237
241
  }
@@ -38,6 +38,7 @@ const genai_1 = require("@google/genai");
38
38
  const fs = __importStar(require("fs"));
39
39
  const path = __importStar(require("path"));
40
40
  const ffmpegManager_1 = require("./services/ffmpegManager");
41
+ const audioFormats_1 = require("./audioFormats");
41
42
  class GeminiService {
42
43
  // Get FFmpeg path for this service
43
44
  async getFFmpegPath() {
@@ -315,14 +316,7 @@ Return as JSON:
315
316
  if (progressCallback) {
316
317
  progressCallback(25, 'Uploading large file to Gemini...');
317
318
  }
318
- const fileExt = path.extname(audioFilePath).toLowerCase();
319
- let mimeType = 'audio/mp3';
320
- if (fileExt === '.wav') {
321
- mimeType = 'audio/wav';
322
- }
323
- else if (fileExt === '.m4a') {
324
- mimeType = 'audio/mp4';
325
- }
319
+ const mimeType = (0, audioFormats_1.mimeTypeForExtension)(path.extname(audioFilePath));
326
320
  const fileData = fs.readFileSync(audioFilePath);
327
321
  const uploadResult = await this.ai.files.upload({
328
322
  file: new Blob([fileData], { type: mimeType })
@@ -368,14 +362,7 @@ IMPORTANT:
368
362
  - Return ONLY the transcription text, no JSON formatting`;
369
363
  let result;
370
364
  if (fileUri) {
371
- const fileExt = path.extname(audioFilePath).toLowerCase();
372
- let mimeType = 'audio/mp3';
373
- if (fileExt === '.wav') {
374
- mimeType = 'audio/wav';
375
- }
376
- else if (fileExt === '.m4a') {
377
- mimeType = 'audio/mp4';
378
- }
365
+ const mimeType = (0, audioFormats_1.mimeTypeForExtension)(path.extname(audioFilePath));
379
366
  result = await this.ai.models.generateContent({
380
367
  model: this.flashModel,
381
368
  contents: [
@@ -401,14 +388,7 @@ IMPORTANT:
401
388
  else {
402
389
  const audioData = fs.readFileSync(audioFilePath);
403
390
  const base64Audio = audioData.toString('base64');
404
- const fileExt = path.extname(audioFilePath).toLowerCase();
405
- let mimeType = 'audio/mp3';
406
- if (fileExt === '.wav') {
407
- mimeType = 'audio/wav';
408
- }
409
- else if (fileExt === '.m4a') {
410
- mimeType = 'audio/mp4';
411
- }
391
+ const mimeType = (0, audioFormats_1.mimeTypeForExtension)(path.extname(audioFilePath));
412
392
  result = await this.ai.models.generateContent({
413
393
  model: this.flashModel,
414
394
  contents: [
@@ -484,8 +464,7 @@ IMPORTANT:
484
464
  console.log(`Starting transcription for segment ${segmentIndex + 1}/${totalSegments} (attempt ${attempt}/${maxRetries})...`);
485
465
  const audioData = fs.readFileSync(segmentFile);
486
466
  const base64Audio = audioData.toString('base64');
487
- const fileExt = path.extname(segmentFile).toLowerCase();
488
- const mimeType = fileExt === '.mp3' ? 'audio/mp3' : 'audio/wav';
467
+ const mimeType = (0, audioFormats_1.mimeTypeForExtension)(path.extname(segmentFile));
489
468
  const result = await this.ai.models.generateContent({
490
469
  model: this.flashModel,
491
470
  contents: [
package/dist/main.js CHANGED
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  const electron_1 = require("electron");
37
+ const child_process_1 = require("child_process");
38
+ const util_1 = require("util");
37
39
  const path = __importStar(require("path"));
38
40
  const fs = __importStar(require("fs"));
39
41
  const simpleAudioRecorder_1 = require("./simpleAudioRecorder");
@@ -52,6 +54,7 @@ const releaseNotesService_1 = require("./services/releaseNotesService");
52
54
  const outputService_1 = require("./outputService");
53
55
  const searchService_1 = require("./searchService");
54
56
  const agentService_1 = require("./agentService");
57
+ const audioFormats_1 = require("./audioFormats");
55
58
  global.isQuitting = false;
56
59
  let mainWindow = null;
57
60
  const audioRecorder = new simpleAudioRecorder_1.SimpleAudioRecorder();
@@ -261,8 +264,6 @@ function createWindow() {
261
264
  });
262
265
  }
263
266
  electron_1.app.whenReady().then(() => {
264
- // Initialize auto-updater
265
- autoUpdaterService_1.autoUpdaterService.checkForUpdates();
266
267
  // Create menu with DevTools option
267
268
  const template = [
268
269
  {
@@ -364,6 +365,9 @@ electron_1.app.whenReady().then(() => {
364
365
  autoUpdaterService_1.autoUpdaterService.setMainWindow(mainWindow);
365
366
  notificationService_1.notificationService.setMainWindow(mainWindow);
366
367
  }
368
+ // Kick off initial + periodic update checks once the renderer can receive IPC.
369
+ autoUpdaterService_1.autoUpdaterService.checkForUpdates();
370
+ autoUpdaterService_1.autoUpdaterService.startPeriodicCheck();
367
371
  // Show release notes on first launch after an update.
368
372
  checkAndShowReleaseNotes().catch((err) => {
369
373
  console.error('Release notes check failed:', err);
@@ -433,6 +437,7 @@ electron_1.app.on('window-all-closed', () => {
433
437
  electron_1.app.on('before-quit', () => {
434
438
  meetingDetector.stop();
435
439
  displayDetector.stop();
440
+ autoUpdaterService_1.autoUpdaterService.stopPeriodicCheck();
436
441
  global.isQuitting = true;
437
442
  });
438
443
  // Unregister all shortcuts when app quits
@@ -501,46 +506,115 @@ electron_1.ipcMain.handle('cancel-ffmpeg-download', async () => {
501
506
  ffmpegManager.cancelDownload();
502
507
  return { success: true };
503
508
  });
509
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
510
+ // Returns `code: 'ffmpeg-missing'` so the renderer can route users into the
511
+ // existing ffmpeg-download UI (triggered by transcription) rather than
512
+ // duplicating that flow here.
513
+ electron_1.ipcMain.handle('export-recording-m4a', async (_, srcPath) => {
514
+ try {
515
+ if (!srcPath || typeof srcPath !== 'string') {
516
+ return { success: false, error: 'Invalid source path' };
517
+ }
518
+ // Containment: the renderer is trusted today, but bound srcPath to the
519
+ // recordings directory so a future renderer bug can't transcode arbitrary
520
+ // local files. realpath resolves symlinks before the prefix check.
521
+ const recordingsDir = path.join(electron_1.app.getPath('userData'), 'recordings');
522
+ let resolvedSrc;
523
+ try {
524
+ resolvedSrc = await fs.promises.realpath(srcPath);
525
+ }
526
+ catch {
527
+ return { success: false, error: 'Source recording not found' };
528
+ }
529
+ const resolvedRoot = await fs.promises.realpath(recordingsDir).catch(() => recordingsDir);
530
+ if (!resolvedSrc.startsWith(resolvedRoot + path.sep)) {
531
+ return { success: false, error: 'Source path is outside the recordings directory' };
532
+ }
533
+ const ffmpegPath = await ffmpegManager.ensureFFmpeg();
534
+ if (!ffmpegPath) {
535
+ return { success: false, code: 'ffmpeg-missing', error: 'FFmpeg is required for M4A export.' };
536
+ }
537
+ const baseName = path.basename(resolvedSrc, path.extname(resolvedSrc));
538
+ const dialogOptions = {
539
+ title: 'Export recording as M4A',
540
+ defaultPath: `${baseName}.m4a`,
541
+ filters: [{ name: 'M4A Audio', extensions: ['m4a'] }],
542
+ };
543
+ const saveResult = mainWindow
544
+ ? await electron_1.dialog.showSaveDialog(mainWindow, dialogOptions)
545
+ : await electron_1.dialog.showSaveDialog(dialogOptions);
546
+ if (saveResult.canceled || !saveResult.filePath) {
547
+ return { canceled: true };
548
+ }
549
+ const destPath = saveResult.filePath;
550
+ // Write to a sibling temp file and atomically rename on success so a
551
+ // failed encode never overwrites the user's picked path with a partial.
552
+ const tmpPath = `${destPath}.partial`;
553
+ try {
554
+ // Force -f ipod (M4A muxer) because the `.partial` extension defeats
555
+ // ffmpeg's format-by-extension detection otherwise.
556
+ await execFileAsync(ffmpegPath, [
557
+ '-y', '-loglevel', 'error',
558
+ '-i', resolvedSrc,
559
+ '-vn', '-c:a', 'aac', '-b:a', '128k',
560
+ '-movflags', '+faststart',
561
+ '-f', 'ipod',
562
+ tmpPath,
563
+ ]);
564
+ await fs.promises.rename(tmpPath, destPath);
565
+ }
566
+ catch (encodeError) {
567
+ await fs.promises.unlink(tmpPath).catch(() => { });
568
+ throw encodeError;
569
+ }
570
+ return { success: true, path: destPath };
571
+ }
572
+ catch (error) {
573
+ console.error('Error exporting M4A:', error);
574
+ // execFileAsync rejections carry stderr on the error object — surface it
575
+ // so renderer-side toasts aren't reduced to "Command failed".
576
+ const stderr = error?.stderr;
577
+ const baseMessage = error instanceof Error ? error.message : String(error);
578
+ const message = stderr ? `${baseMessage.split('\n')[0]} — ${stderr.trim()}` : baseMessage;
579
+ return { success: false, error: message };
580
+ }
581
+ });
582
+ // Audio capture runs in the renderer via MediaRecorder; main only tracks state,
583
+ // runs max/reminder timers, updates the menu bar, and writes the final blob to disk.
504
584
  electron_1.ipcMain.handle('start-recording', async (_, meetingTitle) => {
505
585
  try {
506
- const result = await audioRecorder.startRecording(meetingTitle);
507
- // Update menu bar icon state
508
- if (result.success) {
509
- menuBarManager.updateRecordingState(true, meetingTitle);
510
- // Set up recording limit timer
511
- const maxMinutes = configService.getMaxRecordingMinutes();
512
- if (maxMinutes > 0) {
513
- recordingMaxTimer = setTimeout(async () => {
514
- clearRecordingTimers();
515
- if (audioRecorder.isRecording()) {
516
- try {
517
- const stopResult = await audioRecorder.stopRecording();
518
- menuBarManager.updateRecordingState(false);
519
- meetingAutoStartedRecording = false;
520
- if (stopResult.success) {
521
- notificationService_1.notificationService.notifyRecordingAutoStopped(maxMinutes);
522
- }
523
- if (mainWindow && !mainWindow.isDestroyed()) {
524
- mainWindow.webContents.send('recording-auto-stopped', stopResult);
525
- }
526
- }
527
- catch (error) {
528
- console.error('Error auto-stopping recording:', error);
529
- }
530
- }
531
- }, maxMinutes * 60 * 1000);
532
- }
533
- // Set up recording reminder interval
534
- const reminderMinutes = configService.getRecordingReminderMinutes();
535
- if (reminderMinutes > 0) {
536
- let elapsed = 0;
537
- recordingReminderTimer = setInterval(() => {
538
- elapsed += reminderMinutes;
539
- if (audioRecorder.isRecording()) {
540
- notificationService_1.notificationService.notifyRecordingReminder(elapsed);
541
- }
542
- }, reminderMinutes * 60 * 1000);
543
- }
586
+ const result = audioRecorder.startRecording(meetingTitle);
587
+ if (!result.success)
588
+ return result;
589
+ menuBarManager.updateRecordingState(true, meetingTitle);
590
+ const maxMinutes = configService.getMaxRecordingMinutes();
591
+ if (maxMinutes > 0) {
592
+ recordingMaxTimer = setTimeout(() => {
593
+ clearRecordingTimers();
594
+ if (!audioRecorder.isRecording())
595
+ return;
596
+ notificationService_1.notificationService.notifyRecordingAutoStopped(maxMinutes);
597
+ if (mainWindow && !mainWindow.isDestroyed()) {
598
+ // Renderer owns MediaRecorder — it will stop, save, and re-enter stop-recording via IPC.
599
+ mainWindow.webContents.send('recording-auto-stopped', { reason: 'maxDuration', maxMinutes });
600
+ }
601
+ // Always reset main-side state so an unresponsive or hung renderer can't leave the
602
+ // isRecording() flag stuck and block future recordings. stopRecording() is idempotent,
603
+ // so the renderer's own stop-recording IPC (if it runs) is a safe no-op here.
604
+ audioRecorder.stopRecording();
605
+ menuBarManager.updateRecordingState(false);
606
+ meetingAutoStartedRecording = false;
607
+ }, maxMinutes * 60 * 1000);
608
+ }
609
+ const reminderMinutes = configService.getRecordingReminderMinutes();
610
+ if (reminderMinutes > 0) {
611
+ let elapsed = 0;
612
+ recordingReminderTimer = setInterval(() => {
613
+ elapsed += reminderMinutes;
614
+ if (audioRecorder.isRecording()) {
615
+ notificationService_1.notificationService.notifyRecordingReminder(elapsed);
616
+ }
617
+ }, reminderMinutes * 60 * 1000);
544
618
  }
545
619
  return result;
546
620
  }
@@ -553,17 +627,31 @@ electron_1.ipcMain.handle('start-recording', async (_, meetingTitle) => {
553
627
  electron_1.ipcMain.handle('stop-recording', async () => {
554
628
  try {
555
629
  clearRecordingTimers();
556
- const result = await audioRecorder.stopRecording();
557
- // Update menu bar icon state
630
+ const result = audioRecorder.stopRecording();
558
631
  menuBarManager.updateRecordingState(false);
559
632
  meetingAutoStartedRecording = false;
633
+ return result;
634
+ }
635
+ catch (error) {
636
+ console.error('Error stopping recording:', error);
637
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
638
+ }
639
+ });
640
+ // Receives the encoded audio blob from the renderer after MediaRecorder finalizes.
641
+ // The "stopped" notification only fires once the file is actually on disk — otherwise
642
+ // a failed write would still show a success toast.
643
+ electron_1.ipcMain.handle('save-recording', async (_, payload) => {
644
+ try {
645
+ const buf = Buffer.from(payload.data);
646
+ const extension = (0, audioFormats_1.extensionForMimeType)(payload.mimeType);
647
+ const result = await audioRecorder.saveRecording(payload.title, buf, extension, payload.durationMs);
560
648
  if (result.success) {
561
649
  notificationService_1.notificationService.notifyRecordingStopped();
562
650
  }
563
651
  return result;
564
652
  }
565
653
  catch (error) {
566
- console.error('Error stopping recording:', error);
654
+ console.error('Error saving recording:', error);
567
655
  return { success: false, error: error instanceof Error ? error.message : String(error) };
568
656
  }
569
657
  });
@@ -623,6 +711,19 @@ electron_1.ipcMain.handle('get-all-releases', async () => {
623
711
  console.log(`Release list IPC: fetched ${results.length} releases`);
624
712
  return results;
625
713
  });
714
+ electron_1.ipcMain.handle('update:get-state', async () => {
715
+ return autoUpdaterService_1.autoUpdaterService.getUpdateState();
716
+ });
717
+ electron_1.ipcMain.handle('update:download', async () => {
718
+ autoUpdaterService_1.autoUpdaterService.downloadUpdate();
719
+ });
720
+ electron_1.ipcMain.handle('update:install', async () => {
721
+ autoUpdaterService_1.autoUpdaterService.quitAndInstall();
722
+ });
723
+ // Dev-only: drive the badge state machine manually from DevTools.
724
+ electron_1.ipcMain.handle('update:simulate', async (_, event, data) => {
725
+ autoUpdaterService_1.autoUpdaterService.simulateUpdateEvent(event, data);
726
+ });
626
727
  electron_1.ipcMain.handle('check-config', async () => {
627
728
  return {
628
729
  hasConfig: configService.hasRequiredConfig(),
@@ -790,7 +891,7 @@ electron_1.ipcMain.handle('get-metadata', async (_, filePath) => {
790
891
  return { success: true, data: null };
791
892
  // New format: read from transcription folder
792
893
  if (metadata.transcriptionPath) {
793
- const transcription = (0, outputService_1.readTranscription)(metadata.transcriptionPath);
894
+ const transcription = await (0, outputService_1.readTranscription)(metadata.transcriptionPath);
794
895
  if (transcription) {
795
896
  return {
796
897
  success: true,
@@ -834,6 +935,20 @@ electron_1.ipcMain.handle('open-recordings-folder', async () => {
834
935
  // Open the folder in the system file explorer
835
936
  electron_1.shell.openPath(recordingsPath);
836
937
  });
938
+ // Reveal a specific recording in Finder/Explorer. This gives the user a
939
+ // one-click path to native OS share tools (right-click -> Share -> AirDrop,
940
+ // Messages, Mail, KakaoTalk, etc.) without us having to integrate each target.
941
+ electron_1.ipcMain.handle('show-in-finder', (_, filePath) => {
942
+ if (!filePath)
943
+ return { success: false, error: 'No file path provided' };
944
+ try {
945
+ electron_1.shell.showItemInFolder(filePath);
946
+ return { success: true };
947
+ }
948
+ catch (error) {
949
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
950
+ }
951
+ });
837
952
  // Search past transcriptions
838
953
  electron_1.ipcMain.handle('search-transcriptions', async (_, opts) => {
839
954
  try {
@@ -847,7 +962,7 @@ electron_1.ipcMain.handle('search-transcriptions', async (_, opts) => {
847
962
  const fields = filtered.length > 0 ? filtered : searchService_1.ALL_FIELDS;
848
963
  const limit = Number.isFinite(opts.limit) && opts.limit >= 0 ? opts.limit : 20;
849
964
  const dataPath = electron_1.app.getPath('userData');
850
- const raw = (0, searchService_1.searchTranscriptions)(dataPath, { query, fields, limit });
965
+ const raw = await (0, searchService_1.searchTranscriptions)(dataPath, { query, fields, limit });
851
966
  const hits = raw.map((h) => ({
852
967
  folderName: h.entry.folderName,
853
968
  folderPath: h.entry.folderPath,
@@ -891,7 +1006,7 @@ electron_1.ipcMain.handle('get-recordings', async () => {
891
1006
  // Filter out segment files
892
1007
  if (file.includes('_segment_'))
893
1008
  return false;
894
- return file.endsWith('.mp3') || file.endsWith('.wav') || file.endsWith('.m4a');
1009
+ return (0, audioFormats_1.isSupportedAudioExtension)(path.extname(file));
895
1010
  })
896
1011
  .map((file) => {
897
1012
  const filePath = path.join(recordingsDir, file);
@@ -239,25 +239,39 @@ function saveTranscription(opts) {
239
239
  * List transcription folders sorted by most-recent-first.
240
240
  * Performs a lightweight line scan of each summary.md to extract only title and transcribedAt.
241
241
  */
242
- function listTranscriptions(dataPath, limit) {
242
+ async function listTranscriptions(dataPath, limit) {
243
243
  const dir = getTranscriptionsDir(dataPath);
244
- if (!fs.existsSync(dir))
245
- return [];
244
+ let dirents;
245
+ try {
246
+ dirents = await fs.promises.readdir(dir, { withFileTypes: true });
247
+ }
248
+ catch (err) {
249
+ if (err.code === 'ENOENT')
250
+ return [];
251
+ throw err;
252
+ }
246
253
  const entries = [];
247
- for (const name of fs.readdirSync(dir)) {
248
- const folderPath = path.join(dir, name);
249
- if (!fs.statSync(folderPath).isDirectory())
254
+ for (const dirent of dirents) {
255
+ if (!dirent.isDirectory())
250
256
  continue;
257
+ const name = dirent.name;
258
+ const folderPath = path.join(dir, name);
251
259
  const summaryPath = path.join(folderPath, 'summary.md');
252
- if (!fs.existsSync(summaryPath))
253
- continue;
254
260
  let title = name;
255
261
  let transcribedAt = '';
256
262
  // Lightweight line scan -- read only frontmatter, not the full file
257
- const fd = fs.openSync(summaryPath, 'r');
263
+ let fileHandle;
264
+ try {
265
+ fileHandle = await fs.promises.open(summaryPath, 'r');
266
+ }
267
+ catch (err) {
268
+ if (err.code === 'ENOENT')
269
+ continue;
270
+ throw err;
271
+ }
258
272
  try {
259
273
  const buf = Buffer.alloc(2048);
260
- const bytesRead = fs.readSync(fd, buf, 0, 2048, 0);
274
+ const { bytesRead } = await fileHandle.read(buf, 0, 2048, 0);
261
275
  const head = buf.toString('utf-8', 0, bytesRead);
262
276
  for (const line of head.split('\n')) {
263
277
  const titleMatch = line.match(/^title:\s*(.+)$/);
@@ -272,7 +286,7 @@ function listTranscriptions(dataPath, limit) {
272
286
  }
273
287
  }
274
288
  finally {
275
- fs.closeSync(fd);
289
+ await fileHandle.close();
276
290
  }
277
291
  // Fall back to folder name timestamp suffix (e.g. _20260219_143000)
278
292
  if (!transcribedAt) {
@@ -292,12 +306,10 @@ function listTranscriptions(dataPath, limit) {
292
306
  * Read transcription data from a transcription folder.
293
307
  * Returns raw data from frontmatter (machine-readable), not the markdown body.
294
308
  */
295
- function readTranscription(folderPath) {
309
+ async function readTranscription(folderPath) {
296
310
  try {
297
311
  const summaryPath = path.join(folderPath, 'summary.md');
298
- if (!fs.existsSync(summaryPath))
299
- return null;
300
- const summaryContent = fs.readFileSync(summaryPath, 'utf-8');
312
+ const summaryContent = await fs.promises.readFile(summaryPath, 'utf-8');
301
313
  const { meta } = parseFrontmatter(summaryContent);
302
314
  // Parse customFields from frontmatter (stored as JSON string)
303
315
  let customFields;
@@ -122,14 +122,14 @@ function resolveFields(opts) {
122
122
  return opts.includeTranscript ? [...exports.ALL_FIELDS] : [...exports.DEFAULT_FIELDS];
123
123
  }
124
124
  /** Run a search against the local transcriptions archive. */
125
- function searchTranscriptions(dataPath, opts) {
126
- const entries = (0, outputService_1.listTranscriptions)(dataPath, 0);
125
+ async function searchTranscriptions(dataPath, opts) {
126
+ const entries = await (0, outputService_1.listTranscriptions)(dataPath, 0);
127
127
  const fields = opts.fields ?? exports.DEFAULT_FIELDS;
128
128
  const needle = opts.query.toLowerCase();
129
129
  const scope = new Set(fields);
130
130
  const hits = [];
131
131
  for (const entry of entries) {
132
- const data = (0, outputService_1.readTranscription)(entry.folderPath);
132
+ const data = await (0, outputService_1.readTranscription)(entry.folderPath);
133
133
  if (!data)
134
134
  continue;
135
135
  const hit = scoreRecordPrepared(entry, data, needle, scope);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "listener-ai",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "description": "A lightweight desktop application for recording and transcribing meetings with AI-powered notes.",
5
5
  "main": "dist/main.js",
6
6
  "bin": {
@@ -48,7 +48,7 @@
48
48
  "@types/fs-extra": "^11.0.4",
49
49
  "@types/node": "^24.0.12",
50
50
  "dotenv": "^17.2.0",
51
- "electron": "^37.3.1",
51
+ "electron": "^39.8.5",
52
52
  "electron-builder": "^26.8.1",
53
53
  "semver": "^7.7.2",
54
54
  "typescript": "^5.8.3"