Files
db095baa94 fix(windows): stop offering hotkeys the missing key listener can't fire (#2239)
* fix(windows): stop offering hotkeys the missing key listener can't fire

A Windows build can ship without windows-key-listener.exe (#2005). The app
then kept Hold selectable and registered modifier-only hotkeys such as the
default Control+Super, and none of them ever fired.

HotkeyManager now treats a missing listener like any other unsupported
hotkey, so the existing fallbacks take over:
- Settings and onboarding disable Hold with the existing reason, and a
  saved Hold is reset to Tap at startup.
- Modifier-only and right-side-modifier hotkeys are refused with a message
  that says what to pick instead, and startup falls back to F8.
- The effective default becomes F8, so onboarding and Reset to default
  stop offering Control+Super and Right Ctrl, and suggestions skip
  modifier-only combos.

* fix(hotkeys): tell the user what replaced a saved hotkey and suggest keys they can use

- When a saved dictation hotkey fails at startup and the default registers,
  main now reports the active key and shows the "Hotkey changed" toast
  instead of the generic "choose a different one" toast. Fallbacks are
  reported against the saved hotkey, not a default the user never chose.
- Suggestions skip keys another slot already holds, and fall back to
  F8/F9/Control+Shift+Space when filtering leaves none. The missing-listener
  message no longer names F8, which dictation already holds on those installs.
- Slot conflicts name the slot by its Settings title, and a registration that
  throws reports the translated failure instead of the exception text.
- Saved Voice Assistant, Translation and Meeting hotkeys that no longer
  register now reach the user, and the "Hotkey unavailable" toast shows
  main's reason so it says which hotkey failed.

---------

Co-authored-by: boseq <307062491+boseq@users.noreply.github.com>
Co-authored-by: Chadpiha <chadpiha23@gmail.com>
2026-09-30 22:16:42 +02:00

2077 lines
76 KiB
JavaScript

// Chromium picks the display backend before JS runs, so appendSwitch is too
// late — the flag has to come from a relaunch.
const { XWAYLAND_FLAG, shouldForceXWayland } = require("./src/helpers/xwayland");
const { createHotkeyRepeatGate } = require("./src/helpers/hotkeyRepeatGate");
if (shouldForceXWayland(process.argv)) {
const { spawn } = require("child_process");
spawn(process.execPath, [...process.argv.slice(1), XWAYLAND_FLAG], {
stdio: "inherit",
detached: true,
}).unref();
process.exit(0);
}
const {
app,
desktopCapturer,
globalShortcut,
BrowserWindow,
dialog,
ipcMain,
net,
session,
systemPreferences,
} = require("electron");
const path = require("path");
const http = require("http");
const tls = require("tls");
require("dotenv").config({ path: path.join(__dirname, ".env") });
// Extend Node's TLS trust with the OS store so ws and https.get see corporate
// CAs that Chromium already trusts.
try {
const currentCAs = tls.getCACertificates();
const systemCAs = tls.getCACertificates("system");
if (systemCAs?.length) {
tls.setDefaultCACertificates([...currentCAs, ...systemCAs]);
}
} catch (err) {
require("./src/helpers/debugLogger").warn("System CA merge failed; using existing CA list", {
error: err?.message,
});
}
const VALID_CHANNELS = new Set(["development", "staging", "production"]);
const DEFAULT_OAUTH_PROTOCOL_BY_CHANNEL = {
development: "openwhispr-dev",
staging: "openwhispr-staging",
production: "openwhispr",
};
const BASE_WINDOWS_APP_ID = "com.gizmolabs.openwhispr";
const DEFAULT_AUTH_BRIDGE_PORT = 5199;
function isElectronBinaryExec() {
const execPath = (process.execPath || "").toLowerCase();
return (
execPath.includes("/electron.app/contents/macos/electron") ||
execPath.endsWith("/electron") ||
execPath.endsWith("\\electron.exe")
);
}
function inferDefaultChannel() {
if (process.env.NODE_ENV === "development" || process.defaultApp || isElectronBinaryExec()) {
return "development";
}
return "production";
}
function resolveAppChannel() {
const rawChannel = (process.env.OPENWHISPR_CHANNEL || process.env.VITE_OPENWHISPR_CHANNEL || "")
.trim()
.toLowerCase();
if (VALID_CHANNELS.has(rawChannel)) {
return rawChannel;
}
return inferDefaultChannel();
}
const APP_CHANNEL = resolveAppChannel();
process.env.OPENWHISPR_CHANNEL = APP_CHANNEL;
function configureChannelUserDataPath() {
if (APP_CHANNEL === "production") {
return;
}
const isolatedPath = path.join(app.getPath("appData"), `OpenWhispr-${APP_CHANNEL}`);
app.setPath("userData", isolatedPath);
}
configureChannelUserDataPath();
// Load userData .env (contains DICTATION_KEY, API keys, etc.) early — before
// hotkey registration, which needs DICTATION_KEY before the renderer loads.
require("dotenv").config({
path: path.join(app.getPath("userData"), ".env"),
override: false,
});
// Chromium's Windows-only occlusion tracker misclassifies the always-on-top
// transparent pill as occluded, throttling its renderer and jittering animations.
if (process.platform === "win32") {
app.commandLine.appendSwitch("disable-features", "CalculateNativeWinOcclusion");
}
// Fix transparent window flickering on Linux: --enable-transparent-visuals requires
// the compositor to set up an ARGB visual before any windows are created.
// --disable-gpu-compositing prevents GPU compositing conflicts with the compositor.
if (process.platform === "linux") {
app.commandLine.appendSwitch("gtk-version", "3");
app.commandLine.appendSwitch("enable-transparent-visuals");
app.commandLine.appendSwitch("disable-gpu-compositing");
}
// Wayland: packaged builds use the wrapper script (scripts/afterPack.js) to
// force --ozone-platform=x11 before Electron starts. appendSwitch below is a
// best-effort fallback for unpackaged dev mode (may not take effect on E39+).
if (process.platform === "linux" && process.env.XDG_SESSION_TYPE === "wayland") {
app.commandLine.appendSwitch("enable-features", "WaylandWindowDecorations");
}
// Set desktop filename so Wayland compositors can match windows to the .desktop entry.
// This allows XDG portals (e.g. PipeWire) to persist permissions across sessions.
if (process.platform === "linux") {
app.setDesktopName("open-whispr.desktop");
}
// Group all windows under single taskbar entry on Windows
if (process.platform === "win32") {
const windowsAppId =
APP_CHANNEL === "production" ? BASE_WINDOWS_APP_ID : `${BASE_WINDOWS_APP_ID}.${APP_CHANNEL}`;
app.setAppUserModelId(windowsAppId);
}
function getOAuthProtocol() {
const fromEnv = (process.env.VITE_OPENWHISPR_PROTOCOL || process.env.OPENWHISPR_PROTOCOL || "")
.trim()
.toLowerCase();
if (/^[a-z][a-z0-9+.-]*$/.test(fromEnv)) {
return fromEnv;
}
return (
DEFAULT_OAUTH_PROTOCOL_BY_CHANNEL[APP_CHANNEL] || DEFAULT_OAUTH_PROTOCOL_BY_CHANNEL.production
);
}
const OAUTH_PROTOCOL = getOAuthProtocol();
const { registerLinuxUrlSchemeHandler } = require("./src/helpers/linuxUrlSchemeHandler");
function shouldRegisterProtocolWithAppArg() {
return Boolean(process.defaultApp) || isElectronBinaryExec();
}
function getDefaultHtmlHandler() {
try {
const { execFileSync } = require("child_process");
return (
execFileSync("xdg-mime", ["query", "default", "text/html"], {
encoding: "utf8",
timeout: 3000,
}).trim() || null
);
} catch {
return null;
}
}
function restoreHtmlHandlerIfChanged(original) {
try {
const { execFileSync } = require("child_process");
const current = execFileSync("xdg-mime", ["query", "default", "text/html"], {
encoding: "utf8",
timeout: 3000,
}).trim();
if (current && current !== original) {
execFileSync("xdg-mime", ["default", original, "text/html"], { timeout: 3000 });
}
} catch {
// xdg-mime unavailable or failed
}
}
// True source of truth for whether openwhispr:// resolves on Linux — the same
// MIME database xdg-open consults. Returns true for deb/rpm/flatpak/AUR installs
// (scheme registered via the packaged .desktop MimeType; registerLinuxUrlSchemeHandler
// first takes it back from an AppImage/tar.gz entry) and false for AppImage/tar.gz
// runs whose own registration failed, so we never enable a dead-end OAuth flow.
// Used to recover from setAsDefaultProtocolClient's KDE false negative.
function isOAuthSchemeRegistered() {
if (process.platform !== "linux") return false;
try {
const { execFileSync } = require("child_process");
const handler = execFileSync(
"xdg-mime",
["query", "default", `x-scheme-handler/${OAUTH_PROTOCOL}`],
{ encoding: "utf8", timeout: 3000 }
).trim();
return handler.length > 0;
} catch {
return false;
}
}
// In development, always include the app path argument so macOS/Windows/Linux
// can launch the project app instead of opening bare Electron.
function getProtocolAppArgs() {
if (!shouldRegisterProtocolWithAppArg()) return [];
return [process.argv[1] ? path.resolve(process.argv[1]) : path.resolve(".")];
}
// Register custom protocol for OAuth callbacks.
function registerOpenWhisprProtocol() {
const protocol = OAUTH_PROTOCOL;
const htmlHandler = process.platform === "linux" ? getDefaultHtmlHandler() : null;
const appArgs = getProtocolAppArgs();
let result;
if (appArgs.length > 0) {
result = app.setAsDefaultProtocolClient(protocol, process.execPath, appArgs);
} else {
result = app.setAsDefaultProtocolClient(protocol);
}
if (htmlHandler) {
restoreHtmlHandlerIfChanged(htmlHandler);
}
return result;
}
// On Linux, setAsDefaultProtocolClient can only name open-whispr.desktop, which
// AppImage and tar.gz installs don't have, so those (and development) register
// their own handler entry first and skip it. Otherwise it runs as before, and
// since it returns a false negative on KDE/Wayland, fall back to probing the
// system MIME database for an actual handler. This keeps OAuth enabled where the
// callback can resolve and correctly gated where it can't.
const linuxSchemeHandler =
process.platform === "linux"
? registerLinuxUrlSchemeHandler(OAUTH_PROTOCOL, getProtocolAppArgs())
: null;
const protocolRegistered =
linuxSchemeHandler?.registered || registerOpenWhisprProtocol() || isOAuthSchemeRegistered();
if (!protocolRegistered) {
console.warn(`[Auth] Failed to register ${OAUTH_PROTOCOL}:// protocol handler`);
}
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.exit(0);
}
const isLiveWindow = (window) => window && !window.isDestroyed();
// Ensure macOS menus use the proper casing for the app name
if (process.platform === "darwin" && app.getName() !== "OpenWhispr") {
app.setName("OpenWhispr");
}
// Add global error handling for uncaught exceptions
process.on("uncaughtException", (error) => {
console.error("Uncaught Exception:", error);
// Don't exit the process for EPIPE errors as they're harmless
if (error.code === "EPIPE") {
return;
}
// For other errors, log and continue
console.error("Error stack:", error.stack);
});
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
});
// Import helper module classes (but don't instantiate yet - wait for app.whenReady())
const EnvironmentManager = require("./src/helpers/environment");
const WindowManager = require("./src/helpers/windowManager");
const DatabaseManager = require("./src/helpers/database");
const ClipboardManager = require("./src/helpers/clipboard");
const WhisperManager = require("./src/helpers/whisper");
const ParakeetManager = require("./src/helpers/parakeet");
const DiarizationManager = require("./src/helpers/diarization");
const TrayManager = require("./src/helpers/tray");
const dockManager = require("./src/helpers/dockManager");
const autoStart = require("./src/helpers/autoStart");
const IPCHandlers = require("./src/helpers/ipcHandlers");
const CliBridge = require("./src/helpers/cliBridge");
const UpdateManager = require("./src/updater");
const GlobeKeyManager = require("./src/helpers/globeKeyManager");
const DevServerManager = require("./src/helpers/devServerManager");
const WindowsKeyManager = require("./src/helpers/windowsKeyManager");
const LinuxKeyManager = require("./src/helpers/linuxKeyManager");
const TextEditMonitor = require("./src/helpers/textEditMonitor");
const SelectionManager = require("./src/helpers/selectionManager");
const WhisperCudaManager = require("./src/helpers/whisperCudaManager");
const WhisperVulkanManager = require("./src/helpers/whisperVulkanManager");
const { migrateLegacyBinDir, detectOrphanedGpuPacks } = require("./src/helpers/gpuBinaryManager");
const { resetWhisperGpuFailureOnUpgrade } = require("./src/helpers/whisperGpuUpgradeReset");
const GoogleCalendarManager = require("./src/helpers/googleCalendarManager");
const MicrosoftCalendarManager = require("./src/helpers/microsoftCalendarManager");
const AppleCalendarManager = require("./src/helpers/appleCalendarManager");
const CalendarReminderScheduler = require("./src/helpers/calendarReminderScheduler");
const MeetingProcessDetector = require("./src/helpers/meetingProcessDetector");
const AudioActivityDetector = require("./src/helpers/audioActivityDetector");
const {
collectAudioCaptureHelperPids,
createExcludedProcessIdProvider,
} = require("./src/helpers/electronProcessIds");
const AudioTapManager = require("./src/helpers/audioTapManager");
const LinuxPortalAudioManager = require("./src/helpers/linuxPortalAudioManager");
const WindowsLoopbackAudioManager = require("./src/helpers/windowsLoopbackAudioManager");
const MeetingAecManager = require("./src/helpers/meetingAecManager");
const MeetingDetectionEngine = require("./src/helpers/meetingDetectionEngine");
const { applyOpenWhisprOriginHeader } = require("./src/helpers/sessionHeaders");
const { i18nMain, changeLanguage } = require("./src/helpers/i18nMain");
const { ensureYdotool } = require("./src/helpers/ensureYdotool");
const sidecarRegistry = require("./src/helpers/sidecarRegistry");
const { reapStaleSidecars } = require("./src/helpers/sidecarReaper");
// Manager instances - initialized after app.whenReady()
let debugLogger = null;
let environmentManager = null;
let windowManager = null;
let hotkeyManager = null;
let databaseManager = null;
let clipboardManager = null;
let connectorManager = null;
let whisperManager = null;
let parakeetManager = null;
let diarizationManager = null;
let trayManager = null;
let updateManager = null;
let globeKeyManager = null;
let windowsKeyManager = null;
let linuxKeyManager = null;
let textEditMonitor = null;
let selectionManager = null;
let whisperCudaManager = null;
let whisperVulkanManager = null;
let googleCalendarManager = null;
let microsoftCalendarManager = null;
let appleCalendarManager = null;
let calendarReminderScheduler = null;
let meetingDetectionEngine = null;
let audioTapManager = null;
let linuxPortalAudioManager = null;
let windowsLoopbackAudioManager = null;
let meetingAecManager = null;
let semanticSearch = null;
let ipcHandlers = null;
let cliBridge = null;
let globeKeyAlertShown = false;
let macAccessibilityFeaturesReady = false;
let startMacAccessibilityFeatures = null;
let authBridgeServer = null;
let pendingNoteCloudId = null;
let pendingNoteRetryTimer = null;
let pendingNoteRetryCount = 0;
const WHISPER_WAKE_REWARM_DELAY_MS = 3000;
let wakeRewarmTimer = null;
function parseAuthBridgePort() {
const raw = (process.env.OPENWHISPR_AUTH_BRIDGE_PORT || "").trim();
if (!raw) return DEFAULT_AUTH_BRIDGE_PORT;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
return DEFAULT_AUTH_BRIDGE_PORT;
}
return parsed;
}
const AUTH_BRIDGE_HOST = "127.0.0.1";
const AUTH_BRIDGE_PORT = parseAuthBridgePort();
const AUTH_BRIDGE_PATH = "/oauth/callback";
// Set up PATH for production builds to find system tools (whisper.cpp, ffmpeg)
function setupProductionPath() {
if (process.platform === "darwin" && process.env.NODE_ENV !== "development") {
const commonPaths = [
"/usr/local/bin",
"/opt/homebrew/bin",
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
];
const currentPath = process.env.PATH || "";
const pathsToAdd = commonPaths.filter((p) => !currentPath.includes(p));
if (pathsToAdd.length > 0) {
process.env.PATH = `${currentPath}:${pathsToAdd.join(":")}`;
}
}
}
// Phase 1: Initialize managers + IPC handlers before window content loads
// Best-effort cleanup of the orphaned portal restore-token file older builds wrote. See PR #904.
const LINUX_RESTORE_TOKEN_FILENAME = ".linux-system-audio-restore-token.json";
function cleanupOrphanedLinuxRestoreToken() {
if (process.platform !== "linux") return;
try {
const fs = require("fs");
fs.unlinkSync(path.join(app.getPath("userData"), LINUX_RESTORE_TOKEN_FILENAME));
} catch {}
}
function syncAutoStartEntry() {
try {
if (autoStart.syncAutoStartEntry()) {
debugLogger.info("Re-pointed the launch-at-login entry at the current executable");
}
} catch (error) {
debugLogger.warn("Failed to sync the launch-at-login entry", { error: error?.message });
}
}
// Reading the login item touches the OS, and failing to answer "did the session
// start us?" must not stop the app from starting at all. Falling back to false
// just shows the window, which is what every launch did before.
function wasLaunchedAtLoginHidden() {
try {
return autoStart.wasLaunchedAtLoginHidden();
} catch (error) {
if (debugLogger) debugLogger.warn("Failed to detect a login launch", { error: error?.message });
return false;
}
}
function initializeCoreManagers() {
setupProductionPath();
debugLogger = require("./src/helpers/debugLogger");
debugLogger.ensureFileLogging();
// Registration runs before app ready, when the logger cannot write its file yet.
if (linuxSchemeHandler?.reason) {
debugLogger.warn("Could not register the Linux URL scheme handler entry", {
protocol: OAUTH_PROTOCOL,
reason: linuxSchemeHandler.reason,
protocolRegistered,
});
}
environmentManager = new EnvironmentManager();
const uiLanguage = environmentManager.getUiLanguage(app.getLocale());
changeLanguage(uiLanguage);
debugLogger.refreshLogLevel();
windowManager = new WindowManager();
hotkeyManager = windowManager.hotkeyManager;
databaseManager = new DatabaseManager();
// Restore the last validated account scope before any window, IPC handler,
// or meeting flow can read or create notes. Offline launches keep the
// account's data visible; a stale or rotated credential fails the hash
// check and restores nothing.
const accountScopeBinding = require("./src/helpers/accountScopeBinding");
const bootAccountId = accountScopeBinding.resolveBootAccountScope({
token: require("./src/helpers/tokenStore").get(),
binding: accountScopeBinding.read(),
});
if (bootAccountId) databaseManager.setActiveAccountId(bootAccountId);
clipboardManager = new ClipboardManager();
const { createConnectorManager } = require("./src/helpers/connectors/connectorManager");
const { createPendingActions } = require("./src/helpers/connectors/pendingActions");
const { createActionLog } = require("./src/helpers/connectors/actionLog");
const { createCredentialStore } = require("./src/helpers/connectors/credentialStore");
const { createConnectorCredentials } = require("./src/helpers/connectors/connectorCredentials");
const { createConnectors } = require("./src/helpers/connectors/createConnectors");
const { runOAuthLoopbackFlow, OAuthFlowError } = require("./src/helpers/oauthLoopbackFlow");
const { broadcastToWindows } = require("./src/helpers/windowBroadcast");
const { connectorAccountIdFrom } = require("./src/helpers/connectors/connectorIpc");
// The same account receipts are filed under (ipcHandlers' getAccountScope):
// the one bound to the credential in use. databaseManager's scope doesn't
// move when one signed-in token replaces another.
const getConnectorAccountScope = () =>
accountScopeBinding.resolveActiveAccountScope({
...require("./src/helpers/tokenStore").getState(),
binding: accountScopeBinding.read(),
});
const getConnectorAccountId = connectorAccountIdFrom(getConnectorAccountScope);
const connectorCredentials = createConnectorCredentials({
store: createCredentialStore({
dir: path.join(app.getPath("userData"), "connectors"),
secretCrypto: require("./src/helpers/secretCrypto"),
logger: debugLogger,
}),
getAccountId: getConnectorAccountId,
});
connectorManager = createConnectorManager({
connectors: createConnectors({
fetch: (url, init) => net.fetch(url, { ...init, useSessionCookies: false }),
i18n: i18nMain,
runOAuthLoopbackFlow,
OAuthFlowError,
credentials: connectorCredentials,
logger: debugLogger,
env: process.env,
openExternal: (url) => require("./src/helpers/externalUrlOpener").openExternalUrl(url),
writeClipboard: (text, webContents) => clipboardManager.writeClipboard(text, webContents),
// Read only when revoking a Gmail login on the calendar's Google project;
// the calendar manager is created below.
getGoogleCalendarAccounts: () => googleCalendarManager?.getAccounts() ?? [],
broadcast: broadcastToWindows,
notifyStatusChanged: () => void connectorManager?.notifyStatusChanged(),
}),
pendingActions: createPendingActions(),
actionLog: createActionLog(databaseManager),
logger: debugLogger,
getAccountId: getConnectorAccountId,
credentials: connectorCredentials,
onStatusChanged: (statuses) => broadcastToWindows("connector-status-changed", statuses),
});
// Pending cards expire in main even when no renderer ever answers them.
setInterval(() => connectorManager.sweepExpired(), 60 * 1000).unref();
whisperManager = new WhisperManager();
if (process.platform !== "darwin") {
whisperCudaManager = new WhisperCudaManager();
whisperVulkanManager = new WhisperVulkanManager();
// Heal installs from before GPU packs got per-pack directories; must run
// before startup pre-warm resolves any GPU binary path.
const LlamaVulkanManager = require("./src/helpers/llamaVulkanManager");
const llamaVulkanManager = new LlamaVulkanManager();
const clearedPacks = migrateLegacyBinDir([
whisperCudaManager,
whisperVulkanManager,
llamaVulkanManager,
]);
if (clearedPacks.length > 0) {
// No window exists yet — persist the notice; a control panel window
// shows it as a toast and clears it. See #1606.
require("./src/helpers/gpuPackMigrationNotice").record(clearedPacks);
}
// The 1.8.3 migration deleted lib-carrying packs without recording that
// notice, leaving those users on a silent CPU fallback: an enabled flag
// with no pack on disk only happens via such data loss. recordOnce gates
// each pack to one notice so a dismissed toast doesn't return every launch.
const orphanedPacks = detectOrphanedGpuPacks([
{ manager: whisperCudaManager, enabledEnvVar: "WHISPER_CUDA_ENABLED" },
{ manager: whisperVulkanManager, enabledEnvVar: "WHISPER_VULKAN_ENABLED" },
{ manager: llamaVulkanManager, enabledEnvVar: "LLAMA_VULKAN_ENABLED" },
]);
if (orphanedPacks.length > 0) {
require("./src/helpers/gpuPackMigrationNotice").recordOnce(orphanedPacks);
}
// Lets every server start resolve its GPU backend from installed packs
whisperManager.setGpuBinaryManagers({ cuda: whisperCudaManager, vulkan: whisperVulkanManager });
}
parakeetManager = new ParakeetManager();
diarizationManager = new DiarizationManager();
calendarReminderScheduler = new CalendarReminderScheduler(databaseManager);
googleCalendarManager = new GoogleCalendarManager(
databaseManager,
windowManager,
calendarReminderScheduler
);
microsoftCalendarManager = new MicrosoftCalendarManager(
databaseManager,
calendarReminderScheduler
);
appleCalendarManager = new AppleCalendarManager(databaseManager, calendarReminderScheduler);
const meetingProcessDetector = new MeetingProcessDetector();
meetingDetectionEngine = new MeetingDetectionEngine(
calendarReminderScheduler,
meetingProcessDetector,
new AudioActivityDetector(
// The capture-helper managers are created a few lines below; the provider
// is only invoked on mic events, long after initialization completes.
createExcludedProcessIdProvider(() =>
collectAudioCaptureHelperPids([
audioTapManager,
linuxPortalAudioManager,
windowsLoopbackAudioManager,
])
),
() => meetingProcessDetector.getDetectedProcesses().length > 0
),
windowManager,
databaseManager
);
windowManager.meetingDetectionEngine = meetingDetectionEngine;
calendarReminderScheduler.meetingDetectionEngine = meetingDetectionEngine;
updateManager = new UpdateManager();
updateManager.setWindowManager(windowManager);
windowsKeyManager = new WindowsKeyManager();
linuxKeyManager = new LinuxKeyManager();
textEditMonitor = new TextEditMonitor();
selectionManager = new SelectionManager({ clipboardManager, textEditMonitor });
audioTapManager = new AudioTapManager();
linuxPortalAudioManager = new LinuxPortalAudioManager();
windowsLoopbackAudioManager = new WindowsLoopbackAudioManager();
// Warm the capability cache off the hot path so the first meeting start
// doesn't pay the probe spawn. No-ops on non-Windows.
windowsLoopbackAudioManager.getCapability().catch(() => {});
cleanupOrphanedLinuxRestoreToken();
syncAutoStartEntry();
meetingAecManager = new MeetingAecManager();
windowManager.textEditMonitor = textEditMonitor;
windowManager.selectionManager = selectionManager;
windowManager.windowsKeyManager = windowsKeyManager;
windowManager.linuxKeyManager = linuxKeyManager;
if (process.platform === "linux") {
windowManager.hotkeyManager.nativeListenerProbe = () => linuxKeyManager.checkAvailability();
} else if (process.platform === "win32") {
windowManager.hotkeyManager.nativeListenerProbe = () => windowsKeyManager.checkAvailability();
}
// IPC handlers must be registered before window content loads
ipcHandlers = new IPCHandlers({
environmentManager,
databaseManager,
clipboardManager,
whisperManager,
parakeetManager,
diarizationManager,
windowManager,
updateManager,
windowsKeyManager,
linuxKeyManager,
textEditMonitor,
selectionManager,
whisperCudaManager,
whisperVulkanManager,
googleCalendarManager,
microsoftCalendarManager,
appleCalendarManager,
connectorManager,
meetingDetectionEngine,
audioTapManager,
linuxPortalAudioManager,
windowsLoopbackAudioManager,
meetingAecManager,
getSemanticSearch: () => semanticSearch,
getTrayManager: () => trayManager,
oauthProtocolRegistered: protocolRegistered,
oauthProtocol: OAUTH_PROTOCOL,
});
}
function registerSidecars() {
if (whisperManager) sidecarRegistry.register("whisper", () => whisperManager.stopServer());
if (parakeetManager) sidecarRegistry.register("parakeet", () => parakeetManager.stopServer());
if (diarizationManager) {
sidecarRegistry.register("diarization", () => diarizationManager.shutdown());
}
const modelManager = require("./src/helpers/modelManagerBridge").default;
sidecarRegistry.register("llama", () => modelManager.stopServer());
const onnxWorkerClient = require("./src/helpers/onnxWorkerClient");
sidecarRegistry.register("onnx", () => onnxWorkerClient.stop());
}
// Phase 2: Non-critical setup after windows are visible
function initializeDeferredManagers() {
ensureYdotool().catch((err) => {
require("./src/helpers/debugLogger").warn(
"ydotool setup error",
{ error: err?.message },
"clipboard"
);
});
if (process.platform !== "darwin") {
clipboardManager.preWarmAccessibility();
}
trayManager = new TrayManager();
globeKeyManager = new GlobeKeyManager({
// Lets the listener put the user's macOS Globe action back after a crash.
preferenceStatePath: path.join(app.getPath("userData"), "globe-preference-state.json"),
});
if (process.platform === "darwin") {
globeKeyManager.on("error", (error) => {
if (globeKeyAlertShown) {
return;
}
globeKeyAlertShown = true;
const detailLines = [
error?.message || i18nMain.t("startup.globeHotkey.details.unknown"),
i18nMain.t("startup.globeHotkey.details.fallback"),
];
if (process.env.NODE_ENV === "development") {
detailLines.push(i18nMain.t("startup.globeHotkey.details.devHint"));
} else {
detailLines.push(i18nMain.t("startup.globeHotkey.details.reinstallHint"));
}
dialog.showMessageBox({
type: "warning",
title: i18nMain.t("startup.globeHotkey.title"),
message: i18nMain.t("startup.globeHotkey.message"),
detail: detailLines.join("\n\n"),
});
});
}
googleCalendarManager.start();
microsoftCalendarManager.start();
appleCalendarManager.start();
meetingDetectionEngine.start();
}
app.on("open-url", (event, url) => {
event.preventDefault();
if (!url.startsWith(`${OAUTH_PROTOCOL}://`)) return;
if (url.includes("upgrade-success")) {
handleUpgradeDeepLink();
return;
}
if (isNoteDeepLink(url)) {
void handleNoteDeepLink(url);
return;
}
if (isInvitationDeepLink(url)) {
handleInvitationDeepLink(url);
return;
}
void handleOAuthDeepLink(url);
if (windowManager && isLiveWindow(windowManager.controlPanelWindow)) {
windowManager.controlPanelWindow.show();
windowManager.controlPanelWindow.focus();
dockManager.setControlPanelVisible(true);
}
});
function isInvitationDeepLink(url) {
return url.slice(`${OAUTH_PROTOCOL}://`.length).startsWith("invitations/");
}
// Deep links can arrive before windowManager exists (cold start) or before the
// renderer has mounted its listener. The token is stashed here and the renderer
// pulls it via `get-pending-invitation-token` on mount; the push below is a
// best-effort fast path for an already-running app.
let pendingInvitationDeepLinkToken = null;
ipcMain.handle("get-pending-invitation-token", () => {
const token = pendingInvitationDeepLinkToken;
pendingInvitationDeepLinkToken = null;
return token;
});
function isNoteDeepLink(url) {
return url.slice(`${OAUTH_PROTOCOL}://`.length).startsWith("notes/");
}
function parseNoteCloudId(deepLinkUrl) {
try {
const match = deepLinkUrl.match(/notes\/([^/?#]+)/);
const cloudId = match?.[1] ? decodeURIComponent(match[1]) : "";
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(cloudId)
? cloudId
: null;
} catch {
return null;
}
}
function clearPendingNoteDeepLink() {
clearTimeout(pendingNoteRetryTimer);
pendingNoteRetryTimer = null;
pendingNoteCloudId = null;
pendingNoteRetryCount = 0;
}
async function flushPendingNoteDeepLink() {
if (!pendingNoteCloudId || !windowManager || !databaseManager) return;
try {
// Surface the panel on the first attempt only; retries just poll the
// database so they can't repeatedly steal focus.
if (pendingNoteRetryCount === 0) {
await windowManager.createControlPanelWindow();
}
const note = databaseManager.getNoteByCloudId(pendingNoteCloudId);
if (!note) {
// Cloud sync may still be hydrating during a cold launch. Retry briefly so
// the handoff can resolve a note pulled after the protocol event arrived.
pendingNoteRetryCount += 1;
if (pendingNoteRetryCount <= 10) {
clearTimeout(pendingNoteRetryTimer);
pendingNoteRetryTimer = setTimeout(() => {
void flushPendingNoteDeepLink();
}, 1000);
} else {
console.warn("Note deep link could not resolve a local note", {
cloudId: pendingNoteCloudId,
});
clearPendingNoteDeepLink();
}
return;
}
const payload = { noteId: note.id, folderId: note.folder_id ?? null };
clearPendingNoteDeepLink();
await windowManager.queueNoteNavigation(payload);
} catch (error) {
console.error("Note deep link failed:", error);
clearPendingNoteDeepLink();
}
}
async function handleNoteDeepLink(deepLinkUrl) {
const cloudId = parseNoteCloudId(deepLinkUrl);
if (!cloudId) {
console.warn("Invalid note deep link");
return;
}
clearPendingNoteDeepLink();
pendingNoteCloudId = cloudId;
await flushPendingNoteDeepLink();
}
function handleInvitationDeepLink(deepLinkUrl) {
try {
const match = deepLinkUrl.match(/invitations\/([^/?#]+)/);
const token = match?.[1];
if (!token) return;
pendingInvitationDeepLinkToken = token;
if (!windowManager) return;
if (isLiveWindow(windowManager.controlPanelWindow)) {
windowManager.controlPanelWindow.show();
windowManager.controlPanelWindow.focus();
dockManager.setControlPanelVisible(true);
// Best-effort fast path — the get-pending-invitation-token pull is the reliable path.
windowManager.controlPanelWindow.webContents.send("workspace-invitation-token", token);
} else {
windowManager.createControlPanelWindow();
}
} catch (error) {
console.error("Invitation deep link parse failed:", error);
}
}
function resolveAuthUrl() {
const fs = require("fs");
const envPath = path.join(__dirname, "src", "dist", "runtime-env.json");
let runtimeEnv = {};
try {
if (fs.existsSync(envPath)) runtimeEnv = JSON.parse(fs.readFileSync(envPath, "utf8"));
} catch {}
return (
process.env.AUTH_URL ||
process.env.VITE_AUTH_URL ||
runtimeEnv.VITE_AUTH_URL ||
"https://auth.openwhispr.com"
);
}
function getOauthCookieName() {
return process.env.NODE_ENV === "production"
? "__Secure-openwhispr.session_token"
: "openwhispr.session_token";
}
// Older website builds send the signed cookie value as `?token=`; trade it
// for the raw session.token the bearer plugin expects.
async function exchangeSignedTokenForRawBearer(signedToken) {
try {
const res = await net.fetch(`${resolveAuthUrl()}/api/auth/get-session`, {
headers: { Cookie: `${getOauthCookieName()}=${signedToken}` },
signal: AbortSignal.timeout(5000),
useSessionCookies: false,
});
if (!res.ok) return null;
const data = await res.json();
return data?.session?.token || null;
} catch (err) {
if (debugLogger) {
debugLogger.warn("Signed-token bearer exchange failed (non-fatal)", {
error: err?.message,
});
}
return null;
}
}
// One-time bridge for users upgrading from a build that injected the session
// cookie into Electron's jar: exchange the existing cookie for a raw bearer
// token, store it, and remove the cookie. Non-fatal — failures fall through
// to the normal sign-in flow.
async function migrateCookieToBearerToken() {
const tokenStore = require("./src/helpers/tokenStore");
if (tokenStore.get()) return;
const cookieName = getOauthCookieName();
const authUrl = resolveAuthUrl();
try {
const cookies = await session.defaultSession.cookies.get({ url: authUrl, name: cookieName });
if (!cookies.length) return;
const rawToken = await exchangeSignedTokenForRawBearer(cookies[0].value);
if (!rawToken) return;
tokenStore.set(rawToken);
await session.defaultSession.cookies.remove(authUrl, cookieName);
if (debugLogger) debugLogger.debug("Migrated cookie to bearer token");
} catch (err) {
if (debugLogger) {
debugLogger.warn("Cookie→bearer token migration failed (non-fatal)", {
error: err?.message,
});
}
}
}
// Persist the bearer token and reload the control panel so the renderer's
// authClient sends `Authorization: Bearer <token>` on its next request.
async function applySessionTokenAndRefresh(token) {
if (!token) return;
if (!isLiveWindow(windowManager?.controlPanelWindow)) return;
const tokenStore = require("./src/helpers/tokenStore");
tokenStore.set(token);
const appUrl = DevServerManager.getAppUrl(true);
if (appUrl) {
windowManager.controlPanelWindow.loadURL(appUrl);
} else {
const fileInfo = DevServerManager.getAppFilePath(true);
if (fileInfo) {
windowManager.controlPanelWindow.loadFile(fileInfo.path, { query: fileInfo.query });
}
}
if (debugLogger) {
debugLogger.debug("Applied bearer token and reloaded control panel", {
appChannel: APP_CHANNEL,
oauthProtocol: OAUTH_PROTOCOL,
});
}
windowManager.controlPanelWindow.show();
windowManager.controlPanelWindow.focus();
dockManager.setControlPanelVisible(true);
}
async function handleOAuthDeepLink(deepLinkUrl) {
try {
const parsed = new URL(deepLinkUrl);
const bearerToken = parsed.searchParams.get("bearer_token");
if (bearerToken) {
void applySessionTokenAndRefresh(bearerToken);
return;
}
const signedToken = parsed.searchParams.get("token");
if (!signedToken) return;
const rawToken = await exchangeSignedTokenForRawBearer(signedToken);
if (rawToken) void applySessionTokenAndRefresh(rawToken);
} catch (err) {
if (debugLogger) debugLogger.error("Failed to handle OAuth deep link:", err);
}
}
function handleUpgradeDeepLink() {
if (isLiveWindow(windowManager?.controlPanelWindow)) {
windowManager.controlPanelWindow.webContents.executeJavaScript(
'window.dispatchEvent(new Event("upgrade-success"))'
);
windowManager.controlPanelWindow.show();
windowManager.controlPanelWindow.focus();
dockManager.setControlPanelVisible(true);
}
}
function parseJsonBody(req) {
return new Promise((resolve, reject) => {
let raw = "";
req.on("data", (chunk) => {
raw += chunk;
if (raw.length > 32 * 1024) {
reject(new Error("Request body too large"));
req.destroy();
}
});
req.on("end", () => {
if (!raw) return resolve({});
try {
resolve(JSON.parse(raw));
} catch {
reject(new Error("Invalid JSON payload"));
}
});
req.on("error", reject);
});
}
function writeCorsHeaders(res) {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
}
function startAuthBridgeServer() {
if (APP_CHANNEL !== "development" || authBridgeServer) {
return;
}
authBridgeServer = http.createServer(async (req, res) => {
writeCorsHeaders(res);
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
const requestUrl = new URL(req.url || "/", `http://${AUTH_BRIDGE_HOST}:${AUTH_BRIDGE_PORT}`);
if (requestUrl.pathname !== AUTH_BRIDGE_PATH) {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not found");
return;
}
let token = requestUrl.searchParams.get("bearer_token") || requestUrl.searchParams.get("token");
if (!token && req.method === "POST") {
try {
const body = await parseJsonBody(req);
token = body?.bearer_token || body?.token || null;
} catch (error) {
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
res.end(error.message || "Invalid request");
return;
}
}
if (!token) {
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Missing token");
return;
}
void applySessionTokenAndRefresh(token);
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(
"<html><body><h3>OpenWhispr sign-in complete.</h3><p>You can close this tab.</p></body></html>"
);
});
authBridgeServer.on("error", (error) => {
if (debugLogger) {
debugLogger.error("OAuth auth bridge server failed:", error);
}
});
authBridgeServer.listen(AUTH_BRIDGE_PORT, AUTH_BRIDGE_HOST, () => {
if (debugLogger) {
debugLogger.debug("OAuth auth bridge server started", {
url: `http://${AUTH_BRIDGE_HOST}:${AUTH_BRIDGE_PORT}${AUTH_BRIDGE_PATH}`,
});
}
});
}
// Main application startup
async function startApp() {
// Await so a stale sidecar is confirmed dead before new ones can spawn and
// contend for its port or storage lock.
await reapStaleSidecars();
// Phase 1: Core managers + IPC handlers before windows
initializeCoreManagers();
await environmentManager.init();
// After any upgrade the GPU gets one fresh attempt: clear the remembered
// failure before the whisper pre-warm below resolves its GPU backend.
resetWhisperGpuFailureOnUpgrade(environmentManager);
registerSidecars();
startAuthBridgeServer();
cliBridge = new CliBridge(ipcHandlers);
cliBridge.start().catch((err) => {
debugLogger.error("CLI bridge failed to start", { error: err.message });
cliBridge = null;
});
await migrateCookieToBearerToken();
applyOpenWhisprOriginHeader(session.defaultSession);
await windowManager.setActivationModeCache(environmentManager.getActivationMode());
windowManager.setFloatingIconAutoHide(environmentManager.getFloatingIconAutoHide());
windowManager.setPanelStartPosition(environmentManager.getPanelStartPosition());
let activationModeChangeQueue = Promise.resolve();
ipcMain.on("activation-mode-changed", (_event, mode) => {
activationModeChangeQueue = activationModeChangeQueue
.then(async () => {
const success = await windowManager.setActivationModeCache(mode);
const effectiveMode = windowManager.getActivationMode();
if (success) {
environmentManager.saveActivationMode(effectiveMode);
} else {
for (const browserWindow of BrowserWindow.getAllWindows()) {
if (!browserWindow.isDestroyed()) {
browserWindow.webContents.send("setting-updated", {
key: "activationMode",
value: effectiveMode,
});
}
}
}
windowManager.resetWindowsPushState();
windowManager.reconcileNativeKeyListeners();
})
.catch((err) => {
debugLogger.error("Failed to change activation mode", { error: err.message }, "hotkey");
});
});
ipcMain.on("floating-icon-auto-hide-changed", (_event, enabled) => {
windowManager.setFloatingIconAutoHide(enabled);
environmentManager.saveFloatingIconAutoHide(enabled);
// Relay to the floating icon window so it can react immediately
if (windowManager.mainWindow && !windowManager.mainWindow.isDestroyed()) {
windowManager.mainWindow.webContents.send("floating-icon-auto-hide-changed", enabled);
}
});
ipcMain.on("start-minimized-changed", (_event, enabled) => {
if (debugLogger) debugLogger.info("Start minimized changed", { enabled });
environmentManager.saveStartMinimized(enabled);
});
ipcMain.on("panel-start-position-changed", (_event, position) => {
windowManager.setPanelStartPosition(position);
environmentManager.savePanelStartPosition(position);
});
dockManager.init();
// In development, wait for Vite dev server to be ready
if (process.env.NODE_ENV === "development") {
await new Promise((resolve) => setTimeout(resolve, 500));
}
// Create windows FIRST so the user sees UI as soon as possible.
// A login launch goes to the tray whatever the preference says: the user asked
// the OS to start us, not to put a window in front of them at every login.
const launchedHidden = wasLaunchedAtLoginHidden();
const startMinimized = environmentManager.getStartMinimized() || launchedHidden;
if (debugLogger) debugLogger.info("Start minimized", { enabled: startMinimized, launchedHidden });
await windowManager.createMainWindow();
// The activation mode was cached before the hotkey was registered, so a saved
// Hold could not be checked against its key until now.
if (
windowManager.getActivationMode() === "push" &&
!windowManager.hotkeyManager.supportsPushToTalk()
) {
await windowManager.setActivationModeCache("tap");
environmentManager.saveActivationMode("tap");
for (const browserWindow of BrowserWindow.getAllWindows()) {
if (!browserWindow.isDestroyed()) {
browserWindow.webContents.send("setting-updated", { key: "activationMode", value: "tap" });
}
}
}
if (!startMinimized) {
await windowManager.createControlPanelWindow();
}
// Windows/Linux cold start delivers protocol URLs via argv (macOS uses
// open-url); without this scan a deep link that launches the app is lost.
const initialProtocolUrl = process.argv.find((arg) => arg.startsWith(`${OAUTH_PROTOCOL}://`));
if (initialProtocolUrl && isNoteDeepLink(initialProtocolUrl)) {
await handleNoteDeepLink(initialProtocolUrl);
} else {
if (initialProtocolUrl && process.platform !== "darwin") {
if (initialProtocolUrl.includes("upgrade-success")) {
handleUpgradeDeepLink();
} else if (isInvitationDeepLink(initialProtocolUrl)) {
handleInvitationDeepLink(initialProtocolUrl);
} else {
void handleOAuthDeepLink(initialProtocolUrl);
}
}
await flushPendingNoteDeepLink();
}
await hotkeyManager.hyprlandRegistrationReady;
// Set up voice agent hotkey (dictation routed straight to the dictation
// agent, bypassing cleanup). Tap-only slots gate autorepeat like the
// dictation toggle does.
const isVoiceAgentPress = createHotkeyRepeatGate();
const voiceAgentHotkeyCallback = () => {
if (!isVoiceAgentPress()) return;
windowManager.sendToggleVoiceAgent();
};
windowManager._voiceAgentHotkeyCallback = voiceAgentHotkeyCallback;
const savedVoiceAgentKey = environmentManager.getVoiceAgentKey?.() || "";
if (savedVoiceAgentKey) {
const result = await hotkeyManager.registerSlot(
"voiceAgent",
savedVoiceAgentKey,
voiceAgentHotkeyCallback
);
if (!result.success) {
debugLogger.warn(
"Failed to restore voice agent hotkey",
{ hotkey: savedVoiceAgentKey },
"hotkey"
);
}
hotkeyManager.notifyRestoreFailures(savedVoiceAgentKey, result);
}
// Set up translation hotkey (dictation cleaned up and translated into the
// configured target language before pasting)
const isTranslationPress = createHotkeyRepeatGate();
const translationHotkeyCallback = () => {
if (!isTranslationPress()) return;
windowManager.sendToggleTranslation();
};
windowManager._translationHotkeyCallback = translationHotkeyCallback;
const savedTranslationKey = environmentManager.getTranslationKey?.() || "";
if (savedTranslationKey) {
const result = await hotkeyManager.registerSlot(
"translation",
savedTranslationKey,
translationHotkeyCallback
);
if (!result.success) {
debugLogger.warn(
"Failed to restore translation hotkey",
{ hotkey: savedTranslationKey },
"hotkey"
);
}
hotkeyManager.notifyRestoreFailures(savedTranslationKey, result);
}
// Set up meeting mode hotkey
const isMeetingPress = createHotkeyRepeatGate();
const meetingHotkeyCallback = () => {
if (!isMeetingPress()) return;
debugLogger.info("Meeting hotkey triggered", {}, "meeting");
windowManager.startManualMeeting();
};
const savedMeetingKey = environmentManager.getMeetingKey?.() || "";
if (savedMeetingKey) {
const result = await hotkeyManager.registerSlot(
"meeting",
savedMeetingKey,
meetingHotkeyCallback
);
debugLogger.info(
"Meeting hotkey startup registration",
{ savedMeetingKey, ...result },
"meeting"
);
hotkeyManager.notifyRestoreFailures(savedMeetingKey, result);
}
ipcMain.handle("register-meeting-hotkey", async (_event, hotkey) => {
if (hotkey) {
const result = await hotkeyManager.registerSlot("meeting", hotkey, meetingHotkeyCallback, {
atomic: true,
});
windowManager.reconcileNativeKeyListeners();
if (result.success) {
environmentManager.saveMeetingKey(hotkey);
return { success: true };
}
return { success: false, message: result.error };
} else {
const removed = await hotkeyManager.unregisterSlot("meeting");
if (removed === false) return { success: false };
environmentManager.saveMeetingKey("");
windowManager.reconcileNativeKeyListeners();
return { success: true };
}
});
// Phase 2: Initialize remaining managers after windows are visible
initializeDeferredManagers();
if (process.platform === "darwin") {
// Restore a Globe preference marker left by a crash without starting any
// Accessibility-protected event monitors during onboarding.
await globeKeyManager.restoreLeftoverSystemPreference();
}
app.on("browser-window-focus", () => {
if (googleCalendarManager) googleCalendarManager.syncOnFocus();
if (microsoftCalendarManager) microsoftCalendarManager.syncOnFocus();
if (appleCalendarManager) appleCalendarManager.syncOnFocus();
});
const { powerMonitor } = require("electron");
powerMonitor.on("resume", () => {
if (calendarReminderScheduler) calendarReminderScheduler.onWakeFromSleep();
if (googleCalendarManager) {
googleCalendarManager.onWakeFromSleep();
}
if (microsoftCalendarManager) microsoftCalendarManager.onWakeFromSleep();
if (appleCalendarManager) appleCalendarManager.onWakeFromSleep();
// Sleep evicts the local GPU model from VRAM; reload it once the driver settles. See #766.
if (wakeRewarmTimer) clearTimeout(wakeRewarmTimer);
wakeRewarmTimer = setTimeout(() => {
wakeRewarmTimer = null;
whisperManager?.onWakeFromSleep().catch((err) => {
debugLogger.debug("whisper wake re-warm error (non-fatal)", { error: err.message });
});
}, WHISPER_WAKE_REWARM_DELAY_MS);
});
// Non-blocking server pre-warming; GPU backend resolved by the manager
const whisperSettings = {
localTranscriptionProvider: process.env.LOCAL_TRANSCRIPTION_PROVIDER || "",
whisperModel: process.env.LOCAL_WHISPER_MODEL,
};
whisperManager.initializeAtStartup(whisperSettings).catch((err) => {
debugLogger.debug("Whisper startup init error (non-fatal)", { error: err.message });
});
const parakeetSettings = {
localTranscriptionProvider: process.env.LOCAL_TRANSCRIPTION_PROVIDER || "",
parakeetModel: process.env.PARAKEET_MODEL,
language: process.env.DICTATION_LANGUAGE,
};
parakeetManager.initializeAtStartup(parakeetSettings).catch((err) => {
debugLogger.debug("Parakeet startup init error (non-fatal)", { error: err.message });
});
// TODO: drop legacy REASONING_PROVIDER / LOCAL_REASONING_MODEL fallbacks after 2 releases.
const cleanupProvider = process.env.CLEANUP_PROVIDER || process.env.REASONING_PROVIDER;
const cleanupLocalModel = process.env.LOCAL_CLEANUP_MODEL || process.env.LOCAL_REASONING_MODEL;
if (cleanupProvider === "local" && cleanupLocalModel) {
const modelManager = require("./src/helpers/modelManagerBridge").default;
modelManager.prewarmServer(cleanupLocalModel).catch((err) => {
debugLogger.debug("llama-server pre-warm error (non-fatal)", { error: err.message });
});
}
if (
process.env.DICTATION_AGENT_PROVIDER === "local" &&
process.env.LOCAL_DICTATION_AGENT_MODEL &&
process.env.LOCAL_DICTATION_AGENT_MODEL !== cleanupLocalModel
) {
const modelManager = require("./src/helpers/modelManagerBridge").default;
modelManager.prewarmServer(process.env.LOCAL_DICTATION_AGENT_MODEL).catch((err) => {
debugLogger.debug("dictation-agent llama-server pre-warm error (non-fatal)", {
error: err.message,
});
});
}
// Auto-download diarization models if binary is available
if (
diarizationManager.getBinaryPath() &&
(!diarizationManager.isModelDownloaded() || !diarizationManager.isVadModelDownloaded())
) {
diarizationManager.downloadModels().catch((err) => {
debugLogger.debug("Diarization model auto-download error (non-fatal)", {
error: err.message,
});
});
}
const QdrantManager = require("./src/helpers/qdrantManager");
const SemanticSearchLifecycle = require("./src/helpers/semanticSearchLifecycle");
const localEmbeddings = require("./src/helpers/localEmbeddings");
const qdrantManager = new QdrantManager();
semanticSearch = new SemanticSearchLifecycle({
qdrant: qdrantManager,
vectorIndex: require("./src/helpers/vectorIndex"),
embeddings: localEmbeddings,
noteEmbedText: localEmbeddings.LocalEmbeddings.noteEmbedText,
database: databaseManager,
logger: debugLogger,
});
sidecarRegistry.register("qdrant", () => semanticSearch.stop());
if (process.platform === "win32") {
const nircmdStatus = clipboardManager.getNircmdStatus();
debugLogger.debug("Windows paste tool status", nircmdStatus);
}
trayManager.setWindows(windowManager.mainWindow, windowManager.controlPanelWindow);
trayManager.setWindowManager(windowManager);
// The tray's listen item is a toggle, so it has to rebuild when dictation
// starts or stops.
windowManager.onDictationStateChanged = () => trayManager.updateTrayMenu();
trayManager.setCreateControlPanelCallback(() => windowManager.createControlPanelWindow());
await trayManager.createTray();
updateManager.checkForUpdatesOnStartup();
if (process.platform === "darwin") {
const { isGlobeLikeHotkey, isMouseButtonHotkey } = require("./src/helpers/hotkeyManager");
let globeKeyDownTime = 0;
let globeKeyIsRecording = false;
let globeLastStopTime = 0;
const MIN_HOLD_DURATION_MS = 150;
const POST_STOP_COOLDOWN_MS = 300;
globeKeyManager.on("globe-down", async () => {
const currentHotkey = hotkeyManager.getCurrentHotkey && hotkeyManager.getCurrentHotkey();
const mainWindowLive = isLiveWindow(windowManager.mainWindow);
debugLogger?.debug("[Globe] globe-down received", {
currentHotkey,
mainWindowLive,
activationMode: mainWindowLive ? windowManager.getActivationMode() : "n/a",
});
// Forward to control panel for hotkey capture
if (isLiveWindow(windowManager.controlPanelWindow)) {
windowManager.controlPanelWindow.webContents.send("globe-key-pressed");
}
// Handle dictation if Globe/Fn is one of the dictation hotkeys
const dictationUsesGlobe = hotkeyManager.getSlotHotkeys("dictation").some(isGlobeLikeHotkey);
if (dictationUsesGlobe) {
if (mainWindowLive && windowManager.isDictationProcessing()) {
debugLogger?.debug("[Globe] Ignored — dictation processing");
} else if (mainWindowLive) {
// Capture target app PID BEFORE showing the overlay
if (textEditMonitor) textEditMonitor.captureTargetPid();
const activationMode = windowManager.getActivationMode();
if (activationMode === "push") {
const now = Date.now();
if (now - globeLastStopTime < POST_STOP_COOLDOWN_MS) {
debugLogger?.debug("[Globe] Ignored — cooldown active");
return;
}
windowManager.showDictationPanel();
windowManager.sendPrepareDictation();
const pressTime = now;
globeKeyDownTime = pressTime;
globeKeyIsRecording = false;
setTimeout(async () => {
if (globeKeyDownTime === pressTime && !globeKeyIsRecording) {
globeKeyIsRecording = true;
debugLogger?.debug("[Globe] Starting dictation (push hold)");
windowManager.sendStartDictation();
}
}, MIN_HOLD_DURATION_MS);
} else {
windowManager.sendToggleDictation();
}
} else {
debugLogger?.debug("[Globe] Ignored — mainWindow not live");
}
}
// Check voice agent slot for Globe/Fn key
const voiceAgentUsesGlobe = hotkeyManager
.getSlotHotkeys("voiceAgent")
.some(isGlobeLikeHotkey);
const translationUsesGlobe = hotkeyManager
.getSlotHotkeys("translation")
.some(isGlobeLikeHotkey);
if (voiceAgentUsesGlobe) {
windowManager.sendToggleVoiceAgent();
}
if (translationUsesGlobe) {
windowManager.sendToggleTranslation();
}
if (!voiceAgentUsesGlobe && !translationUsesGlobe && !dictationUsesGlobe) {
debugLogger?.debug("[Globe] Ignored — hotkey is not GLOBE", { currentHotkey });
}
});
globeKeyManager.on("globe-up", async () => {
debugLogger?.debug("[Globe] globe-up received", { wasRecording: globeKeyIsRecording });
// Forward to control panel for hotkey capture (Fn key released)
if (isLiveWindow(windowManager.controlPanelWindow)) {
windowManager.controlPanelWindow.webContents.send("globe-key-released");
}
if (hotkeyManager.getSlotHotkeys("dictation").some(isGlobeLikeHotkey)) {
const activationMode = windowManager.getActivationMode();
if (activationMode === "push") {
if (globeKeyDownTime === 0 && !globeKeyIsRecording) {
// The press was ignored (dictation was processing); releasing it
// must not cancel preparation or hide the thinking pill.
debugLogger?.debug("[Globe] Release without a registered press — ignored");
} else {
globeKeyDownTime = 0;
globeLastStopTime = Date.now();
if (globeKeyIsRecording) {
globeKeyIsRecording = false;
debugLogger?.debug("[Globe] Stopping dictation (push release)");
windowManager.sendStopDictation();
} else {
windowManager.sendCancelDictationPreparation();
windowManager.hideDictationPanel();
}
}
}
}
// Fn release also stops compound push-to-talk for Fn+F-key hotkeys
windowManager.handleMacPushModifierUp("fn");
});
// Another key was pressed while Fn was held — user is using Fn as a
// navigation modifier (Fn+Arrow → Home, Fn+Backspace → Forward Delete, etc.).
// Cancel any bare-Fn push-to-talk in progress instead of transcribing noise.
// Only the bare-Fn path uses globeKeyDownTime/globeKeyIsRecording, so compound
// Fn-hotkey push-to-talk and tap mode are untouched.
globeKeyManager.on("globe-interrupted", () => {
if (globeKeyDownTime === 0 && !globeKeyIsRecording) {
return;
}
const wasRecording = globeKeyIsRecording;
debugLogger?.debug("[Globe] Fn+key interrupted push-to-talk", { wasRecording });
globeKeyDownTime = 0;
globeKeyIsRecording = false;
globeLastStopTime = Date.now();
if (wasRecording) {
windowManager.sendCancelDictation();
} else {
windowManager.sendCancelDictationPreparation();
windowManager.hideDictationPanel();
}
});
globeKeyManager.on("modifier-up", (modifier) => {
if (windowManager?.handleMacPushModifierUp) {
windowManager.handleMacPushModifierUp(modifier);
}
});
// Right-side single modifier handling (e.g., RightOption as hotkey)
let rightModDownTime = 0;
let rightModIsRecording = false;
let rightModLastStopTime = 0;
let rightModActiveKey = null;
globeKeyManager.on("right-modifier-down", async (modifier) => {
// Check voice agent slot for right-modifier
if (hotkeyManager.slotHasHotkey("voiceAgent", modifier)) {
windowManager.sendToggleVoiceAgent();
}
if (hotkeyManager.slotHasHotkey("translation", modifier)) {
windowManager.sendToggleTranslation();
}
if (!hotkeyManager.slotHasHotkey("dictation", modifier)) return;
if (!isLiveWindow(windowManager.mainWindow)) return;
if (windowManager.isDictationProcessing()) return;
const activationMode = windowManager.getActivationMode();
if (textEditMonitor) textEditMonitor.captureTargetPid();
if (activationMode === "push") {
if (rightModActiveKey && rightModActiveKey !== modifier) return;
const now = Date.now();
if (now - rightModLastStopTime < POST_STOP_COOLDOWN_MS) return;
windowManager.showDictationPanel();
windowManager.sendPrepareDictation();
const pressTime = now;
rightModActiveKey = modifier;
rightModDownTime = pressTime;
rightModIsRecording = false;
setTimeout(() => {
if (rightModDownTime === pressTime && !rightModIsRecording) {
rightModIsRecording = true;
windowManager.sendStartDictation();
}
}, MIN_HOLD_DURATION_MS);
} else {
windowManager.sendToggleDictation();
}
});
globeKeyManager.on("right-modifier-up", async (modifier) => {
if (hotkeyManager.slotHasHotkey("dictation", modifier)) {
if (!isLiveWindow(windowManager.mainWindow)) return;
const activationMode = windowManager.getActivationMode();
if (activationMode === "push" && (!rightModActiveKey || rightModActiveKey === modifier)) {
if (rightModDownTime === 0 && !rightModIsRecording) {
// The press was ignored (dictation was processing); releasing it
// must not cancel preparation or hide the thinking pill.
debugLogger?.debug("[RightMod] Release without a registered press — ignored");
} else {
rightModActiveKey = null;
rightModDownTime = 0;
rightModLastStopTime = Date.now();
if (rightModIsRecording) {
rightModIsRecording = false;
windowManager.sendStopDictation();
} else {
windowManager.sendCancelDictationPreparation();
windowManager.hideDictationPanel();
}
}
}
}
const rightModToBase = {
RightCommand: "command",
RightOption: "option",
RightControl: "control",
RightShift: "shift",
};
const baseMod = rightModToBase[modifier];
if (baseMod && windowManager?.handleMacPushModifierUp) {
windowManager.handleMacPushModifierUp(baseMod);
}
});
const MAC_NATIVE_HOTKEY_SLOTS = ["dictation", "voiceAgent", "translation"];
const syncMacNativeHotkeyConfiguration = () => {
globeKeyManager.setConfiguration(
hotkeyManager.getMacNativeListenerConfig(MAC_NATIVE_HOTKEY_SLOTS)
);
};
// Mouse Button 4/5 handling (e.g., Logitech MX Master side buttons)
let mouseButtonDownTime = 0;
let mouseButtonIsRecording = false;
let mouseButtonLastStopTime = 0;
let mouseButtonActiveButton = null;
globeKeyManager.on("mouse-button-down", async (button) => {
if (hotkeyManager.isInListeningMode && hotkeyManager.isInListeningMode()) return;
if (!isMouseButtonHotkey(button)) return;
if (hotkeyManager.slotHasHotkey("voiceAgent", button)) {
windowManager.sendToggleVoiceAgent();
}
if (hotkeyManager.slotHasHotkey("translation", button)) {
windowManager.sendToggleTranslation();
}
if (!hotkeyManager.slotHasHotkey("dictation", button)) return;
if (!isLiveWindow(windowManager.mainWindow)) return;
if (windowManager.isDictationProcessing()) return;
const activationMode = windowManager.getActivationMode();
if (textEditMonitor) textEditMonitor.captureTargetPid();
if (activationMode === "push") {
if (mouseButtonActiveButton && mouseButtonActiveButton !== button) return;
const now = Date.now();
if (now - mouseButtonLastStopTime < POST_STOP_COOLDOWN_MS) return;
windowManager.showDictationPanel();
windowManager.sendPrepareDictation();
const pressTime = now;
mouseButtonActiveButton = button;
mouseButtonDownTime = pressTime;
mouseButtonIsRecording = false;
setTimeout(() => {
if (mouseButtonDownTime === pressTime && !mouseButtonIsRecording) {
mouseButtonIsRecording = true;
windowManager.sendStartDictation();
}
}, MIN_HOLD_DURATION_MS);
} else {
windowManager.sendToggleDictation();
}
});
globeKeyManager.on("mouse-button-up", async (button) => {
if (hotkeyManager.isInListeningMode && hotkeyManager.isInListeningMode()) return;
if (!isMouseButtonHotkey(button)) return;
if (!hotkeyManager.slotHasHotkey("dictation", button)) return;
if (!isLiveWindow(windowManager.mainWindow)) return;
const activationMode = windowManager.getActivationMode();
if (
activationMode === "push" &&
(!mouseButtonActiveButton || mouseButtonActiveButton === button)
) {
if (mouseButtonDownTime === 0 && !mouseButtonIsRecording) {
// The press was ignored (dictation was processing); releasing it
// must not cancel preparation or hide the thinking pill.
debugLogger?.debug("[MouseButton] Release without a registered press — ignored");
} else {
mouseButtonActiveButton = null;
mouseButtonDownTime = 0;
mouseButtonLastStopTime = Date.now();
if (mouseButtonIsRecording) {
mouseButtonIsRecording = false;
windowManager.sendStopDictation();
} else {
windowManager.sendCancelDictationPreparation();
windowManager.hideDictationPanel();
}
}
}
});
// If accessibility is missing, notify the normal control panel after the
// protected macOS features have started. During onboarding the permissions
// screen owns this guidance, so its event has no ControlPanel listener.
const checkAndNotifyAccessibility = () => {
if (!systemPreferences.isTrustedAccessibilityClient(false)) {
debugLogger.info("[Accessibility] macOS accessibility not trusted — notifying renderers");
if (isLiveWindow(windowManager.controlPanelWindow)) {
windowManager.controlPanelWindow.webContents.send("accessibility-missing");
}
}
};
let accessibilityFeaturesStarted = false;
startMacAccessibilityFeatures = () => {
if (accessibilityFeaturesStarted) return;
accessibilityFeaturesStarted = true;
clipboardManager.preWarmAccessibility();
syncMacNativeHotkeyConfiguration();
globeKeyManager.start();
setTimeout(checkAndNotifyAccessibility, 3000);
};
if (macAccessibilityFeaturesReady) {
startMacAccessibilityFeatures();
}
hotkeyManager.on("hotkey-loaded", syncMacNativeHotkeyConfiguration);
ipcMain.on("hotkey-listening-mode-changed", (_event, enabled) => {
if (enabled) {
startMacAccessibilityFeatures();
// Let mouse buttons through so they can be captured, but keep macOS's
// Globe action down so choosing Globe cannot flash the emoji viewer.
globeKeyManager.setConfiguration({ mouseButtons: [], suppressGlobeAction: true });
} else {
syncMacNativeHotkeyConfiguration();
}
});
// Allow renderer to request an accessibility check (e.g. on sign-in).
// Also sends accessibility-missing events if untrusted.
ipcMain.handle("check-accessibility-trusted", () => {
const trusted = systemPreferences.isTrustedAccessibilityClient(false);
if (!trusted) {
checkAndNotifyAccessibility();
}
return trusted;
});
// Reset native key state when hotkey changes
ipcMain.on("hotkey-changed", (_event, _newHotkey) => {
globeKeyDownTime = 0;
globeKeyIsRecording = false;
globeLastStopTime = 0;
rightModDownTime = 0;
rightModIsRecording = false;
rightModLastStopTime = 0;
mouseButtonDownTime = 0;
mouseButtonIsRecording = false;
mouseButtonLastStopTime = 0;
syncMacNativeHotkeyConfiguration();
});
}
// Windows and Linux share the same native low-level key listener model: one hook
// process per watched key (Electron globalShortcut can't see modifier-only or
// right-side-modifier combos), routed to the owning slot here. macOS is handled
// separately above via globeKeyManager.
if (process.platform === "win32" || process.platform === "linux") {
const isWindows = process.platform === "win32";
const nativeKeyManager = isWindows ? windowsKeyManager : linuxKeyManager;
debugLogger.debug("[Push-to-Talk] Native key listener setup starting");
// Dictation supports push-to-talk and needs the overlay window; meeting
// drives other windows (matching their globalShortcut callbacks and macOS).
const dispatchNativeKeyDown = (key) => {
if (hotkeyManager.slotHasHotkey("dictation", key)) {
if (!isLiveWindow(windowManager.mainWindow)) return;
if (windowManager.getActivationMode() === "push") {
windowManager.startWindowsPushToTalk(key);
} else {
windowManager.sendToggleDictation();
}
return;
}
if (hotkeyManager.slotHasHotkey("voiceAgent", key)) {
windowManager.sendToggleVoiceAgent();
} else if (hotkeyManager.slotHasHotkey("translation", key)) {
windowManager.sendToggleTranslation();
} else if (hotkeyManager.slotHasHotkey("meeting", key)) {
windowManager.startManualMeeting();
}
};
// Only dictation drives push-to-talk, so only its key-up matters.
const dispatchNativeKeyUp = (key) => {
if (!hotkeyManager.slotHasHotkey("dictation", key)) return;
if (windowManager.winPushState?.active) {
windowManager.handleWindowsPushKeyUp(key);
} else if (
isLiveWindow(windowManager.mainWindow) &&
windowManager.getActivationMode() === "push"
) {
windowManager.handleWindowsPushKeyUp(key);
}
};
nativeKeyManager.on("key-down", dispatchNativeKeyDown);
nativeKeyManager.on("key-up", dispatchNativeKeyUp);
nativeKeyManager.on("error", (error) => {
debugLogger.warn("[Push-to-Talk] Native key listener error", { error: error.message });
if (isWindows && isLiveWindow(windowManager.mainWindow)) {
windowManager.mainWindow.webContents.send("windows-ptt-unavailable", {
reason: "error",
message: error.message,
});
}
});
nativeKeyManager.on("unavailable", () => {
debugLogger.debug(
"[Push-to-Talk] Native key listener unavailable - falling back to toggle mode"
);
if (isWindows && isLiveWindow(windowManager.mainWindow)) {
windowManager.mainWindow.webContents.send("windows-ptt-unavailable", {
reason: "binary_not_found",
message: i18nMain.t("windows.pttUnavailable"),
});
}
});
nativeKeyManager.on("ready", () => {
debugLogger.debug("[Push-to-Talk] Native key listener ready and listening");
});
if (!isWindows) {
nativeKeyManager.on("permission-denied", () => {
debugLogger.warn(
"[Push-to-Talk] Linux key listener has no permission to access input devices"
);
// GNOME, KDE and Hyprland run this listener only as a spare release
// source in Hold; their own shortcut still presses and releases.
if (!hotkeyManager.reliesOnLinuxKeyListener()) return;
// Settings owns the recovery (toast, Hold disabled, back to Tap) and it
// renders in the control panel, not the pill this event used to reach.
for (const browserWindow of BrowserWindow.getAllWindows()) {
if (!browserWindow.isDestroyed()) {
browserWindow.webContents.send("linux-ptt-permission-denied");
}
}
});
}
const STARTUP_DELAY_MS = 3000;
setTimeout(() => windowManager.reconcileNativeKeyListeners(), STARTUP_DELAY_MS);
ipcMain.on("hotkey-changed", () => {
windowManager.resetWindowsPushState();
windowManager.reconcileNativeKeyListeners();
});
}
}
ipcMain.on("mac-accessibility-features-ready", (_event, expectedAccountScope) => {
if (process.platform !== "darwin") return;
if (expectedAccountScope) {
const accountScopeBinding = require("./src/helpers/accountScopeBinding");
const currentAccountScope = accountScopeBinding.resolveActiveAccountScope({
...require("./src/helpers/tokenStore").getState(),
binding: accountScopeBinding.read(),
});
if (!accountScopeBinding.matchesActiveAccountScope(expectedAccountScope, currentAccountScope)) {
debugLogger.info("[Accessibility] Ignoring stale account-scoped readiness signal");
return;
}
}
macAccessibilityFeaturesReady = true;
startMacAccessibilityFeatures?.();
});
// Listen for usage limit reached from dictation overlay, forward to control panel
ipcMain.on("limit-reached", (_event, data) => {
if (isLiveWindow(windowManager?.controlPanelWindow)) {
windowManager.controlPanelWindow.webContents.send("limit-reached", data);
}
});
// App event handlers
if (gotSingleInstanceLock) {
app.on("second-instance", async (_event, commandLine) => {
await app.whenReady();
if (!windowManager) {
return;
}
if (isLiveWindow(windowManager.controlPanelWindow)) {
if (windowManager.controlPanelWindow.isMinimized()) {
windowManager.controlPanelWindow.restore();
}
windowManager.controlPanelWindow.show();
windowManager.controlPanelWindow.focus();
dockManager.setControlPanelVisible(true);
if (windowManager.controlPanelWindow.webContents.isCrashed()) {
windowManager.loadControlPanel();
}
} else {
windowManager.createControlPanelWindow();
}
if (isLiveWindow(windowManager.mainWindow)) {
windowManager.enforceMainWindowOnTop();
} else {
windowManager.createMainWindow();
}
// Check for OAuth protocol URL in command line arguments (Windows/Linux)
const url = commandLine.find((arg) => arg.startsWith(`${OAUTH_PROTOCOL}://`));
if (url) {
if (url.includes("upgrade-success")) {
handleUpgradeDeepLink();
} else if (isNoteDeepLink(url)) {
await handleNoteDeepLink(url);
} else if (isInvitationDeepLink(url)) {
handleInvitationDeepLink(url);
} else {
void handleOAuthDeepLink(url);
}
}
});
app
.whenReady()
.then(() => {
// On Linux, --enable-transparent-visuals requires a short delay before creating
// windows to allow the compositor to set up the ARGB visual correctly.
// Without this delay, transparent windows flicker on both X11 and Wayland.
const delay = process.platform === "linux" ? 300 : 0;
return new Promise((resolve) => setTimeout(resolve, delay));
})
.then(() => {
if (process.platform === "win32") {
session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => {
// Only the loopback audio track is used; the video source is
// discarded by the renderer, so skip thumbnail generation.
desktopCapturer
.getSources({ types: ["screen"], thumbnailSize: { width: 0, height: 0 } })
.then((sources) => {
if (sources.length > 0) {
callback({ video: sources[0], audio: "loopback" });
} else {
callback(null);
}
})
.catch((error) => {
console.error("Display media request failed:", error);
callback(null);
});
});
}
startApp().catch((error) => {
console.error("Failed to start app:", error);
dialog.showErrorBox(
i18nMain.t("startup.error.title"),
i18nMain.t("startup.error.message", { error: error.message })
);
app.exit(1);
});
});
app.on("window-all-closed", () => {
// Don't quit on macOS when all windows are closed
// The app should stay in the dock/menu bar
if (process.platform !== "darwin") {
app.quit();
}
// On macOS, keep the app running even without windows
});
app.on("browser-window-focus", (event, window) => {
// Only apply always-on-top to the dictation window, not the control panel
if (windowManager && isLiveWindow(windowManager.mainWindow)) {
// Check if the focused window is the dictation window
if (window === windowManager.mainWindow) {
windowManager.enforceMainWindowOnTop();
}
}
// Control panel doesn't need any special handling on focus
// It should behave like a normal window
});
app.on("activate", () => {
// On macOS, re-create windows when dock icon is clicked
if (BrowserWindow.getAllWindows().length === 0) {
if (windowManager) {
windowManager.createMainWindow();
windowManager.createControlPanelWindow();
}
} else {
// Show control panel when dock icon is clicked (most common user action)
if (windowManager && isLiveWindow(windowManager.controlPanelWindow)) {
if (windowManager.controlPanelWindow.isMinimized()) {
windowManager.controlPanelWindow.restore();
}
windowManager.controlPanelWindow.show();
windowManager.controlPanelWindow.focus();
dockManager.setControlPanelVisible(true);
} else if (windowManager) {
// If control panel doesn't exist, create it
windowManager.createControlPanelWindow();
}
// Ensure dictation panel maintains its always-on-top status
if (windowManager && isLiveWindow(windowManager.mainWindow)) {
windowManager.enforceMainWindowOnTop();
}
}
});
let isShuttingDown = false;
app.on("before-quit", (event) => {
if (isShuttingDown) return;
isShuttingDown = true;
if (updateManager && updateManager.isQuittingForUpdate) {
// Quit must proceed for the installer to run, so no preventDefault;
// sidecar shutdown is best-effort (the reaper cleans up orphans on relaunch).
performSyncTeardown();
sidecarRegistry.shutdownAll().catch(() => {});
return;
}
event.preventDefault();
performSyncTeardown();
sidecarRegistry.shutdownAll().finally(() => app.exit(0));
});
}
function performSyncTeardown() {
if (wakeRewarmTimer) {
clearTimeout(wakeRewarmTimer);
wakeRewarmTimer = null;
}
clearPendingNoteDeepLink();
if (authBridgeServer) {
authBridgeServer.close();
authBridgeServer = null;
}
if (cliBridge) {
cliBridge.stop().catch(() => {});
cliBridge = null;
}
if (hotkeyManager) {
hotkeyManager.unregisterAll();
} else {
globalShortcut.unregisterAll();
}
if (globeKeyManager) globeKeyManager.stop();
if (windowsKeyManager) windowsKeyManager.stop();
if (linuxKeyManager) linuxKeyManager.stop();
if (meetingDetectionEngine) meetingDetectionEngine.stop();
if (googleCalendarManager) googleCalendarManager.stop();
if (microsoftCalendarManager) microsoftCalendarManager.stop();
if (appleCalendarManager) appleCalendarManager.stop();
if (calendarReminderScheduler) calendarReminderScheduler.stop();
if (audioTapManager) audioTapManager.stop().catch(() => {});
if (linuxPortalAudioManager) linuxPortalAudioManager.stop().catch(() => {});
if (windowsLoopbackAudioManager) windowsLoopbackAudioManager.stop().catch(() => {});
if (meetingAecManager) meetingAecManager.stop().catch(() => {});
if (ipcHandlers) ipcHandlers._cleanupTextEditMonitor();
if (textEditMonitor) textEditMonitor.stopMonitoring();
if (updateManager) updateManager.cleanup();
}