glass/src/electron/windowManager.js

1482 lines
54 KiB
JavaScript

const { BrowserWindow, globalShortcut, ipcMain, screen, app, shell, desktopCapturer } = require('electron');
const WindowLayoutManager = require('./windowLayoutManager');
const SmoothMovementManager = require('./smoothMovementManager');
const liquidGlass = require('electron-liquid-glass');
const path = require('node:path');
const fs = require('node:fs');
const os = require('os');
const util = require('util');
const execFile = util.promisify(require('child_process').execFile);
const sharp = require('sharp');
const authService = require('../common/services/authService');
const systemSettingsRepository = require('../common/repositories/systemSettings');
const userRepository = require('../common/repositories/user');
const fetch = require('node-fetch');
const isLiquidGlassSupported = () => {
if (process.platform !== 'darwin') {
return false;
}
const majorVersion = parseInt(os.release().split('.')[0], 10);
return majorVersion >= 26; // macOS 26+ (Darwin 25+)
};
const shouldUseLiquidGlass = isLiquidGlassSupported();
let isContentProtectionOn = true;
let currentDisplayId = null;
let mouseEventsIgnored = false;
let lastVisibleWindows = new Set(['header']);
const HEADER_HEIGHT = 47;
const DEFAULT_WINDOW_WIDTH = 353;
let currentHeaderState = 'apikey';
const windowPool = new Map();
let fixedYPosition = 0;
let lastScreenshot = null;
let settingsHideTimer = null;
let selectedCaptureSourceId = null;
let layoutManager = null;
function updateLayout() {
if (layoutManager) {
layoutManager.updateLayout();
}
}
let movementManager = null;
let storedProvider = 'openai';
const featureWindows = ['listen','ask','settings'];
function isAllowed(name) {
if (name === 'header') return true;
return featureWindows.includes(name) && currentHeaderState === 'main';
}
function createFeatureWindows(header) {
if (windowPool.has('listen')) return;
const commonChildOptions = {
parent: header,
show: false,
frame: false,
transparent: true,
vibrancy: false,
hasShadow: false,
skipTaskbar: true,
hiddenInMissionControl: true,
resizable: false,
webPreferences: { nodeIntegration: true, contextIsolation: false },
};
// listen
const listen = new BrowserWindow({
...commonChildOptions, width:400,minWidth:400,maxWidth:400,
maxHeight:700,
});
listen.setContentProtection(isContentProtectionOn);
listen.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true});
listen.setWindowButtonVisibility(false);
const listenLoadOptions = { query: { view: 'listen' } };
if (!shouldUseLiquidGlass) {
listen.loadFile(path.join(__dirname, '../app/content.html'), listenLoadOptions);
}
else {
listenLoadOptions.query.glass = 'true';
listen.loadFile(path.join(__dirname, '../app/content.html'), listenLoadOptions);
listen.webContents.once('did-finish-load', () => {
const viewId = liquidGlass.addView(listen.getNativeWindowHandle(), {
cornerRadius: 12,
tintColor: '#FF00001A', // Red tint
opaque: false,
});
if (viewId !== -1) {
liquidGlass.unstable_setVariant(viewId, 2);
// liquidGlass.unstable_setScrim(viewId, 1);
// liquidGlass.unstable_setSubdued(viewId, 1);
}
});
}
windowPool.set('listen', listen);
// ask
const ask = new BrowserWindow({ ...commonChildOptions, width:600 });
ask.setContentProtection(isContentProtectionOn);
ask.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true});
ask.setWindowButtonVisibility(false);
const askLoadOptions = { query: { view: 'ask' } };
if (!shouldUseLiquidGlass) {
ask.loadFile(path.join(__dirname, '../app/content.html'), askLoadOptions);
}
else {
askLoadOptions.query.glass = 'true';
ask.loadFile(path.join(__dirname, '../app/content.html'), askLoadOptions);
ask.webContents.once('did-finish-load', () => {
const viewId = liquidGlass.addView(ask.getNativeWindowHandle(), {
cornerRadius: 12,
tintColor: '#FF00001A', // Red tint
opaque: false,
});
if (viewId !== -1) {
liquidGlass.unstable_setVariant(viewId, 2);
// liquidGlass.unstable_setScrim(viewId, 1);
// liquidGlass.unstable_setSubdued(viewId, 1);
}
});
}
ask.on('blur',()=>ask.webContents.send('window-blur'));
// Open DevTools in development
if (!app.isPackaged) {
ask.webContents.openDevTools({ mode: 'detach' });
}
windowPool.set('ask', ask);
// settings
const settings = new BrowserWindow({ ...commonChildOptions, width:240, maxHeight:350, parent:undefined });
settings.setContentProtection(isContentProtectionOn);
settings.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true});
settings.setWindowButtonVisibility(false);
const settingsLoadOptions = { query: { view: 'customize' } };
if (!shouldUseLiquidGlass) {
settings.loadFile(path.join(__dirname,'../app/content.html'), settingsLoadOptions)
.catch(console.error);
}
else {
settingsLoadOptions.query.glass = 'true';
settings.loadFile(path.join(__dirname,'../app/content.html'), settingsLoadOptions)
.catch(console.error);
settings.webContents.once('did-finish-load', () => {
const viewId = liquidGlass.addView(settings.getNativeWindowHandle(), {
cornerRadius: 12,
tintColor: '#FF00001A', // Red tint
opaque: false,
});
if (viewId !== -1) {
liquidGlass.unstable_setVariant(viewId, 2);
// liquidGlass.unstable_setScrim(viewId, 1);
// liquidGlass.unstable_setSubdued(viewId, 1);
}
});
}
windowPool.set('settings', settings);
}
function destroyFeatureWindows() {
if (settingsHideTimer) {
clearTimeout(settingsHideTimer);
settingsHideTimer = null;
}
featureWindows.forEach(name=>{
const win = windowPool.get(name);
if (win && !win.isDestroyed()) win.destroy();
windowPool.delete(name);
});
}
function getCurrentDisplay(window) {
if (!window || window.isDestroyed()) return screen.getPrimaryDisplay();
const windowBounds = window.getBounds();
const windowCenter = {
x: windowBounds.x + windowBounds.width / 2,
y: windowBounds.y + windowBounds.height / 2,
};
return screen.getDisplayNearestPoint(windowCenter);
}
function getDisplayById(displayId) {
const displays = screen.getAllDisplays();
return displays.find(d => d.id === displayId) || screen.getPrimaryDisplay();
}
function toggleAllWindowsVisibility(movementManager) {
const header = windowPool.get('header');
if (!header) return;
if (header.isVisible()) {
console.log('[Visibility] Smart hiding - calculating nearest edge');
const headerBounds = header.getBounds();
const display = screen.getPrimaryDisplay();
const { width: screenWidth, height: screenHeight } = display.workAreaSize;
const centerX = headerBounds.x + headerBounds.width / 2;
const centerY = headerBounds.y + headerBounds.height / 2;
const distances = {
top: centerY,
bottom: screenHeight - centerY,
left: centerX,
right: screenWidth - centerX,
};
const nearestEdge = Object.keys(distances).reduce((nearest, edge) => (distances[edge] < distances[nearest] ? edge : nearest));
console.log(`[Visibility] Nearest edge: ${nearestEdge} (distance: ${distances[nearestEdge].toFixed(1)}px)`);
lastVisibleWindows.clear();
lastVisibleWindows.add('header');
windowPool.forEach((win, name) => {
if (win.isVisible()) {
lastVisibleWindows.add(name);
if (name !== 'header') {
win.webContents.send('window-hide-animation');
setTimeout(() => {
if (!win.isDestroyed()) {
win.hide();
}
}, 200);
}
}
});
console.log('[Visibility] Visible windows before hide:', Array.from(lastVisibleWindows));
movementManager.hideToEdge(nearestEdge, () => {
header.hide();
console.log('[Visibility] Smart hide completed');
});
} else {
console.log('[Visibility] Smart showing from hidden position');
console.log('[Visibility] Restoring windows:', Array.from(lastVisibleWindows));
header.show();
movementManager.showFromEdge(() => {
lastVisibleWindows.forEach(name => {
if (name === 'header') return;
const win = windowPool.get(name);
if (win && !win.isDestroyed()) {
win.show();
win.webContents.send('window-show-animation');
}
});
setImmediate(updateLayout);
setTimeout(updateLayout, 120);
console.log('[Visibility] Smart show completed');
});
}
}
function createWindows() {
const primaryDisplay = screen.getPrimaryDisplay();
const { y: workAreaY, width: screenWidth } = primaryDisplay.workArea;
const initialX = Math.round((screenWidth - DEFAULT_WINDOW_WIDTH) / 2);
const initialY = workAreaY + 21;
movementManager = new SmoothMovementManager(windowPool, getDisplayById, getCurrentDisplay, updateLayout);
const header = new BrowserWindow({
width: DEFAULT_WINDOW_WIDTH,
height: HEADER_HEIGHT,
x: initialX,
y: initialY,
frame: false,
transparent: true,
vibrancy: false,
alwaysOnTop: true,
skipTaskbar: true,
hiddenInMissionControl: true,
resizable: false,
focusable: true,
acceptFirstMouse: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
backgroundThrottling: false,
webSecurity: false,
},
});
header.setWindowButtonVisibility(false);
const headerLoadOptions = {};
if (!shouldUseLiquidGlass) {
header.loadFile(path.join(__dirname, '../app/header.html'), headerLoadOptions);
}
else {
headerLoadOptions.query = { glass: 'true' };
header.loadFile(path.join(__dirname, '../app/header.html'), headerLoadOptions);
header.webContents.once('did-finish-load', () => {
const viewId = liquidGlass.addView(header.getNativeWindowHandle(), {
cornerRadius: 12,
tintColor: '#FF00001A', // Red tint
opaque: false,
});
if (viewId !== -1) {
liquidGlass.unstable_setVariant(viewId, 2);
// liquidGlass.unstable_setScrim(viewId, 1);
// liquidGlass.unstable_setSubdued(viewId, 1);
}
});
}
windowPool.set('header', header);
layoutManager = new WindowLayoutManager(windowPool);
header.webContents.once('dom-ready', () => {
loadAndRegisterShortcuts(movementManager);
});
setupIpcHandlers(movementManager);
if (currentHeaderState === 'main') {
createFeatureWindows(header);
}
header.setContentProtection(isContentProtectionOn);
header.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
// header.loadFile(path.join(__dirname, '../app/header.html'));
// Open DevTools in development
if (!app.isPackaged) {
header.webContents.openDevTools({ mode: 'detach' });
}
header.on('focus', () => {
console.log('[WindowManager] Header gained focus');
});
header.on('blur', () => {
console.log('[WindowManager] Header lost focus');
});
header.webContents.on('before-input-event', (event, input) => {
if (input.type === 'mouseDown') {
const target = input.target;
if (target && (target.includes('input') || target.includes('apikey'))) {
header.focus();
}
}
});
header.on('resize', updateLayout);
// header.webContents.once('dom-ready', () => {
// loadAndRegisterShortcuts();
// });
ipcMain.handle('toggle-all-windows-visibility', toggleAllWindowsVisibility);
ipcMain.handle('toggle-feature', async (event, featureName) => {
if (!windowPool.get(featureName) && currentHeaderState === 'main') {
createFeatureWindows(windowPool.get('header'));
}
const windowToToggle = windowPool.get(featureName);
if (windowToToggle) {
if (featureName === 'listen') {
const liveSummaryService = require('../features/listen/liveSummaryService');
if (liveSummaryService.isSessionActive()) {
console.log('[WindowManager] Listen session is active, closing it via toggle.');
await liveSummaryService.closeSession();
return;
}
}
console.log(`[WindowManager] Toggling feature: ${featureName}`);
}
if (featureName === 'ask') {
let askWindow = windowPool.get('ask');
if (!askWindow || askWindow.isDestroyed()) {
console.log('[WindowManager] Ask window not found, creating new one');
return;
}
if (askWindow.isVisible()) {
try {
const hasResponse = await askWindow.webContents.executeJavaScript(`
(() => {
try {
// PickleGlassApp의 Shadow DOM 내부로 접근
const pickleApp = document.querySelector('pickle-glass-app');
if (!pickleApp || !pickleApp.shadowRoot) {
console.log('PickleGlassApp not found');
return false;
}
// PickleGlassApp의 shadowRoot 내부에서 ask-view 찾기
const askView = pickleApp.shadowRoot.querySelector('ask-view');
if (!askView) {
console.log('AskView not found in PickleGlassApp shadow DOM');
return false;
}
console.log('AskView found, checking state...');
console.log('currentResponse:', askView.currentResponse);
console.log('isLoading:', askView.isLoading);
console.log('isStreaming:', askView.isStreaming);
const hasContent = !!(askView.currentResponse || askView.isLoading || askView.isStreaming);
if (!hasContent && askView.shadowRoot) {
const responseContainer = askView.shadowRoot.querySelector('.response-container');
if (responseContainer && !responseContainer.classList.contains('hidden')) {
const textContent = responseContainer.textContent.trim();
const hasActualContent = textContent &&
!textContent.includes('Ask a question to see the response here') &&
textContent.length > 0;
console.log('Response container content check:', hasActualContent);
return hasActualContent;
}
}
return hasContent;
} catch (error) {
console.error('Error checking AskView state:', error);
return false;
}
})()
`);
console.log(`[WindowManager] Ask window visible, hasResponse: ${hasResponse}`);
if (hasResponse) {
askWindow.webContents.send('toggle-text-input');
console.log('[WindowManager] Sent toggle-text-input command');
} else {
console.log('[WindowManager] No response found, closing window');
askWindow.webContents.send('window-hide-animation');
setTimeout(() => {
if (!askWindow.isDestroyed()) {
askWindow.hide();
updateLayout();
}
}, 250);
}
} catch (error) {
console.error('[WindowManager] Error checking Ask window state:', error);
console.log('[WindowManager] Falling back to toggle text input');
askWindow.webContents.send('toggle-text-input');
}
} else {
console.log('[WindowManager] Showing hidden Ask window');
askWindow.show();
updateLayout();
askWindow.webContents.send('window-show-animation');
askWindow.webContents.send('window-did-show');
}
} else {
const windowToToggle = windowPool.get(featureName);
if (windowToToggle) {
if (windowToToggle.isDestroyed()) {
console.error(`Window ${featureName} is destroyed, cannot toggle`);
return;
}
if (windowToToggle.isVisible()) {
if (featureName === 'settings') {
windowToToggle.webContents.send('settings-window-hide-animation');
} else {
windowToToggle.webContents.send('window-hide-animation');
}
setTimeout(() => {
if (!windowToToggle.isDestroyed()) {
windowToToggle.hide();
updateLayout();
}
}, 250);
} else {
try {
windowToToggle.show();
updateLayout();
if (featureName === 'listen') {
windowToToggle.webContents.send('start-listening-session');
}
windowToToggle.webContents.send('window-show-animation');
} catch (e) {
console.error('Error showing window:', e);
}
}
} else {
console.error(`Window not found for feature: ${featureName}`);
console.error('Available windows:', Array.from(windowPool.keys()));
}
}
});
ipcMain.handle('send-question-to-ask', (event, question) => {
const askWindow = windowPool.get('ask');
if (askWindow && !askWindow.isDestroyed()) {
console.log('📨 Main process: Sending question to AskView', question);
askWindow.webContents.send('receive-question-from-assistant', question);
return { success: true };
} else {
console.error('❌ Cannot find AskView window');
return { success: false, error: 'AskView window not found' };
}
});
ipcMain.handle('adjust-window-height', (event, targetHeight) => {
const senderWindow = BrowserWindow.fromWebContents(event.sender);
if (senderWindow) {
const wasResizable = senderWindow.isResizable();
if (!wasResizable) {
senderWindow.setResizable(true);
}
const currentBounds = senderWindow.getBounds();
const minHeight = senderWindow.getMinimumSize()[1];
const maxHeight = senderWindow.getMaximumSize()[1];
let adjustedHeight;
if (maxHeight === 0) {
adjustedHeight = Math.max(minHeight, targetHeight);
} else {
adjustedHeight = Math.max(minHeight, Math.min(maxHeight, targetHeight));
}
senderWindow.setSize(currentBounds.width, adjustedHeight, false);
if (!wasResizable) {
senderWindow.setResizable(false);
}
updateLayout();
}
});
ipcMain.on('session-did-close', () => {
const listenWindow = windowPool.get('listen');
if (listenWindow && listenWindow.isVisible()) {
console.log('[WindowManager] Session closed, hiding listen window.');
listenWindow.hide();
}
});
// setupIpcHandlers();
return windowPool;
}
function loadAndRegisterShortcuts(movementManager) {
const defaultKeybinds = getDefaultKeybinds();
const header = windowPool.get('header');
const sendToRenderer = (channel, ...args) => {
windowPool.forEach(win => {
try {
if (win && !win.isDestroyed()) {
win.webContents.send(channel, ...args);
}
} catch (e) {}
});
};
if (!header) {
return updateGlobalShortcuts(defaultKeybinds, undefined, sendToRenderer, movementManager);
}
header.webContents
.executeJavaScript(`(() => localStorage.getItem('customKeybinds'))()`)
.then(saved => (saved ? JSON.parse(saved) : {}))
.then(savedKeybinds => {
const keybinds = { ...defaultKeybinds, ...savedKeybinds };
updateGlobalShortcuts(keybinds, header, sendToRenderer, movementManager);
})
.catch(() => updateGlobalShortcuts(defaultKeybinds, header, sendToRenderer, movementManager));
}
function setupIpcHandlers(movementManager) {
screen.on('display-added', (event, newDisplay) => {
console.log('[Display] New display added:', newDisplay.id);
});
screen.on('display-removed', (event, oldDisplay) => {
console.log('[Display] Display removed:', oldDisplay.id);
const header = windowPool.get('header');
if (header && getCurrentDisplay(header).id === oldDisplay.id) {
const primaryDisplay = screen.getPrimaryDisplay();
movementManager.moveToDisplay(primaryDisplay.id);
}
});
screen.on('display-metrics-changed', (event, display, changedMetrics) => {
console.log('[Display] Display metrics changed:', display.id, changedMetrics);
updateLayout();
});
// 1. 스트리밍 데이터 조각(chunk)을 받아서 ask 창으로 전달
ipcMain.on('ask-response-chunk', (event, { token }) => {
const askWindow = windowPool.get('ask');
if (askWindow && !askWindow.isDestroyed()) {
// renderer.js가 보낸 토큰을 AskView.js로 그대로 전달합니다.
askWindow.webContents.send('ask-response-chunk', { token });
}
});
// 2. 스트리밍 종료 신호를 받아서 ask 창으로 전달
ipcMain.on('ask-response-stream-end', () => {
const askWindow = windowPool.get('ask');
if (askWindow && !askWindow.isDestroyed()) {
askWindow.webContents.send('ask-response-stream-end');
}
});
ipcMain.on('show-window', (event, args) => {
const { name, bounds } = typeof args === 'object' && args !== null ? args : { name: args, bounds: null };
const win = windowPool.get(name);
if (win && !win.isDestroyed()) {
if (settingsHideTimer) {
clearTimeout(settingsHideTimer);
settingsHideTimer = null;
}
if (name === 'settings') {
// Adjust position based on button bounds
const header = windowPool.get('header');
const headerBounds = header?.getBounds() ?? { x: 0, y: 0 };
const settingsBounds = win.getBounds();
const disp = getCurrentDisplay(header);
const { x: waX, y: waY, width: waW, height: waH } = disp.workArea;
let x = Math.round(headerBounds.x + (bounds?.x ?? 0) + (bounds?.width ?? 0) / 2 - settingsBounds.width / 2);
let y = Math.round(headerBounds.y + (bounds?.y ?? 0) + (bounds?.height ?? 0) + 31);
x = Math.max(waX + 10, Math.min(waX + waW - settingsBounds.width - 10, x));
y = Math.max(waY + 10, Math.min(waY + waH - settingsBounds.height - 10, y));
win.setBounds({ x, y });
win.__lockedByButton = true;
console.log(`[WindowManager] Positioning settings window at (${x}, ${y}) based on button bounds.`);
}
win.show();
win.moveTop();
if (name === 'settings') {
win.setAlwaysOnTop(true);
}
// updateLayout();
}
});
ipcMain.on('hide-window', (event, name) => {
const window = windowPool.get(name);
if (window && !window.isDestroyed()) {
if (name === 'settings') {
if (settingsHideTimer) {
clearTimeout(settingsHideTimer);
}
settingsHideTimer = setTimeout(() => {
// window.setAlwaysOnTop(false);
// window.hide();
if (window && !window.isDestroyed()) {
window.setAlwaysOnTop(false);
window.hide();
}
settingsHideTimer = null;
}, 200);
} else {
window.hide();
}
window.__lockedByButton = false;
}
});
ipcMain.on('cancel-hide-window', (event, name) => {
if (name === 'settings' && settingsHideTimer) {
clearTimeout(settingsHideTimer);
settingsHideTimer = null;
}
});
ipcMain.handle('hide-all', () => {
windowPool.forEach(win => {
if (win.isFocused()) return;
win.hide();
});
});
ipcMain.handle('quit-application', () => {
app.quit();
});
ipcMain.handle('is-window-visible', (event, windowName) => {
const window = windowPool.get(windowName);
if (window && !window.isDestroyed()) {
return window.isVisible();
}
return false;
});
ipcMain.handle('toggle-content-protection', () => {
isContentProtectionOn = !isContentProtectionOn;
console.log(`[Protection] Content protection toggled to: ${isContentProtectionOn}`);
windowPool.forEach(win => {
if (win && !win.isDestroyed()) {
win.setContentProtection(isContentProtectionOn);
}
});
return isContentProtectionOn;
});
ipcMain.handle('get-content-protection-status', () => {
return isContentProtectionOn;
});
ipcMain.on('header-state-changed', (event, state) => {
console.log(`[WindowManager] Header state changed to: ${state}`);
currentHeaderState = state;
if (state === 'main') {
createFeatureWindows(windowPool.get('header'));
} else { // 'apikey' | 'permission'
destroyFeatureWindows();
}
for (const [name, win] of windowPool) {
if (!isAllowed(name) && !win.isDestroyed()) {
win.hide();
}
if (isAllowed(name) && win.isVisible()) {
win.show();
}
}
const header = windowPool.get('header');
if (header && !header.isDestroyed()) {
header.webContents
.executeJavaScript(`(() => localStorage.getItem('customKeybinds'))()`)
.then(saved => {
const defaultKeybinds = getDefaultKeybinds();
const savedKeybinds = saved ? JSON.parse(saved) : {};
const keybinds = { ...defaultKeybinds, ...savedKeybinds };
const sendToRenderer = (channel, ...args) => {
windowPool.forEach(win => {
try {
if (win && !win.isDestroyed()) {
win.webContents.send(channel, ...args);
}
} catch (e) {}
});
};
updateGlobalShortcuts(keybinds, header, sendToRenderer, movementManager);
})
.catch(console.error);
}
});
ipcMain.on('update-keybinds', (event, newKeybinds) => {
updateGlobalShortcuts(newKeybinds);
});
ipcMain.handle('open-login-page', () => {
const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000';
const personalizeUrl = `${webUrl}/personalize?desktop=true`;
shell.openExternal(personalizeUrl);
console.log('Opening personalization page:', personalizeUrl);
});
setupApiKeyIPC();
ipcMain.handle('resize-window', () => {});
ipcMain.handle('resize-for-view', () => {});
ipcMain.handle('resize-header-window', (event, { width, height }) => {
const header = windowPool.get('header');
if (header) {
const wasResizable = header.isResizable();
if (!wasResizable) {
header.setResizable(true);
}
const bounds = header.getBounds();
const newX = bounds.x + Math.round((bounds.width - width) / 2);
header.setBounds({ x: newX, y: bounds.y, width, height });
if (!wasResizable) {
header.setResizable(false);
}
return { success: true };
}
return { success: false, error: 'Header window not found' };
});
ipcMain.on('header-animation-complete', (event, state) => {
const header = windowPool.get('header');
if (!header) return;
if (state === 'hidden') {
header.hide();
} else if (state === 'visible') {
lastVisibleWindows.forEach(name => {
if (name === 'header') return;
const win = windowPool.get(name);
if (win) win.show();
});
setImmediate(updateLayout);
setTimeout(updateLayout, 120);
}
});
ipcMain.handle('get-header-position', () => {
const header = windowPool.get('header');
if (header) {
const [x, y] = header.getPosition();
return { x, y };
}
return { x: 0, y: 0 };
});
ipcMain.handle('move-header', (event, newX, newY) => {
const header = windowPool.get('header');
if (header) {
const currentY = newY !== undefined ? newY : header.getBounds().y;
header.setPosition(newX, currentY, false);
updateLayout();
}
});
ipcMain.handle('move-header-to', (event, newX, newY) => {
const header = windowPool.get('header');
if (header) {
const targetDisplay = screen.getDisplayNearestPoint({ x: newX, y: newY });
const { x: workAreaX, y: workAreaY, width, height } = targetDisplay.workArea;
const headerBounds = header.getBounds();
const clampedX = Math.max(workAreaX, Math.min(workAreaX + width - headerBounds.width, newX));
const clampedY = Math.max(workAreaY, Math.min(workAreaY + height - headerBounds.height, newY));
header.setPosition(clampedX, clampedY, false);
updateLayout();
}
});
ipcMain.handle('move-window-step', (event, direction) => {
if (movementManager) {
movementManager.moveStep(direction);
}
});
ipcMain.handle('force-close-window', (event, windowName) => {
const window = windowPool.get(windowName);
if (window && !window.isDestroyed()) {
console.log(`[WindowManager] Force closing window: ${windowName}`);
window.webContents.send('window-hide-animation');
setTimeout(() => {
if (!window.isDestroyed()) {
window.hide();
updateLayout();
}
}, 250);
}
});
ipcMain.handle('start-screen-capture', async () => {
try {
isCapturing = true;
console.log('Starting screen capture in main process');
return { success: true };
} catch (error) {
console.error('Failed to start screen capture:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('stop-screen-capture', async () => {
try {
isCapturing = false;
lastScreenshot = null;
console.log('Stopped screen capture in main process');
return { success: true };
} catch (error) {
console.error('Failed to stop screen capture:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('capture-screenshot', async (event, options = {}) => {
return captureScreenshot(options);
});
ipcMain.handle('get-current-screenshot', async event => {
try {
if (lastScreenshot && Date.now() - lastScreenshot.timestamp < 1000) {
console.log('Returning cached screenshot');
return {
success: true,
base64: lastScreenshot.base64,
width: lastScreenshot.width,
height: lastScreenshot.height,
};
}
return {
success: false,
error: 'No screenshot available',
};
} catch (error) {
console.error('Failed to get current screenshot:', error);
return {
success: false,
error: error.message,
};
}
});
ipcMain.handle('firebase-logout', async () => {
console.log('[WindowManager] Received request to log out.');
await authService.signOut();
await setApiKey(null);
windowPool.forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('api-key-removed');
}
});
});
ipcMain.handle('check-system-permissions', async () => {
const { systemPreferences } = require('electron');
const permissions = {
microphone: 'unknown',
screen: 'unknown',
needsSetup: true
};
try {
if (process.platform === 'darwin') {
// Check microphone permission on macOS
const micStatus = systemPreferences.getMediaAccessStatus('microphone');
console.log('[Permissions] Microphone status:', micStatus);
permissions.microphone = micStatus;
// Check screen recording permission using the system API
const screenStatus = systemPreferences.getMediaAccessStatus('screen');
console.log('[Permissions] Screen status:', screenStatus);
permissions.screen = screenStatus;
permissions.needsSetup = micStatus !== 'granted' || screenStatus !== 'granted';
} else {
permissions.microphone = 'granted';
permissions.screen = 'granted';
permissions.needsSetup = false;
}
console.log('[Permissions] System permissions status:', permissions);
return permissions;
} catch (error) {
console.error('[Permissions] Error checking permissions:', error);
return {
microphone: 'unknown',
screen: 'unknown',
needsSetup: true,
error: error.message
};
}
});
ipcMain.handle('request-microphone-permission', async () => {
if (process.platform !== 'darwin') {
return { success: true };
}
const { systemPreferences } = require('electron');
try {
const status = systemPreferences.getMediaAccessStatus('microphone');
console.log('[Permissions] Microphone status:', status);
if (status === 'granted') {
return { success: true, status: 'granted' };
}
// Req mic permission
const granted = await systemPreferences.askForMediaAccess('microphone');
return {
success: granted,
status: granted ? 'granted' : 'denied'
};
} catch (error) {
console.error('[Permissions] Error requesting microphone permission:', error);
return {
success: false,
error: error.message
};
}
});
ipcMain.handle('open-system-preferences', async (event, section) => {
if (process.platform !== 'darwin') {
return { success: false, error: 'Not supported on this platform' };
}
try {
if (section === 'screen-recording') {
// First trigger screen capture request to register the app in system preferences
try {
console.log('[Permissions] Triggering screen capture request to register app...');
await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: 1, height: 1 }
});
console.log('[Permissions] App registered for screen recording');
} catch (captureError) {
console.log('[Permissions] Screen capture request triggered (expected to fail):', captureError.message);
}
// Then open system preferences
// await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture');
}
// if (section === 'microphone') {
// await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone');
// }
return { success: true };
} catch (error) {
console.error('[Permissions] Error opening system preferences:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('mark-permissions-completed', async () => {
try {
// This is a system-level setting, not user-specific.
await systemSettingsRepository.markPermissionsAsCompleted();
console.log('[Permissions] Marked permissions as completed');
return { success: true };
} catch (error) {
console.error('[Permissions] Error marking permissions as completed:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('check-permissions-completed', async () => {
try {
const completed = await systemSettingsRepository.checkPermissionsCompleted();
console.log('[Permissions] Permissions completed status:', completed);
return completed;
} catch (error) {
console.error('[Permissions] Error checking permissions completed status:', error);
return false;
}
});
ipcMain.handle('close-ask-window-if-empty', async () => {
const askWindow = windowPool.get('ask');
if (askWindow && !askWindow.isFocused()) {
askWindow.hide();
}
});
}
async function setApiKey(apiKey, provider = 'openai') {
console.log('[WindowManager] Persisting API key and provider to DB');
try {
await userRepository.saveApiKey(apiKey, authService.getCurrentUserId(), provider);
console.log('[WindowManager] API key and provider saved to SQLite');
// Notify authService that the key status may have changed
await authService.updateApiKeyStatus();
} catch (err) {
console.error('[WindowManager] Failed to save API key to SQLite:', err);
}
windowPool.forEach(win => {
if (win && !win.isDestroyed()) {
const js = apiKey ? `
localStorage.setItem('openai_api_key', ${JSON.stringify(apiKey)});
localStorage.setItem('ai_provider', ${JSON.stringify(provider)});
` : `
localStorage.removeItem('openai_api_key');
localStorage.removeItem('ai_provider');
`;
win.webContents.executeJavaScript(js).catch(() => {});
}
});
}
async function getStoredApiKey() {
const userId = authService.getCurrentUserId();
if (!userId) return null;
const user = await userRepository.getById(userId);
return user?.api_key || null;
}
async function getStoredProvider() {
const userId = authService.getCurrentUserId();
if (!userId) return 'openai';
const user = await userRepository.getById(userId);
return user?.provider || 'openai';
}
function setupApiKeyIPC() {
const { ipcMain } = require('electron');
// Both handlers now do the same thing: fetch the key from the source of truth.
ipcMain.handle('get-stored-api-key', getStoredApiKey);
ipcMain.handle('api-key-validated', async (event, data) => {
console.log('[WindowManager] API key validation completed, saving...');
// Support both old format (string) and new format (object)
const apiKey = typeof data === 'string' ? data : data.apiKey;
const provider = typeof data === 'string' ? 'openai' : (data.provider || 'openai');
await setApiKey(apiKey, provider);
windowPool.forEach((win, name) => {
if (win && !win.isDestroyed()) {
win.webContents.send('api-key-validated', { apiKey, provider });
}
});
return { success: true };
});
ipcMain.handle('remove-api-key', async () => {
console.log('[WindowManager] API key removal requested');
await setApiKey(null);
windowPool.forEach((win, name) => {
if (win && !win.isDestroyed()) {
win.webContents.send('api-key-removed');
}
});
const settingsWindow = windowPool.get('settings');
if (settingsWindow && settingsWindow.isVisible()) {
settingsWindow.hide();
console.log('[WindowManager] Settings window hidden after clearing API key.');
}
return { success: true };
});
ipcMain.handle('get-ai-provider', getStoredProvider);
console.log('[WindowManager] API key related IPC handlers registered (SQLite-backed)');
}
function getDefaultKeybinds() {
const isMac = process.platform === 'darwin';
return {
moveUp: isMac ? 'Cmd+Up' : 'Ctrl+Up',
moveDown: isMac ? 'Cmd+Down' : 'Ctrl+Down',
moveLeft: isMac ? 'Cmd+Left' : 'Ctrl+Left',
moveRight: isMac ? 'Cmd+Right' : 'Ctrl+Right',
toggleVisibility: isMac ? 'Cmd+\\' : 'Ctrl+\\',
toggleClickThrough: isMac ? 'Cmd+M' : 'Ctrl+M',
nextStep: isMac ? 'Cmd+Enter' : 'Ctrl+Enter',
manualScreenshot: isMac ? 'Cmd+Shift+S' : 'Ctrl+Shift+S',
previousResponse: isMac ? 'Cmd+[' : 'Ctrl+[',
nextResponse: isMac ? 'Cmd+]' : 'Ctrl+]',
scrollUp: isMac ? 'Cmd+Shift+Up' : 'Ctrl+Shift+Up',
scrollDown: isMac ? 'Cmd+Shift+Down' : 'Ctrl+Shift+Down',
};
}
function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, movementManager) {
console.log('Updating global shortcuts with:', keybinds);
// Unregister all existing shortcuts
globalShortcut.unregisterAll();
let toggleVisibilityDebounceTimer = null;
const isMac = process.platform === 'darwin';
const modifier = isMac ? 'Cmd' : 'Ctrl';
if (keybinds.toggleVisibility) {
try {
globalShortcut.register(keybinds.toggleVisibility, () => toggleAllWindowsVisibility(movementManager));
console.log(`Registered toggleVisibility: ${keybinds.toggleVisibility}`);
} catch (error) {
console.error(`Failed to register toggleVisibility (${keybinds.toggleVisibility}):`, error);
}
}
const displays = screen.getAllDisplays();
if (displays.length > 1) {
displays.forEach((display, index) => {
const key = `${modifier}+Shift+${index + 1}`;
try {
globalShortcut.register(key, () => {
movementManager.moveToDisplay(display.id);
});
console.log(`Registered display switch shortcut: ${key} -> Display ${index + 1}`);
} catch (error) {
console.error(`Failed to register display switch ${key}:`, error);
}
});
}
if (currentHeaderState === 'apikey') {
console.log('ApiKeyHeader is active, skipping conditional shortcuts');
return;
}
const directions = [
{ key: `${modifier}+Left`, direction: 'left' },
{ key: `${modifier}+Right`, direction: 'right' },
{ key: `${modifier}+Up`, direction: 'up' },
{ key: `${modifier}+Down`, direction: 'down' },
];
directions.forEach(({ key, direction }) => {
try {
globalShortcut.register(key, () => {
const header = windowPool.get('header');
if (header && header.isVisible()) {
movementManager.moveStep(direction);
}
});
console.log(`Registered global shortcut: ${key} -> ${direction}`);
} catch (error) {
console.error(`Failed to register ${key}:`, error);
}
});
const edgeDirections = [
{ key: `${modifier}+Shift+Left`, direction: 'left' },
{ key: `${modifier}+Shift+Right`, direction: 'right' },
{ key: `${modifier}+Shift+Up`, direction: 'up' },
{ key: `${modifier}+Shift+Down`, direction: 'down' },
];
edgeDirections.forEach(({ key, direction }) => {
try {
globalShortcut.register(key, () => {
const header = windowPool.get('header');
if (header && header.isVisible()) {
movementManager.moveToEdge(direction);
}
});
console.log(`Registered global shortcut: ${key} -> edge ${direction}`);
} catch (error) {
console.error(`Failed to register ${key}:`, error);
}
});
if (keybinds.toggleClickThrough) {
try {
globalShortcut.register(keybinds.toggleClickThrough, () => {
mouseEventsIgnored = !mouseEventsIgnored;
if (mouseEventsIgnored) {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
console.log('Mouse events ignored');
} else {
mainWindow.setIgnoreMouseEvents(false);
console.log('Mouse events enabled');
}
mainWindow.webContents.send('click-through-toggled', mouseEventsIgnored);
});
console.log(`Registered toggleClickThrough: ${keybinds.toggleClickThrough}`);
} catch (error) {
console.error(`Failed to register toggleClickThrough (${keybinds.toggleClickThrough}):`, error);
}
}
if (keybinds.nextStep) {
try {
globalShortcut.register(keybinds.nextStep, () => {
console.log('⌘/Ctrl+Enter Ask shortcut triggered');
const askWindow = windowPool.get('ask');
if (!askWindow || askWindow.isDestroyed()) {
console.error('Ask window not found or destroyed');
return;
}
if (askWindow.isVisible()) {
askWindow.webContents.send('ask-global-send');
} else {
try {
askWindow.show();
const header = windowPool.get('header');
if (header) {
const currentHeaderPosition = header.getBounds();
updateLayout();
header.setPosition(currentHeaderPosition.x, currentHeaderPosition.y, false);
}
askWindow.webContents.send('window-show-animation');
} catch (e) {
console.error('Error showing Ask window:', e);
}
}
});
console.log(`Registered Ask shortcut (nextStep): ${keybinds.nextStep}`);
} catch (error) {
console.error(`Failed to register Ask shortcut (${keybinds.nextStep}):`, error);
}
}
if (keybinds.manualScreenshot) {
try {
globalShortcut.register(keybinds.manualScreenshot, () => {
console.log('Manual screenshot shortcut triggered');
mainWindow.webContents.executeJavaScript(`
if (window.captureManualScreenshot) {
window.captureManualScreenshot();
} else {
console.log('Manual screenshot function not available');
}
`);
});
console.log(`Registered manualScreenshot: ${keybinds.manualScreenshot}`);
} catch (error) {
console.error(`Failed to register manualScreenshot (${keybinds.manualScreenshot}):`, error);
}
}
if (keybinds.previousResponse) {
try {
globalShortcut.register(keybinds.previousResponse, () => {
console.log('Previous response shortcut triggered');
sendToRenderer('navigate-previous-response');
});
console.log(`Registered previousResponse: ${keybinds.previousResponse}`);
} catch (error) {
console.error(`Failed to register previousResponse (${keybinds.previousResponse}):`, error);
}
}
if (keybinds.nextResponse) {
try {
globalShortcut.register(keybinds.nextResponse, () => {
console.log('Next response shortcut triggered');
sendToRenderer('navigate-next-response');
});
console.log(`Registered nextResponse: ${keybinds.nextResponse}`);
} catch (error) {
console.error(`Failed to register nextResponse (${keybinds.nextResponse}):`, error);
}
}
if (keybinds.scrollUp) {
try {
globalShortcut.register(keybinds.scrollUp, () => {
console.log('Scroll up shortcut triggered');
sendToRenderer('scroll-response-up');
});
console.log(`Registered scrollUp: ${keybinds.scrollUp}`);
} catch (error) {
console.error(`Failed to register scrollUp (${keybinds.scrollUp}):`, error);
}
}
if (keybinds.scrollDown) {
try {
globalShortcut.register(keybinds.scrollDown, () => {
console.log('Scroll down shortcut triggered');
sendToRenderer('scroll-response-down');
});
console.log(`Registered scrollDown: ${keybinds.scrollDown}`);
} catch (error) {
console.error(`Failed to register scrollDown (${keybinds.scrollDown}):`, error);
}
}
}
async function captureScreenshot(options = {}) {
if (process.platform === 'darwin') {
try {
const tempPath = path.join(os.tmpdir(), `screenshot-${Date.now()}.jpg`);
await execFile('screencapture', ['-x', '-t', 'jpg', tempPath]);
const imageBuffer = await fs.promises.readFile(tempPath);
await fs.promises.unlink(tempPath);
const resizedBuffer = await sharp(imageBuffer)
// .resize({ height: 1080 })
.resize({ height: 384 })
.jpeg({ quality: 80 })
.toBuffer();
const base64 = resizedBuffer.toString('base64');
const metadata = await sharp(resizedBuffer).metadata();
lastScreenshot = {
base64,
width: metadata.width,
height: metadata.height,
timestamp: Date.now(),
};
return { success: true, base64, width: metadata.width, height: metadata.height };
} catch (error) {
console.error('Failed to capture and resize screenshot:', error);
return { success: false, error: error.message };
}
}
try {
const sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: {
width: 1920,
height: 1080,
},
});
if (sources.length === 0) {
throw new Error('No screen sources available');
}
const source = sources[0];
const buffer = source.thumbnail.toJPEG(70);
const base64 = buffer.toString('base64');
const size = source.thumbnail.getSize();
return {
success: true,
base64,
width: size.width,
height: size.height,
};
} catch (error) {
console.error('Failed to capture screenshot using desktopCapturer:', error);
return {
success: false,
error: error.message,
};
}
}
module.exports = {
createWindows,
windowPool,
fixedYPosition,
setApiKey,
getStoredApiKey,
getStoredProvider,
captureScreenshot,
};