glass/src/electron/windowManager.js
2025-07-05 23:34:15 +09:00

2567 lines
91 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { BrowserWindow, globalShortcut, ipcMain, screen, app, shell, desktopCapturer } = require('electron');
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 sqliteClient = require('../common/services/sqliteClient');
const fetch = require('node-fetch');
let currentFirebaseUser = null;
let isContentProtectionOn = true;
let currentDisplayId = null;
let mouseEventsIgnored = false;
let lastVisibleWindows = new Set(['header']);
const HEADER_HEIGHT = 60;
const DEFAULT_WINDOW_WIDTH = 345;
let currentHeaderState = 'apikey';
const windowPool = new Map();
let fixedYPosition = 0;
let lastScreenshot = null;
let settingsHideTimer = null;
let selectedCaptureSourceId = null;
const windowDefinitions = {
header: {
file: 'header.html',
options: {
/*…*/
},
allowedStates: ['apikey', 'app'],
},
ask: {
file: 'ask.html',
options: {
/*…*/
},
allowedStates: ['app'],
},
listen: {
file: 'assistant.html',
options: {
/*…*/
},
allowedStates: ['app'],
},
settings: {
file: 'settings.html',
options: {
/*…*/
},
allowedStates: ['app'],
},
};
const featureWindows = ['listen','ask','settings'];
function createFeatureWindows(header) {
if (windowPool.has('listen')) return;
const commonChildOptions = {
parent: header,
show: false,
frame: false,
transparent: true,
hasShadow: false,
skipTaskbar: true,
hiddenInMissionControl: true,
resizable: false,
webPreferences: { nodeIntegration: true, contextIsolation: false },
};
// listen
const listen = new BrowserWindow({
...commonChildOptions, width:400,height:300,minWidth:400,maxWidth:400,
minHeight:200,maxHeight:700,
});
listen.setContentProtection(isContentProtectionOn);
listen.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true});
listen.loadFile(path.join(__dirname,'../app/content.html'),{query:{view:'listen'}});
windowPool.set('listen', listen);
// ask
const ask = new BrowserWindow({ ...commonChildOptions, width:600, height:350 });
ask.setContentProtection(isContentProtectionOn);
ask.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true});
ask.loadFile(path.join(__dirname,'../app/content.html'),{query:{view:'ask'}});
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, height:450, parent:undefined });
settings.setContentProtection(isContentProtectionOn);
settings.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true});
settings.loadFile(path.join(__dirname,'../app/content.html'),{query:{view:'customize'}})
.catch(console.error);
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 isAllowed(name) {
const def = windowDefinitions[name];
return def && def.allowedStates.includes(currentHeaderState);
}
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();
}
class WindowLayoutManager {
constructor() {
this.isUpdating = false;
this.PADDING = 80;
}
updateLayout() {
if (this.isUpdating) return;
this.isUpdating = true;
setImmediate(() => {
this.positionWindows();
this.isUpdating = false;
});
}
positionWindows() {
const header = windowPool.get('header');
if (!header?.getBounds) return;
const headerBounds = header.getBounds();
const display = getCurrentDisplay(header);
const { width: screenWidth, height: screenHeight } = display.workAreaSize;
const { x: workAreaX, y: workAreaY } = display.workArea;
const headerCenterX = headerBounds.x - workAreaX + headerBounds.width / 2;
const headerCenterY = headerBounds.y - workAreaY + headerBounds.height / 2;
const relativeX = headerCenterX / screenWidth;
const relativeY = headerCenterY / screenHeight;
const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY);
this.positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY);
this.positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY);
}
determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY) {
const spaceBelow = screenHeight - (headerBounds.y + headerBounds.height);
const spaceAbove = headerBounds.y;
const spaceLeft = headerBounds.x;
const spaceRight = screenWidth - (headerBounds.x + headerBounds.width);
const spaces = {
below: spaceBelow,
above: spaceAbove,
left: spaceLeft,
right: spaceRight,
};
if (spaceBelow >= 400) {
return {
name: 'below',
primary: 'below',
secondary: relativeX < 0.5 ? 'right' : 'left',
};
} else if (spaceAbove >= 400) {
return {
name: 'above',
primary: 'above',
secondary: relativeX < 0.5 ? 'right' : 'left',
};
} else if (relativeX < 0.3 && spaceRight >= 800) {
return {
name: 'right-side',
primary: 'right',
secondary: spaceBelow > spaceAbove ? 'below' : 'above',
};
} else if (relativeX > 0.7 && spaceLeft >= 800) {
return {
name: 'left-side',
primary: 'left',
secondary: spaceBelow > spaceAbove ? 'below' : 'above',
};
} else {
return {
name: 'adaptive',
primary: spaceBelow > spaceAbove ? 'below' : 'above',
secondary: spaceRight > spaceLeft ? 'right' : 'left',
};
}
}
positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) {
const ask = windowPool.get('ask');
const listen = windowPool.get('listen');
const askVisible = ask && ask.isVisible() && !ask.isDestroyed();
const listenVisible = listen && listen.isVisible() && !listen.isDestroyed();
if (!askVisible && !listenVisible) return;
const PAD = 8;
/* ① 헤더 중심 X를 “디스플레이 기준 상대좌표”로 변환 */
const headerCenterXRel = headerBounds.x - workAreaX + headerBounds.width / 2;
let askBounds = askVisible ? ask.getBounds() : null;
let listenBounds = listenVisible ? listen.getBounds() : null;
/* ------------------------------------------------- */
/* 두 창 모두 보이는 경우 */
/* ------------------------------------------------- */
if (askVisible && listenVisible) {
const combinedWidth = listenBounds.width + PAD + askBounds.width;
/* ② 모든 X 좌표를 상대좌표로 계산 */
let groupStartXRel = headerCenterXRel - combinedWidth / 2;
let listenXRel = groupStartXRel;
let askXRel = groupStartXRel + listenBounds.width + PAD;
/* 좌우 화면 여백 클램프 역시 상대좌표로 */
if (listenXRel < PAD) {
listenXRel = PAD;
askXRel = listenXRel + listenBounds.width + PAD;
}
if (askXRel + askBounds.width > screenWidth - PAD) {
askXRel = screenWidth - PAD - askBounds.width;
listenXRel = askXRel - listenBounds.width - PAD;
}
/* Y 좌표는 이미 상대값으로 계산돼 있음 */
let yRel;
switch (strategy.primary) {
case 'below':
yRel = headerBounds.y - workAreaY + headerBounds.height + PAD;
break;
case 'above':
yRel = headerBounds.y - workAreaY - Math.max(askBounds.height, listenBounds.height) - PAD;
break;
default:
yRel = headerBounds.y - workAreaY + headerBounds.height + PAD;
break;
}
/* ③ setBounds 직전에 workAreaX/Y를 더해 절대좌표로 변환 */
listen.setBounds({
x: Math.round(listenXRel + workAreaX),
y: Math.round(yRel + workAreaY),
width: listenBounds.width,
height: listenBounds.height,
});
ask.setBounds({
x: Math.round(askXRel + workAreaX),
y: Math.round(yRel + workAreaY),
width: askBounds.width,
height: askBounds.height,
});
/* ------------------------------------------------- */
/* 하나만 보이는 경우 */
/* ------------------------------------------------- */
} else {
const win = askVisible ? ask : listen;
const winBounds = askVisible ? askBounds : listenBounds;
/* X, Y 둘 다 상대좌표로 계산 */
let xRel = headerCenterXRel - winBounds.width / 2;
let yRel;
switch (strategy.primary) {
case 'below':
yRel = headerBounds.y - workAreaY + headerBounds.height + PAD;
break;
case 'above':
yRel = headerBounds.y - workAreaY - winBounds.height - PAD;
break;
default:
yRel = headerBounds.y - workAreaY + headerBounds.height + PAD;
break;
}
/* 화면 경계 클램프 */
xRel = Math.max(PAD, Math.min(screenWidth - winBounds.width - PAD, xRel));
yRel = Math.max(PAD, Math.min(screenHeight - winBounds.height - PAD, yRel));
/* 절대좌표로 변환 후 배치 */
win.setBounds({
x: Math.round(xRel + workAreaX),
y: Math.round(yRel + workAreaY),
width: winBounds.width,
height: winBounds.height,
});
}
}
positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight) {
const settings = windowPool.get('settings');
if (!settings?.getBounds || !settings.isVisible()) return;
// if (settings.__lockedByButton) return;
if (settings.__lockedByButton) {
const headerDisplay = getCurrentDisplay(windowPool.get('header'));
const settingsDisplay = getCurrentDisplay(settings);
if (headerDisplay.id !== settingsDisplay.id) {
settings.__lockedByButton = false;
} else {
return; // 같은 화면이면 그대로 둔다
}
}
const settingsBounds = settings.getBounds();
const PAD = 5;
const buttonPadding = 17;
let x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding;
let y = headerBounds.y + headerBounds.height + PAD;
const otherVisibleWindows = [];
['listen', 'ask'].forEach(name => {
const win = windowPool.get(name);
if (win && win.isVisible() && !win.isDestroyed()) {
otherVisibleWindows.push({
name,
bounds: win.getBounds(),
});
}
});
const settingsNewBounds = { x, y, width: settingsBounds.width, height: settingsBounds.height };
let hasOverlap = false;
for (const otherWin of otherVisibleWindows) {
if (this.boundsOverlap(settingsNewBounds, otherWin.bounds)) {
hasOverlap = true;
break;
}
}
if (hasOverlap) {
x = headerBounds.x + headerBounds.width + PAD;
y = headerBounds.y;
settingsNewBounds.x = x;
settingsNewBounds.y = y;
if (x + settingsBounds.width > screenWidth - 10) {
x = headerBounds.x - settingsBounds.width - PAD;
settingsNewBounds.x = x;
}
if (x < 10) {
x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding;
y = headerBounds.y - settingsBounds.height - PAD;
settingsNewBounds.x = x;
settingsNewBounds.y = y;
if (y < 10) {
x = headerBounds.x + headerBounds.width - settingsBounds.width;
y = headerBounds.y + headerBounds.height + PAD;
}
}
}
x = Math.max(10, Math.min(screenWidth - settingsBounds.width - 10, x));
y = Math.max(10, Math.min(screenHeight - settingsBounds.height - 10, y));
settings.setBounds({ x, y });
settings.moveTop();
// console.log(`[Layout] Settings positioned at (${x}, ${y}) ${hasOverlap ? '(adjusted for overlap)' : '(default position)'}`);
}
boundsOverlap(bounds1, bounds2) {
const margin = 10;
return !(
bounds1.x + bounds1.width + margin < bounds2.x ||
bounds2.x + bounds2.width + margin < bounds1.x ||
bounds1.y + bounds1.height + margin < bounds2.y ||
bounds2.y + bounds2.height + margin < bounds1.y
);
}
isWindowVisible(windowName) {
const window = windowPool.get(windowName);
return window && !window.isDestroyed() && window.isVisible();
}
destroy() {}
}
class SmoothMovementManager {
constructor() {
this.stepSize = 80;
this.animationDuration = 300;
this.headerPosition = { x: 0, y: 0 };
this.isAnimating = false;
this.hiddenPosition = null;
this.lastVisiblePosition = null;
this.currentDisplayId = null;
}
moveToDisplay(displayId) {
const header = windowPool.get('header');
if (!header || !header.isVisible() || this.isAnimating) return;
const targetDisplay = getDisplayById(displayId);
if (!targetDisplay) return;
const currentBounds = header.getBounds();
const currentDisplay = getCurrentDisplay(header);
if (currentDisplay.id === targetDisplay.id) {
console.log('[Movement] Already on target display');
return;
}
const relativeX = (currentBounds.x - currentDisplay.workArea.x) / currentDisplay.workAreaSize.width;
const relativeY = (currentBounds.y - currentDisplay.workArea.y) / currentDisplay.workAreaSize.height;
const targetX = targetDisplay.workArea.x + targetDisplay.workAreaSize.width * relativeX;
const targetY = targetDisplay.workArea.y + targetDisplay.workAreaSize.height * relativeY;
const finalX = Math.max(
targetDisplay.workArea.x,
Math.min(targetDisplay.workArea.x + targetDisplay.workAreaSize.width - currentBounds.width, targetX)
);
const finalY = Math.max(
targetDisplay.workArea.y,
Math.min(targetDisplay.workArea.y + targetDisplay.workAreaSize.height - currentBounds.height, targetY)
);
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
this.animateToPosition(header, finalX, finalY);
this.currentDisplayId = targetDisplay.id;
}
hideToEdge(edge, callback) {
const header = windowPool.get('header');
if (!header || !header.isVisible() || this.isAnimating) return;
console.log(`[Movement] Hiding to ${edge} edge`);
const currentBounds = header.getBounds();
this.lastVisiblePosition = { x: currentBounds.x, y: currentBounds.y };
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
const display = getCurrentDisplay(header);
const { width: screenWidth, height: screenHeight } = display.workAreaSize;
const { x: workAreaX, y: workAreaY } = display.workArea;
const headerBounds = header.getBounds();
let targetX = this.headerPosition.x;
let targetY = this.headerPosition.y;
switch (edge) {
case 'top':
targetY = workAreaY - headerBounds.height - 20;
break;
case 'bottom':
targetY = workAreaY + screenHeight + 20;
break;
case 'left':
targetX = workAreaX - headerBounds.width - 20;
break;
case 'right':
targetX = workAreaX + screenWidth + 20;
break;
}
this.hiddenPosition = { x: targetX, y: targetY, edge };
this.isAnimating = true;
const startX = this.headerPosition.x;
const startY = this.headerPosition.y;
const duration = 400;
const startTime = Date.now();
const animate = () => {
if (!header || typeof header.setPosition !== 'function' || header.isDestroyed()) {
this.isAnimating = false;
return;
}
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = progress * progress * progress;
const currentX = startX + (targetX - startX) * eased;
const currentY = startY + (targetY - startY) * eased;
// Validate computed positions before using
if (!Number.isFinite(currentX) || !Number.isFinite(currentY)) {
console.error('[Movement] Invalid animation values for hide:', {
currentX, currentY, progress, eased, startX, startY, targetX, targetY
});
this.isAnimating = false;
return;
}
// Safely call setPosition
try {
header.setPosition(Math.round(currentX), Math.round(currentY));
} catch (err) {
console.error('[Movement] Failed to set position:', err);
this.isAnimating = false;
return;
}
if (progress < 1) {
setTimeout(animate, 8);
} else {
this.headerPosition = { x: targetX, y: targetY };
if (Number.isFinite(targetX) && Number.isFinite(targetY)) {
try {
header.setPosition(Math.round(targetX), Math.round(targetY));
} catch (err) {
console.error('[Movement] Failed to set final position:', err);
}
}
this.isAnimating = false;
if (typeof callback === 'function') {
try {
callback();
} catch (err) {
console.error('[Movement] Callback error:', err);
}
}
console.log(`[Movement] Hide to ${edge} completed`);
}
};
animate();
}
showFromEdge(callback) {
const header = windowPool.get('header');
if (!header || this.isAnimating || !this.hiddenPosition || !this.lastVisiblePosition) return;
console.log(`[Movement] Showing from ${this.hiddenPosition.edge} edge`);
header.setPosition(this.hiddenPosition.x, this.hiddenPosition.y);
this.headerPosition = { x: this.hiddenPosition.x, y: this.hiddenPosition.y };
const targetX = this.lastVisiblePosition.x;
const targetY = this.lastVisiblePosition.y;
this.isAnimating = true;
const startX = this.headerPosition.x;
const startY = this.headerPosition.y;
const duration = 500;
const startTime = Date.now();
const animate = () => {
if (!header || header.isDestroyed()) {
this.isAnimating = false;
return;
}
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const c1 = 1.70158;
const c3 = c1 + 1;
const eased = 1 + c3 * Math.pow(progress - 1, 3) + c1 * Math.pow(progress - 1, 2);
const currentX = startX + (targetX - startX) * eased;
const currentY = startY + (targetY - startY) * eased;
if (!Number.isFinite(currentX) || !Number.isFinite(currentY)) {
console.error('[Movement] Invalid animation values for show:', { currentX, currentY, progress, eased });
this.isAnimating = false;
return;
}
header.setPosition(Math.round(currentX), Math.round(currentY));
if (progress < 1) {
setTimeout(animate, 8);
} else {
this.headerPosition = { x: targetX, y: targetY };
this.headerPosition = { x: targetX, y: targetY };
if (Number.isFinite(targetX) && Number.isFinite(targetY)) {
header.setPosition(Math.round(targetX), Math.round(targetY));
}
this.isAnimating = false;
this.hiddenPosition = null;
this.lastVisiblePosition = null;
if (callback) callback();
console.log(`[Movement] Show from edge completed`);
}
};
animate();
}
moveStep(direction) {
const header = windowPool.get('header');
if (!header || !header.isVisible() || this.isAnimating) return;
console.log(`[Movement] Step ${direction}`);
const currentBounds = header.getBounds();
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
let targetX = this.headerPosition.x;
let targetY = this.headerPosition.y;
switch (direction) {
case 'left':
targetX -= this.stepSize;
break;
case 'right':
targetX += this.stepSize;
break;
case 'up':
targetY -= this.stepSize;
break;
case 'down':
targetY += this.stepSize;
break;
default:
return;
}
const displays = screen.getAllDisplays();
let validPosition = false;
for (const display of displays) {
const { x, y, width, height } = display.workArea;
const headerBounds = header.getBounds();
if (targetX >= x && targetX + headerBounds.width <= x + width && targetY >= y && targetY + headerBounds.height <= y + height) {
validPosition = true;
break;
}
}
if (!validPosition) {
const nearestDisplay = screen.getDisplayNearestPoint({ x: targetX, y: targetY });
const { x, y, width, height } = nearestDisplay.workArea;
const headerBounds = header.getBounds();
targetX = Math.max(x, Math.min(x + width - headerBounds.width, targetX));
targetY = Math.max(y, Math.min(y + height - headerBounds.height, targetY));
}
if (targetX === this.headerPosition.x && targetY === this.headerPosition.y) {
console.log(`[Movement] Already at boundary for ${direction}`);
return;
}
this.animateToPosition(header, targetX, targetY);
}
animateToPosition(header, targetX, targetY) {
this.isAnimating = true;
const startX = this.headerPosition.x;
const startY = this.headerPosition.y;
const startTime = Date.now();
if (!Number.isFinite(targetX) || !Number.isFinite(targetY) || !Number.isFinite(startX) || !Number.isFinite(startY)) {
console.error('[Movement] Invalid position values:', { startX, startY, targetX, targetY });
this.isAnimating = false;
return;
}
const animate = () => {
if (!header || header.isDestroyed()) {
this.isAnimating = false;
return;
}
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / this.animationDuration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const currentX = startX + (targetX - startX) * eased;
const currentY = startY + (targetY - startY) * eased;
if (!Number.isFinite(currentX) || !Number.isFinite(currentY)) {
console.error('[Movement] Invalid animation values:', { currentX, currentY, progress, eased });
this.isAnimating = false;
return;
}
header.setPosition(Math.round(currentX), Math.round(currentY));
if (progress < 1) {
setTimeout(animate, 8);
} else {
this.headerPosition = { x: targetX, y: targetY };
if (Number.isFinite(targetX) && Number.isFinite(targetY)) {
header.setPosition(Math.round(targetX), Math.round(targetY));
} else {
console.warn('[Movement] Final position invalid, skip setPosition:', { targetX, targetY });
}
this.isAnimating = false;
updateLayout();
console.log(`[Movement] Step completed to (${targetX}, ${targetY})`);
}
};
animate();
}
moveToEdge(direction) {
const header = windowPool.get('header');
if (!header || !header.isVisible() || this.isAnimating) return;
console.log(`[Movement] Move to edge: ${direction}`);
const display = getCurrentDisplay(header);
const { width, height } = display.workAreaSize;
const { x: workAreaX, y: workAreaY } = display.workArea;
const headerBounds = header.getBounds();
const currentBounds = header.getBounds();
let targetX = currentBounds.x;
let targetY = currentBounds.y;
switch (direction) {
case 'left':
targetX = workAreaX;
break;
case 'right':
targetX = workAreaX + width - headerBounds.width;
break;
case 'up':
targetY = workAreaY;
break;
case 'down':
targetY = workAreaY + height - headerBounds.height;
break;
}
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
this.isAnimating = true;
const startX = this.headerPosition.x;
const startY = this.headerPosition.y;
const duration = 400;
const startTime = Date.now(); // 이 줄을 animate 함수 정의 전으로 이동
if (!Number.isFinite(targetX) || !Number.isFinite(targetY) || !Number.isFinite(startX) || !Number.isFinite(startY)) {
console.error('[Movement] Invalid edge position values:', { startX, startY, targetX, targetY });
this.isAnimating = false;
return;
}
const animate = () => {
if (!header || header.isDestroyed()) {
this.isAnimating = false;
return;
}
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 4);
const currentX = startX + (targetX - startX) * eased;
const currentY = startY + (targetY - startY) * eased;
if (!Number.isFinite(currentX) || !Number.isFinite(currentY)) {
console.error('[Movement] Invalid edge animation values:', { currentX, currentY, progress, eased });
this.isAnimating = false;
return;
}
header.setPosition(Math.round(currentX), Math.round(currentY));
if (progress < 1) {
setTimeout(animate, 8);
} else {
if (Number.isFinite(targetX) && Number.isFinite(targetY)) {
header.setPosition(Math.round(targetX), Math.round(targetY));
}
this.headerPosition = { x: targetX, y: targetY };
this.isAnimating = false;
updateLayout();
console.log(`[Movement] Edge movement completed: ${direction}`);
}
};
animate();
}
handleKeyPress(direction) {}
handleKeyRelease(direction) {}
forceStopMovement() {
this.isAnimating = false;
}
destroy() {
this.isAnimating = false;
console.log('[Movement] Destroyed');
}
}
const layoutManager = new WindowLayoutManager();
let movementManager = null;
function toggleAllWindowsVisibility() {
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 ensureDataDirectories() {
const homeDir = os.homedir();
const pickleGlassDir = path.join(homeDir, '.pickle-glass');
const dataDir = path.join(pickleGlassDir, 'data');
const imageDir = path.join(dataDir, 'image');
const audioDir = path.join(dataDir, 'audio');
[pickleGlassDir, dataDir, imageDir, audioDir].forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
return { imageDir, audioDir };
}
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();
const header = new BrowserWindow({
width: DEFAULT_WINDOW_WIDTH,
height: HEADER_HEIGHT,
x: initialX,
y: initialY,
frame: false,
transparent: true,
alwaysOnTop: true,
skipTaskbar: true,
hiddenInMissionControl: true,
resizable: false,
focusable: true,
acceptFirstMouse: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
backgroundThrottling: false,
webSecurity: false,
},
});
windowPool.set('header', header);
if (currentHeaderState === 'app') {
createFeatureWindows(header);
}
windowPool.set('header', header);
if (currentHeaderState === 'app') {
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 === 'app') {
createFeatureWindows(windowPool.get('header'));
}
if (!windowPool.get(featureName) && currentHeaderState === 'app') {
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() {
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) {}
});
};
const openaiSessionRef = { current: null };
if (!header) {
return updateGlobalShortcuts(defaultKeybinds, undefined, sendToRenderer, openaiSessionRef);
}
header.webContents
.executeJavaScript(`(() => localStorage.getItem('customKeybinds'))()`)
.then(saved => (saved ? JSON.parse(saved) : {}))
.then(savedKeybinds => {
const keybinds = { ...defaultKeybinds, ...savedKeybinds };
updateGlobalShortcuts(keybinds, header, sendToRenderer, openaiSessionRef);
})
.catch(() => updateGlobalShortcuts(defaultKeybinds, header, sendToRenderer, openaiSessionRef));
}
function updateLayout() {
layoutManager.updateLayout();
}
function setupIpcHandlers(openaiSessionRef) {
const layoutManager = new WindowLayoutManager();
// const movementManager = new SmoothMovementManager();
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('message-sending', async event => {
console.log('📨 Main: Received message-sending signal');
const askWindow = windowPool.get('ask');
if (askWindow && !askWindow.isDestroyed()) {
console.log('📤 Main: Sending hide-text-input to ask window');
askWindow.webContents.send('hide-text-input');
return { success: true };
}
return { success: false };
});
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 === 'app') {
createFeatureWindows(windowPool.get('header'));
} else { // 'apikey'
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, { current: null });
})
.catch(console.error);
}
});
ipcMain.handle('get-available-screens', async () => {
try {
const sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: 300, height: 200 },
});
const displays = screen.getAllDisplays();
return sources.map((source, index) => {
const display = displays[index] || displays[0];
return {
id: source.id,
name: source.name,
thumbnail: source.thumbnail.toDataURL(),
display: {
id: display.id,
bounds: display.bounds,
workArea: display.workArea,
scaleFactor: display.scaleFactor,
isPrimary: display.id === screen.getPrimaryDisplay().id,
},
};
});
} catch (error) {
console.error('Failed to get available screens:', error);
return [];
}
});
ipcMain.handle('set-capture-source', (event, sourceId) => {
selectedCaptureSourceId = sourceId;
console.log(`[Capture] Selected source: ${sourceId}`);
return { success: true };
});
ipcMain.handle('get-capture-source', () => {
return selectedCaptureSourceId;
});
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.on('move-to-edge', (event, direction) => {
if (movementManager) {
movementManager.moveToEdge(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 = {}) => {
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,
};
}
});
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-auth-state-changed', (event, user) => {
console.log('[WindowManager] Firebase auth state changed:', user ? user.email : 'null');
const previousUser = currentFirebaseUser;
// 🛡️ Guard: ignore duplicate events where auth state did not actually change
const sameUser = user && previousUser && user.uid && previousUser.uid && user.uid === previousUser.uid;
const bothNull = !user && !previousUser;
if (sameUser || bothNull) {
// No real state change ➜ skip further processing
console.log('[WindowManager] No real state change, skipping further processing');
return;
}
currentFirebaseUser = user;
if (user && user.email) {
(async () => {
try {
const existingKey = getStoredApiKey();
if (existingKey) {
console.log('[WindowManager] Virtual key already exists, skipping fetch');
return;
}
if (!user.idToken) {
console.warn('[WindowManager] No ID token available, cannot fetch virtual key');
return;
}
console.log('[WindowManager] Fetching virtual key via onAuthStateChanged');
const vKey = await getVirtualKeyByEmail(user.email, user.idToken);
console.log('[WindowManager] Virtual key fetched successfully');
setApiKey(vKey)
.then(() => {
windowPool.forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('api-key-updated');
}
});
})
.catch(err => console.error('[WindowManager] Failed to save virtual key:', err));
} catch (err) {
console.error('[WindowManager] Virtual key fetch failed:', err);
if (err.message.includes('token') || err.message.includes('Authentication')) {
windowPool.forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('auth-error', {
message: 'Authentication expired. Please login again.',
shouldLogout: true,
});
}
});
}
}
})();
}
// If the user logged out, also hide the settings window
if (!user && previousUser) {
// ADDED: Only trigger on actual state change from logged in to logged out
console.log('[WindowManager] User logged out, clearing API key and notifying renderers');
setApiKey(null)
.then(() => {
console.log('[WindowManager] API key cleared successfully after logout');
windowPool.forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('api-key-removed');
}
});
})
.catch(err => {
console.error('[WindowManager] setApiKey error:', err);
windowPool.forEach(win => {
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 logout.');
}
}
// Broadcast to all windows
windowPool.forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('firebase-user-updated', user);
}
});
});
ipcMain.handle('get-current-firebase-user', () => {
return currentFirebaseUser;
});
ipcMain.handle('firebase-logout', () => {
console.log('[WindowManager] Received request to log out.');
setApiKey(null)
.then(() => {
console.log('[WindowManager] API key cleared successfully after logout');
windowPool.forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('api-key-removed');
}
});
})
.catch(err => {
console.error('[WindowManager] setApiKey error:', err);
windowPool.forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('api-key-removed');
}
});
});
const header = windowPool.get('header');
if (header && !header.isDestroyed()) {
console.log('[WindowManager] Header window exists, sending to renderer...');
header.webContents.send('request-firebase-logout');
}
});
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 {
// Store in SQLite that permissions have been completed
await sqliteClient.query(
'INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, ?)',
['permissions_completed', 'true']
);
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 result = await sqliteClient.query(
'SELECT value FROM system_settings WHERE key = ?',
['permissions_completed']
);
const completed = result.length > 0 && result[0].value === 'true';
console.log('[Permissions] Permissions completed status:', completed);
return completed;
} catch (error) {
console.error('[Permissions] Error checking permissions completed status:', error);
return false;
}
});
}
let storedApiKey = null;
let storedProvider = 'openai';
async function setApiKey(apiKey, provider = 'openai') {
storedApiKey = apiKey;
storedProvider = provider;
console.log('[WindowManager] API key and provider stored (and will be persisted to DB)');
try {
await sqliteClient.saveApiKey(apiKey, sqliteClient.defaultUserId, provider);
console.log('[WindowManager] API key and provider saved to SQLite');
} 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 loadApiKeyFromDb() {
try {
const user = await sqliteClient.getUser(sqliteClient.defaultUserId);
if (user && user.api_key) {
console.log('[WindowManager] API key and provider loaded from SQLite for default user.');
storedApiKey = user.api_key;
storedProvider = user.provider || 'openai';
return user.api_key;
}
return null;
} catch (error) {
console.error('[WindowManager] Failed to load API key from SQLite:', error);
return null;
}
}
function getCurrentFirebaseUser() {
return currentFirebaseUser;
}
function isFirebaseLoggedIn() {
return !!currentFirebaseUser;
}
function setCurrentFirebaseUser(user) {
currentFirebaseUser = user;
console.log('[WindowManager] Firebase user updated:', user ? user.email : 'null');
}
function getStoredApiKey() {
return storedApiKey;
}
function getStoredProvider() {
return storedProvider || 'openai';
}
function setupApiKeyIPC() {
const { ipcMain } = require('electron');
ipcMain.handle('get-stored-api-key', async () => {
if (storedApiKey === null) {
const dbKey = await loadApiKeyFromDb();
if (dbKey) {
await setApiKey(dbKey, storedProvider);
}
}
return storedApiKey;
});
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-current-api-key', async () => {
if (storedApiKey === null) {
const dbKey = await loadApiKeyFromDb();
if (dbKey) {
await setApiKey(dbKey, storedProvider);
}
}
return storedApiKey;
});
ipcMain.handle('get-ai-provider', async () => {
console.log('[WindowManager] AI provider requested from renderer');
return storedProvider || 'openai';
});
console.log('[WindowManager] API key related IPC handlers registered (SQLite-backed)');
}
function createWindow(sendToRenderer, openaiSessionRef) {
const mainWindow = new BrowserWindow({
width: DEFAULT_WINDOW_WIDTH,
height: HEADER_HEIGHT,
x: initialX,
y: initialY,
frame: false,
transparent: false,
hasShadow: true,
alwaysOnTop: true,
skipTaskbar: true,
hiddenInMissionControl: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
backgroundThrottling: false,
enableBlinkFeatures: 'GetDisplayMedia',
webSecurity: true,
allowRunningInsecureContent: false,
},
backgroundColor: '#FF0000',
});
const { session, desktopCapturer } = require('electron');
session.defaultSession.setDisplayMediaRequestHandler(
(request, callback) => {
desktopCapturer.getSources({ types: ['screen'] }).then(sources => {
callback({ video: sources[0], audio: 'loopback' });
});
},
{ useSystemPicker: true }
);
mainWindow.setResizable(false);
mainWindow.setContentProtection(true);
mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
const primaryDisplay = screen.getPrimaryDisplay();
const { width: screenWidth } = primaryDisplay.workAreaSize;
const x = Math.floor((screenWidth - DEFAULT_WINDOW_WIDTH) / 2);
const y = 0;
mainWindow.setPosition(x, y);
if (process.platform === 'win32') {
mainWindow.setAlwaysOnTop(true, 'screen-saver', 1);
}
mainWindow.loadFile(path.join(__dirname, '../index.html'));
mainWindow.webContents.once('dom-ready', () => {
setTimeout(() => {
const defaultKeybinds = getDefaultKeybinds();
let keybinds = defaultKeybinds;
mainWindow.webContents
.executeJavaScript(
`
(() => {
try {
const savedKeybinds = localStorage.getItem('customKeybinds');
const savedContentProtection = localStorage.getItem('contentProtection');
return {
keybinds: savedKeybinds ? JSON.parse(savedKeybinds) : null,
contentProtection: savedContentProtection !== null ? savedContentProtection === 'true' : true
};
} catch (e) {
return { keybinds: null, contentProtection: true };
}
})()
`
)
.then(savedSettings => {
if (savedSettings.keybinds) {
keybinds = { ...defaultKeybinds, ...savedSettings.keybinds };
}
mainWindow.setContentProtection(savedSettings.contentProtection);
updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, openaiSessionRef);
})
.catch(() => {
mainWindow.setContentProtection(true);
updateGlobalShortcuts(defaultKeybinds, mainWindow, sendToRenderer, openaiSessionRef);
});
}, 150);
});
setupWindowIpcHandlers(mainWindow, sendToRenderer, openaiSessionRef);
return mainWindow;
}
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, openaiSessionRef) {
console.log('Updating global shortcuts with:', keybinds);
// Unregister all existing shortcuts
globalShortcut.unregisterAll();
if (movementManager) {
movementManager.destroy();
}
movementManager = new SmoothMovementManager();
const isMac = process.platform === 'darwin';
const modifier = isMac ? 'Cmd' : 'Ctrl';
if (keybinds.toggleVisibility) {
try {
globalShortcut.register(keybinds.toggleVisibility, toggleAllWindowsVisibility);
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, () => {
if (movementManager) {
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);
}
}
}
function setupWindowIpcHandlers(mainWindow, sendToRenderer, openaiSessionRef) {
ipcMain.handle('resize-window', async (event, args) => {
try {
const { isMainViewVisible, view } = args;
let targetHeight = HEADER_HEIGHT;
let targetWidth = DEFAULT_WINDOW_WIDTH;
if (isMainViewVisible) {
const viewHeights = {
listen: 400,
customize: 600,
help: 550,
history: 550,
setup: 200,
};
targetHeight = viewHeights[view] || 400;
}
const [currentWidth, currentHeight] = mainWindow.getSize();
if (currentWidth !== targetWidth || currentHeight !== targetHeight) {
console.log('Window resize requested but disabled for manual resize prevention');
}
} catch (error) {
console.error('Error resizing window:', error);
}
});
ipcMain.handle('toggle-window-visibility', async event => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
});
ipcMain.handle('quit-application', async () => {
app.quit();
});
// Keep other essential IPC handlers
// ... other handlers like open-external, etc. can be added from the old file if needed
}
function clearApiKey() {
setApiKey(null);
}
async function getVirtualKeyByEmail(email, idToken) {
if (!idToken) {
throw new Error('Firebase ID token is required for virtual key request');
}
const resp = await fetch('https://serverless-api-sf3o.vercel.app/api/virtual_key', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${idToken}`,
},
body: JSON.stringify({ email: email.trim().toLowerCase() }),
redirect: 'follow',
});
const json = await resp.json().catch(() => ({}));
if (!resp.ok) {
console.error('[VK] API request failed:', json.message || 'Unknown error');
throw new Error(json.message || `HTTP ${resp.status}: Virtual key request failed`);
}
const vKey = json?.data?.virtualKey || json?.data?.virtual_key || json?.data?.newVKey?.slug;
if (!vKey) throw new Error('virtual key missing in response');
return vKey;
}
// Helper function to avoid code duplication
async function captureScreenshotInternal(options = {}) {
try {
const quality = options.quality || 'medium';
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 thumbnail = source.thumbnail;
let jpegQuality;
switch (quality) {
case 'high':
jpegQuality = 90;
break;
case 'low':
jpegQuality = 50;
break;
case 'medium':
default:
jpegQuality = 70;
break;
}
const buffer = thumbnail.toJPEG(jpegQuality);
const base64 = buffer.toString('base64');
const size = thumbnail.getSize();
return {
success: true,
base64,
width: size.width,
height: size.height,
};
} catch (error) {
throw error;
}
}
module.exports = {
createWindows,
windowPool,
fixedYPosition,
setApiKey,
getStoredApiKey,
getStoredProvider,
clearApiKey,
getCurrentFirebaseUser,
isFirebaseLoggedIn,
setCurrentFirebaseUser,
getVirtualKeyByEmail,
};