feat: Add browser mode fallback for Kaspersky audio blocking

- Add sidecar management to backend (sidecar_manager.py)
- Add sidecar API router for browser mode (/api/sidecar/*)
- Add browser-api.js polyfill for running in Chrome/Edge
- Add "Open in Browser" button when audio access fails
- Update build scripts with new sidecar modules
- Add start-browser.sh for development browser mode

Browser mode allows users to open the app in their system browser
when Electron's audio access is blocked by security software.
The backend manages the sidecar process in browser mode (BROWSER_MODE=true).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-22 16:41:25 +08:00
parent e7a06e2b8f
commit 7d3fc72bd2
12 changed files with 1374 additions and 3 deletions

View File

@@ -1,4 +1,4 @@
const { app, BrowserWindow, ipcMain, session } = require("electron");
const { app, BrowserWindow, ipcMain, session, shell } = require("electron");
const path = require("path");
const fs = require("fs");
const { spawn } = require("child_process");
@@ -724,3 +724,33 @@ ipcMain.handle("transcribe-audio", async (event, audioFilePath) => {
}, 60000);
});
});
// === Browser Mode Handler ===
// Opens the current page in the system's default browser
// This is useful when Electron's audio access is blocked by security software
ipcMain.handle("open-in-browser", async () => {
const backendConfig = appConfig?.backend || {};
const host = backendConfig.host || "127.0.0.1";
const port = backendConfig.port || 8000;
// Determine the current page URL
let currentPage = "login";
if (mainWindow) {
const currentUrl = mainWindow.webContents.getURL();
if (currentUrl.includes("meetings.html")) {
currentPage = "meetings";
} else if (currentUrl.includes("meeting-detail.html")) {
currentPage = "meeting-detail";
}
}
const browserUrl = `http://${host}:${port}/${currentPage}`;
try {
await shell.openExternal(browserUrl);
return { success: true, url: browserUrl };
} catch (error) {
return { error: error.message };
}
});

View File

@@ -26,6 +26,8 @@
</div>
<script type="module">
// Browser mode polyfill (must be first)
import '../services/browser-api.js';
import { initApp } from '../services/init.js';
import { login } from '../services/api.js';

View File

@@ -305,6 +305,35 @@
color: #dc3545;
margin-top: 4px;
}
/* Browser Mode Hint */
.browser-mode-hint {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 15px;
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 6px;
margin-top: 10px;
font-size: 12px;
color: #856404;
}
.browser-mode-hint.hidden {
display: none;
}
.browser-mode-btn {
padding: 6px 12px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.browser-mode-btn:hover {
background: #0056b3;
}
</style>
</head>
<body>
@@ -368,6 +397,11 @@
<span id="audio-status" class="audio-status">準備就緒</span>
</div>
</div>
<!-- Browser Mode Hint (shown when audio access fails) -->
<div id="browser-mode-hint" class="browser-mode-hint hidden">
<span>無法存取麥克風?安全軟體可能阻擋了 Electron。請嘗試在瀏覽器中開啟。</span>
<button id="open-browser-btn" class="browser-mode-btn">在瀏覽器中開啟</button>
</div>
</div>
</div>
@@ -443,6 +477,8 @@
</div>
<script type="module">
// Browser mode polyfill (must be first)
import '../services/browser-api.js';
import { initApp } from '../services/init.js';
import {
getMeeting,
@@ -501,6 +537,8 @@
const testRecordBtn = document.getElementById('test-record-btn');
const testPlayBtn = document.getElementById('test-play-btn');
const audioStatusEl = document.getElementById('audio-status');
const browserModeHint = document.getElementById('browser-mode-hint');
const openBrowserBtn = document.getElementById('open-browser-btn');
// Audio Device State
const audioDeviceState = {
@@ -663,6 +701,11 @@
} else {
setAudioStatus('無法存取麥克風', 'error');
}
// Show browser mode hint when audio access fails (only in Electron)
if (window.electronAPI && window.electronAPI.openInBrowser) {
browserModeHint.classList.remove('hidden');
}
}
}
@@ -939,6 +982,31 @@
}
});
// Browser mode button - opens in system browser when audio is blocked
if (openBrowserBtn && window.electronAPI && window.electronAPI.openInBrowser) {
openBrowserBtn.addEventListener('click', async () => {
try {
openBrowserBtn.disabled = true;
openBrowserBtn.textContent = '開啟中...';
const result = await window.electronAPI.openInBrowser();
if (result.error) {
console.error('Failed to open browser:', result.error);
openBrowserBtn.textContent = '開啟失敗';
} else {
openBrowserBtn.textContent = '已開啟';
}
setTimeout(() => {
openBrowserBtn.disabled = false;
openBrowserBtn.textContent = '在瀏覽器中開啟';
}, 2000);
} catch (error) {
console.error('Error opening browser:', error);
openBrowserBtn.disabled = false;
openBrowserBtn.textContent = '在瀏覽器中開啟';
}
});
}
// Listen for device changes (hot-plug)
navigator.mediaDevices.addEventListener('devicechange', () => {
console.log('Audio devices changed');

View File

@@ -67,6 +67,8 @@
</div>
<script type="module">
// Browser mode polyfill (must be first)
import '../services/browser-api.js';
import { initApp } from '../services/init.js';
import { getMeetings, createMeeting, clearToken } from '../services/api.js';

View File

@@ -40,4 +40,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
onTranscriptionResult: (callback) => {
ipcRenderer.on("transcription-result", (event, text) => callback(text));
},
// === Browser Mode ===
// Open current page in system browser (useful when Electron audio is blocked)
openInBrowser: () => ipcRenderer.invoke("open-in-browser"),
});

View File

@@ -0,0 +1,288 @@
/**
* Browser API Implementation
*
* Provides a compatible interface for pages that normally use electronAPI
* when running in browser mode. Uses HTTP API to communicate with the
* backend sidecar for transcription functionality.
*/
// Check if we're running in Electron or browser
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined;
// Base URL for API calls (relative in browser mode)
const API_BASE = '';
// Progress listeners
const progressListeners = [];
const segmentListeners = [];
const streamStopListeners = [];
// WebSocket for streaming
let streamingSocket = null;
// Browser mode API implementation
const browserAPI = {
// Navigate to a page
navigate: (page) => {
const pageMap = {
'login': '/login',
'meetings': '/meetings',
'meeting-detail': '/meeting-detail'
};
window.location.href = pageMap[page] || `/${page}`;
},
// Get sidecar status
getSidecarStatus: async () => {
try {
const response = await fetch(`${API_BASE}/api/sidecar/status`);
if (response.ok) {
return await response.json();
}
return {
ready: false,
streaming: false,
whisper: null,
browserMode: true,
message: '無法取得轉寫引擎狀態'
};
} catch (error) {
console.error('[Browser Mode] getSidecarStatus error:', error);
return {
ready: false,
streaming: false,
whisper: null,
browserMode: true,
available: false,
message: '無法連接到後端服務'
};
}
},
// Model download progress listener
onModelDownloadProgress: (callback) => {
progressListeners.push(callback);
// Start polling for status updates
if (progressListeners.length === 1) {
startProgressPolling();
}
},
// Save audio file and return path (for browser mode, we handle differently)
saveAudioFile: async (arrayBuffer) => {
// In browser mode, we don't save to file system
// Instead, we'll convert to base64 and return it
// The transcribeAudio function will handle the base64 data
const base64 = arrayBufferToBase64(arrayBuffer);
return `base64:${base64}`;
},
// Transcribe audio
transcribeAudio: async (filePath) => {
try {
let response;
if (filePath.startsWith('base64:')) {
// Handle base64 encoded audio from saveAudioFile
const base64Data = filePath.substring(7);
response = await fetch(`${API_BASE}/api/sidecar/transcribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audio_data: base64Data })
});
} else {
// Handle actual file path (shouldn't happen in browser mode)
throw new Error('File path transcription not supported in browser mode');
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Transcription failed');
}
return await response.json();
} catch (error) {
console.error('[Browser Mode] transcribeAudio error:', error);
throw error;
}
},
// Transcription segment listener (for streaming mode)
onTranscriptionSegment: (callback) => {
segmentListeners.push(callback);
},
// Stream stopped listener
onStreamStopped: (callback) => {
streamStopListeners.push(callback);
},
// Start recording stream (WebSocket-based)
startRecordingStream: async () => {
try {
// Use HTTP endpoint for starting stream
const response = await fetch(`${API_BASE}/api/sidecar/stream/start`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
return { error: error.detail || 'Failed to start stream' };
}
const result = await response.json();
if (result.status === 'streaming') {
return { status: 'streaming', session_id: result.session_id };
}
return result;
} catch (error) {
console.error('[Browser Mode] startRecordingStream error:', error);
return { error: error.message };
}
},
// Stream audio chunk
streamAudioChunk: async (base64Audio) => {
try {
const response = await fetch(`${API_BASE}/api/sidecar/stream/chunk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: base64Audio })
});
if (!response.ok) {
const error = await response.json();
return { error: error.detail || 'Failed to send chunk' };
}
const result = await response.json();
// If we got a segment, notify listeners
if (result.segment && result.segment.text) {
segmentListeners.forEach(cb => {
try {
cb(result.segment);
} catch (e) {
console.error('[Browser Mode] Segment listener error:', e);
}
});
}
return result;
} catch (error) {
console.error('[Browser Mode] streamAudioChunk error:', error);
return { error: error.message };
}
},
// Stop recording stream
stopRecordingStream: async () => {
try {
const response = await fetch(`${API_BASE}/api/sidecar/stream/stop`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
return { error: error.detail || 'Failed to stop stream' };
}
const result = await response.json();
// Notify stream stop listeners
streamStopListeners.forEach(cb => {
try {
cb(result);
} catch (e) {
console.error('[Browser Mode] Stream stop listener error:', e);
}
});
return result;
} catch (error) {
console.error('[Browser Mode] stopRecordingStream error:', error);
return { error: error.message };
}
},
// Get backend status
getBackendStatus: async () => {
try {
const response = await fetch('/api/health');
if (response.ok) {
return { ready: true };
}
return { ready: false };
} catch {
return { ready: false };
}
}
};
// Helper function to convert ArrayBuffer to base64
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// Poll for sidecar status/progress updates
let progressPollingInterval = null;
let lastStatus = {};
function startProgressPolling() {
if (progressPollingInterval) return;
progressPollingInterval = setInterval(async () => {
try {
const response = await fetch(`${API_BASE}/api/sidecar/status`);
if (response.ok) {
const status = await response.json();
// Check for status changes to report
const currentStatus = status.status || (status.ready ? 'ready' : 'loading');
if (currentStatus !== lastStatus.status) {
// Notify progress listeners
progressListeners.forEach(cb => {
try {
cb(status);
} catch (e) {
console.error('[Browser Mode] Progress listener error:', e);
}
});
}
lastStatus = status;
// Stop polling once ready
if (status.ready) {
clearInterval(progressPollingInterval);
progressPollingInterval = null;
}
}
} catch (error) {
console.error('[Browser Mode] Progress polling error:', error);
}
}, 2000);
}
// Export the appropriate API based on environment
export const electronAPI = isElectron ? window.electronAPI : browserAPI;
// Also set it on window for pages that access it directly
if (!isElectron && typeof window !== 'undefined') {
window.electronAPI = browserAPI;
console.log('[Browser Mode] Running in browser mode with full transcription support');
console.log('[Browser Mode] 透過後端 Sidecar 提供即時語音轉寫功能');
}