Skip to content

Commit 750c5f9

Browse files
committed
Fix mic visibility and whisper venv persistence in packaged builds
- Move Whisper venv from process.cwd() to app.getPath('userData') so it survives app restarts and works in packaged/installer builds - Add userData venv candidate in speech service fallback probing - Reinitialize speech service and broadcast availability when onboarding completes - Add main-window-ready handshake so hidden overlay receives speech state after first-run - Guard mic click to refresh availability when speech is not actually ready
1 parent f460692 commit 750c5f9

5 files changed

Lines changed: 73 additions & 5 deletions

File tree

main.js

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,20 @@ class ApplicationController {
407407
}, 1000);
408408
});
409409

410+
ipcMain.on("main-window-ready", () => {
411+
// Re-check availability whenever the main overlay finishes loading;
412+
// this covers first-run where the window was hidden during onboarding.
413+
this.speechAvailable = speechService.isAvailable
414+
? speechService.isAvailable()
415+
: false;
416+
const { BrowserWindow } = require("electron");
417+
BrowserWindow.getAllWindows().forEach((win) => {
418+
if (!win.isDestroyed()) {
419+
win.webContents.send("speech-availability", { available: this.speechAvailable });
420+
}
421+
});
422+
});
423+
410424
ipcMain.on("test-chat-window", () => {
411425
windowManager.broadcastToAllWindows("transcription-received", {
412426
text: "🧪 IMMEDIATE TEST: Chat window IPC communication test successful!",
@@ -628,13 +642,16 @@ class ApplicationController {
628642
try {
629643
this.firstRunManager.markCompleted();
630644
this.isFirstRun = false;
645+
// Reinitialize speech service with the latest persisted settings
646+
// so the mic button reflects the provider/command set during onboarding.
647+
speechService.initializeClient();
648+
this.speechAvailable = speechService.isAvailable
649+
? speechService.isAvailable()
650+
: false;
631651
// Show the main overlay window now that onboarding is done
632652
// and API keys are configured.
633653
await windowManager.showMainWindow();
634654
// Broadcast speech availability so the mic button appears
635-
this.speechAvailable = speechService.isAvailable
636-
? speechService.isAvailable()
637-
: false;
638655
const { BrowserWindow } = require("electron");
639656
BrowserWindow.getAllWindows().forEach((win) => {
640657
if (!win.isDestroyed()) {
@@ -1284,8 +1301,10 @@ class ApplicationController {
12841301
getWhisperInstaller() {
12851302
if (!this._whisperInstaller) {
12861303
const WhisperInstaller = require("./src/core/whisper-installer");
1304+
const { app } = require("electron");
12871305
this._whisperInstaller = new WhisperInstaller({
12881306
cwd: process.cwd(),
1307+
dataDir: app.getPath("userData"),
12891308
platform: process.platform,
12901309
});
12911310
}

preload.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
6060
updateActiveSkill: (skill) => ipcRenderer.invoke('update-active-skill', skill),
6161
restartAppForStealth: () => ipcRenderer.invoke('restart-app-for-stealth'),
6262
closeWindow: () => ipcRenderer.invoke('close-window'),
63+
notifyMainWindowReady: () => {
64+
try {
65+
ipcRenderer.send('main-window-ready');
66+
} catch (error) {
67+
console.error('Error notifying main window ready:', error);
68+
}
69+
},
6370
quit: () => {
6471
try {
6572
ipcRenderer.send('quit-app');

src/core/whisper-installer.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ function runExec(cmd, args, { timeout = PROBE_TIMEOUT_MS, onProgress } = {}) {
126126
class WhisperInstaller {
127127
constructor(options = {}) {
128128
this.cwd = options.cwd || process.cwd();
129+
// Persistent data directory for the virtual environment. In packaged
130+
// builds process.cwd() is not stable (AppImage mount dirs change,
131+
// system install dirs may be read-only), so we default to userData.
132+
this.dataDir = options.dataDir || this.cwd;
129133
this.platform = options.platform || process.platform;
130134
this.runExec = options.runExec || runExec;
131135
}
@@ -135,7 +139,7 @@ class WhisperInstaller {
135139
// ─────────────────────────────────────────────────────────────────
136140

137141
get venvPath() {
138-
return path.join(this.cwd, '.venv-whisper');
142+
return path.join(this.dataDir, '.venv-whisper');
139143
}
140144

141145
/**

src/services/speech.service.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,27 @@ class SpeechService extends EventEmitter {
992992
return value === '' ? null : value;
993993
}
994994

995+
/**
996+
* Build a whisper candidate pointing at the app-local venv inside
997+
* Electron's userData directory. This is where the onboarding installer
998+
* creates the venv in packaged builds.
999+
*/
1000+
_getUserDataWhisperCandidate() {
1001+
try {
1002+
const { app } = require('electron');
1003+
const userData = app.getPath('userData');
1004+
const binDir = process.platform === 'win32' ? 'Scripts' : 'bin';
1005+
const ext = process.platform === 'win32' ? '.exe' : '';
1006+
const python = path.join(userData, '.venv-whisper', binDir, `python${ext}`);
1007+
if (fs.existsSync(python)) {
1008+
return { command: python, baseArgs: ['-m', 'whisper'] };
1009+
}
1010+
} catch (_) {
1011+
// electron may not be available in unit tests
1012+
}
1013+
return null;
1014+
}
1015+
9951016
_resolveWhisperCommand() {
9961017
const configured = this._getSetting('whisperCommand') || process.env.WHISPER_COMMAND;
9971018
const candidates = [];
@@ -1000,6 +1021,12 @@ class SpeechService extends EventEmitter {
10001021
candidates.push(...this._expandConfiguredWhisperCandidates(configured));
10011022
}
10021023

1024+
// Persistent app venv (highest priority after explicit config)
1025+
const userDataVenv = this._getUserDataWhisperCandidate();
1026+
if (userDataVenv) {
1027+
candidates.push({ ...userDataVenv, source: 'app userData venv' });
1028+
}
1029+
10031030
// Platform-aware fallback candidates (higher priority = tried first)
10041031
candidates.push({ command: 'whisper', baseArgs: [], source: 'system PATH' });
10051032
if (process.platform === 'win32') {

src/ui/main-window.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ class MainWindowUI {
4949
skill: this.currentSkill,
5050
interactive: this.isInteractive
5151
});
52+
53+
// Notify the main process that the overlay renderer is ready
54+
// so it can push the latest speech availability state.
55+
if (window.electronAPI && window.electronAPI.notifyMainWindowReady) {
56+
window.electronAPI.notifyMainWindowReady();
57+
}
5258

5359
} catch (error) {
5460
logger.error('Failed to initialize main window UI', {
@@ -301,12 +307,17 @@ class MainWindowUI {
301307

302308
// Add click handler for microphone
303309
this.micButton.addEventListener('click', () => {
304-
if (this.isInteractive) {
310+
if (this.isInteractive && this.speechAvailable) {
305311
if (this.isRecording) {
306312
window.electronAPI.stopSpeechRecognition();
307313
} else {
308314
window.electronAPI.startSpeechRecognition();
309315
}
316+
} else if (this.isInteractive && !this.speechAvailable) {
317+
logger.warn('Mic clicked but speech recognition is not available', {
318+
component: 'MainWindowUI'
319+
});
320+
this.loadSpeechAvailability();
310321
}
311322
});
312323

0 commit comments

Comments
 (0)