2567 lines
91 KiB
JavaScript
2567 lines
91 KiB
JavaScript
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,
|
||
};
|