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:
@@ -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 };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
288
client/src/services/browser-api.js
Normal file
288
client/src/services/browser-api.js
Normal 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 提供即時語音轉寫功能');
|
||||
}
|
||||
Reference in New Issue
Block a user