From ba8401345b6ae4db44d6dd6a7ee5d3224a3a8639 Mon Sep 17 00:00:00 2001 From: jhyang0 Date: Sat, 5 Jul 2025 00:30:02 +0900 Subject: [PATCH] minor fix --- pickleglass_web/Dockerfile.backend | 17 - pickleglass_web/Dockerfile.frontend | 15 - pickleglass_web/docker-compose.yml | 35 - src/app/ApiKeyHeader.js | 67 +- src/app/AppHeader.js | 108 ++-- src/app/HeaderController.js | 196 ++++-- src/electron/windowManager.js | 970 ++++++++++++++++++++-------- src/index.js | 12 +- 8 files changed, 986 insertions(+), 434 deletions(-) delete mode 100644 pickleglass_web/Dockerfile.backend delete mode 100644 pickleglass_web/Dockerfile.frontend delete mode 100644 pickleglass_web/docker-compose.yml diff --git a/pickleglass_web/Dockerfile.backend b/pickleglass_web/Dockerfile.backend deleted file mode 100644 index 74a4526..0000000 --- a/pickleglass_web/Dockerfile.backend +++ /dev/null @@ -1,17 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -RUN apt-get update && apt-get install -y \ - gcc \ - && rm -rf /var/lib/apt/lists/* - -COPY requirements.txt . - -RUN pip install --no-cache-dir -r requirements.txt - -COPY backend/ . - -EXPOSE 8000 - -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file diff --git a/pickleglass_web/Dockerfile.frontend b/pickleglass_web/Dockerfile.frontend deleted file mode 100644 index 5cb13b4..0000000 --- a/pickleglass_web/Dockerfile.frontend +++ /dev/null @@ -1,15 +0,0 @@ -FROM node:18-alpine - -WORKDIR /app - -COPY package*.json ./ - -RUN npm ci --only=production - -COPY . . - -RUN npm run build - -EXPOSE 3000 - -CMD ["npm", "start"] \ No newline at end of file diff --git a/pickleglass_web/docker-compose.yml b/pickleglass_web/docker-compose.yml deleted file mode 100644 index 061817b..0000000 --- a/pickleglass_web/docker-compose.yml +++ /dev/null @@ -1,35 +0,0 @@ -version: '3.8' - -services: - backend: - build: - context: . - dockerfile: Dockerfile.backend - container_name: pickleglass-backend - restart: always - ports: - - "8000:8000" - environment: - - DATABASE_URL=/app/data/pickleglass.db - volumes: - - ./backend:/app - - ./data:/app/data - - frontend: - build: - context: . - dockerfile: Dockerfile.frontend - container_name: pickleglass-frontend - restart: always - ports: - - "3000:3000" - environment: - - NEXT_PUBLIC_API_URL=http://localhost:8000 - depends_on: - - backend - volumes: - - .:/app - - /app/node_modules - -volumes: - mongodb_data: \ No newline at end of file diff --git a/src/app/ApiKeyHeader.js b/src/app/ApiKeyHeader.js index 2940b50..f9540f4 100644 --- a/src/app/ApiKeyHeader.js +++ b/src/app/ApiKeyHeader.js @@ -346,9 +346,18 @@ export class ApiKeyHeader extends LitElement { const isValid = await this.validateApiKey(this.apiKey.trim()); if (isValid) { - console.log('API key valid - starting slide out animation'); - this.startSlideOutAnimation(); - this.validatedApiKey = this.apiKey.trim(); + console.log('API key valid - checking system permissions...'); + + const permissionResult = await this.checkAndRequestPermissions(); + + if (permissionResult.success) { + console.log('All permissions granted - starting slide out animation'); + this.startSlideOutAnimation(); + this.validatedApiKey = this.apiKey.trim(); + } else { + this.errorMessage = permissionResult.error || 'Permission setup required'; + console.log('Permission setup incomplete:', permissionResult); + } } else { this.errorMessage = 'Invalid API key - please check and try again'; console.log('API key validation failed'); @@ -398,6 +407,58 @@ export class ApiKeyHeader extends LitElement { } } + async checkAndRequestPermissions() { + if (!window.require) { + return { success: true }; + } + + const { ipcRenderer } = window.require('electron'); + + try { + const permissions = await ipcRenderer.invoke('check-system-permissions'); + console.log('[Permissions] Current status:', permissions); + + if (!permissions.needsSetup) { + return { success: true }; + } + + if (!permissions.microphone) { + console.log('[Permissions] Requesting microphone permission...'); + const micResult = await ipcRenderer.invoke('request-microphone-permission'); + + if (!micResult.success) { + console.log('[Permissions] Microphone permission denied'); + await ipcRenderer.invoke('open-system-preferences', 'microphone'); + return { + success: false, + error: 'Please grant microphone access in System Preferences' + }; + } + } + + if (!permissions.screen) { + console.log('[Permissions] Screen recording permission needed'); + await ipcRenderer.invoke('open-system-preferences', 'screen-recording'); + + this.errorMessage = 'Please grant screen recording permission and try again'; + this.requestUpdate(); + + return { + success: false, + error: 'Please grant screen recording access in System Preferences' + }; + } + + return { success: true }; + } catch (error) { + console.error('[Permissions] Error checking/requesting permissions:', error); + return { + success: false, + error: 'Failed to check permissions' + }; + } + } + startSlideOutAnimation() { this.classList.add('sliding-out'); } diff --git a/src/app/AppHeader.js b/src/app/AppHeader.js index ad0bcef..faf633b 100644 --- a/src/app/AppHeader.js +++ b/src/app/AppHeader.js @@ -10,25 +10,25 @@ export class AppHeader extends LitElement { display: block; transform: translate3d(0, 0, 0); backface-visibility: hidden; - transition: transform 0.25s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.25s ease-out; + transition: transform 0.2s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.2s ease-out; + will-change: transform, opacity; } :host(.hiding) { - animation: slideUp 0.45s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards; + animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.6, 1) forwards; } :host(.showing) { - animation: slideDown 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) forwards; + animation: slideDown 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; } :host(.sliding-in) { - animation: fadeIn 0.25s ease-out forwards; - will-change: opacity; + animation: fadeIn 0.2s ease-out forwards; } :host(.hidden) { opacity: 0; - transform: translateY(-180%) scale(0.8); + transform: translateY(-150%) scale(0.85); pointer-events: none; } @@ -36,65 +36,50 @@ export class AppHeader extends LitElement { 0% { opacity: 1; transform: translateY(0) scale(1); - filter: blur(0px) brightness(1); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + filter: blur(0px); } - 25% { - opacity: 0.85; - transform: translateY(-20%) scale(0.96); - filter: blur(0px) brightness(0.95); - box-shadow: 0 6px 28px rgba(0, 0, 0, 0.25); + 30% { + opacity: 0.7; + transform: translateY(-20%) scale(0.98); + filter: blur(0.5px); } - 50% { - opacity: 0.5; - transform: translateY(-60%) scale(0.9); - filter: blur(1px) brightness(0.85); - box-shadow: 0 3px 15px rgba(0, 0, 0, 0.15); - } - 75% { - opacity: 0.15; - transform: translateY(-120%) scale(0.85); - filter: blur(2px) brightness(0.75); - box-shadow: 0 1px 8px rgba(0, 0, 0, 0.08); + 70% { + opacity: 0.3; + transform: translateY(-80%) scale(0.92); + filter: blur(1.5px); } 100% { opacity: 0; - transform: translateY(-180%) scale(0.8); - filter: blur(3px) brightness(0.7); - box-shadow: 0 0px 0px rgba(0, 0, 0, 0); + transform: translateY(-150%) scale(0.85); + filter: blur(2px); } } @keyframes slideDown { 0% { opacity: 0; - transform: translateY(-180%) scale(0.8); - filter: blur(3px) brightness(0.7); - box-shadow: 0 0px 0px rgba(0, 0, 0, 0); + transform: translateY(-150%) scale(0.85); + filter: blur(2px); } - 40% { - opacity: 0.6; - transform: translateY(-30%) scale(0.95); - filter: blur(1px) brightness(0.9); - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); + 30% { + opacity: 0.5; + transform: translateY(-50%) scale(0.92); + filter: blur(1px); } - 70% { + 65% { opacity: 0.9; - transform: translateY(-5%) scale(1.01); - filter: blur(0.3px) brightness(1.02); - box-shadow: 0 7px 28px rgba(0, 0, 0, 0.28); + transform: translateY(-5%) scale(0.99); + filter: blur(0.2px); } 85% { opacity: 0.98; - transform: translateY(1%) scale(0.995); - filter: blur(0.1px) brightness(1.01); - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.31); + transform: translateY(2%) scale(1.005); + filter: blur(0px); } 100% { opacity: 1; transform: translateY(0) scale(1); - filter: blur(0px) brightness(1); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + filter: blur(0px); } } @@ -318,6 +303,7 @@ export class AppHeader extends LitElement { this.hasSlidIn = false; this.settingsHideTimer = null; this.isSessionActive = false; + this.animationEndTimer = null; if (window.require) { const { ipcRenderer } = window.require('electron'); @@ -388,7 +374,15 @@ export class AppHeader extends LitElement { } toggleVisibility() { - if (this.isAnimating) return; + if (this.isAnimating) { + console.log('[AppHeader] Animation already in progress, ignoring toggle'); + return; + } + + if (this.animationEndTimer) { + clearTimeout(this.animationEndTimer); + this.animationEndTimer = null; + } this.isAnimating = true; @@ -403,17 +397,34 @@ export class AppHeader extends LitElement { this.classList.remove('showing', 'hidden'); this.classList.add('hiding'); this.isVisible = false; + + this.animationEndTimer = setTimeout(() => { + if (this.classList.contains('hiding')) { + this.handleAnimationEnd({ target: this }); + } + }, 350); } show() { this.classList.remove('hiding', 'hidden'); this.classList.add('showing'); this.isVisible = true; + + this.animationEndTimer = setTimeout(() => { + if (this.classList.contains('showing')) { + this.handleAnimationEnd({ target: this }); + } + }, 400); } handleAnimationEnd(e) { if (e.target !== this) return; + if (this.animationEndTimer) { + clearTimeout(this.animationEndTimer); + this.animationEndTimer = null; + } + this.isAnimating = false; if (this.classList.contains('hiding')) { @@ -434,7 +445,7 @@ export class AppHeader extends LitElement { } else if (this.classList.contains('sliding-in')) { this.classList.remove('sliding-in'); this.hasSlidIn = true; - console.log('AppHeader slide-in animation completed'); + console.log('[AppHeader] Slide-in animation completed'); } } @@ -460,6 +471,11 @@ export class AppHeader extends LitElement { super.disconnectedCallback(); this.removeEventListener('animationend', this.handleAnimationEnd); + if (this.animationEndTimer) { + clearTimeout(this.animationEndTimer); + this.animationEndTimer = null; + } + if (window.require) { const { ipcRenderer } = window.require('electron'); ipcRenderer.removeAllListeners('toggle-header-visibility'); diff --git a/src/app/HeaderController.js b/src/app/HeaderController.js index ee02b6c..ef56920 100644 --- a/src/app/HeaderController.js +++ b/src/app/HeaderController.js @@ -81,11 +81,31 @@ class HeaderTransitionManager { if (error) { console.warn('[HeaderController] Login payload indicates verification failure. Proceeding to AppHeader UI only.'); - this.transitionToAppHeader(); + // Check permissions before transitioning + const permissionResult = await this.checkPermissions(); + if (permissionResult.success) { + this.transitionToAppHeader(); + } else { + console.log('[HeaderController] Permissions not granted after login error'); + if (this.apiKeyHeader) { + this.apiKeyHeader.errorMessage = permissionResult.error || 'Permission setup required'; + this.apiKeyHeader.requestUpdate(); + } + } } } catch (error) { console.error('[HeaderController] Sign-in failed', error); - this.transitionToAppHeader(); + // Check permissions before transitioning + const permissionResult = await this.checkPermissions(); + if (permissionResult.success) { + this.transitionToAppHeader(); + } else { + console.log('[HeaderController] Permissions not granted after sign-in failure'); + if (this.apiKeyHeader) { + this.apiKeyHeader.errorMessage = permissionResult.error || 'Permission setup required'; + this.apiKeyHeader.requestUpdate(); + } + } } }); @@ -125,7 +145,17 @@ class HeaderTransitionManager { console.log('[HeaderController] Firebase sign-in successful via ID token'); } else { console.warn('[HeaderController] No ID token received from deeplink, virtual key request may fail'); - this.transitionToAppHeader(); + // Check permissions before transitioning + const permissionResult = await this.checkPermissions(); + if (permissionResult.success) { + this.transitionToAppHeader(); + } else { + console.log('[HeaderController] Permissions not granted after Firebase auth'); + if (this.apiKeyHeader) { + this.apiKeyHeader.errorMessage = permissionResult.error || 'Permission setup required'; + this.apiKeyHeader.requestUpdate(); + } + } } } catch (error) { console.error('[HeaderController] Firebase auth failed:', error); @@ -173,11 +203,25 @@ class HeaderTransitionManager { } if (user) { - console.log('[HeaderController] User is logged in, transitioning to AppHeader'); - this.transitionToAppHeader(!this.hasApiKey); + console.log('[HeaderController] User is logged in, checking permissions...'); + const permissionResult = await this.checkPermissions(); + if (permissionResult.success) { + this.transitionToAppHeader(!this.hasApiKey); + } else { + console.log('[HeaderController] Permissions not granted, staying on ApiKeyHeader'); + if (this.apiKeyHeader) { + this.apiKeyHeader.errorMessage = permissionResult.error || 'Permission setup required'; + this.apiKeyHeader.requestUpdate(); + } + } } else if (this.hasApiKey) { - console.log('[HeaderController] No Firebase user but API key exists, showing AppHeader'); - this.transitionToAppHeader(false); + console.log('[HeaderController] No Firebase user but API key exists, checking permissions...'); + const permissionResult = await this.checkPermissions(); + if (permissionResult.success) { + this.transitionToAppHeader(false); + } else { + console.log('[HeaderController] Permissions not granted, staying on ApiKeyHeader'); + } } else { console.log('[HeaderController] No auth & no API key — showing ApiKeyHeader'); this.transitionToApiKeyHeader(); @@ -185,7 +229,6 @@ class HeaderTransitionManager { }); } - notifyHeaderState(stateOverride) { const state = stateOverride || this.currentHeaderType || 'apikey'; if (window.require) { @@ -193,33 +236,46 @@ class HeaderTransitionManager { } } - async _bootstrap() { - let storedKey = null; - if (window.require) { - try { - storedKey = await window - .require('electron') - .ipcRenderer.invoke('get-current-api-key'); - } catch (_) {} - } - this.hasApiKey = !!storedKey; - - const user = await new Promise(resolve => { - const unsubscribe = onAuthStateChanged(auth, u => { - unsubscribe(); - resolve(u); - }); - }); - - if (user || this.hasApiKey) { - await this._resizeForApp(); - this.ensureHeader('app'); - } else { - await this._resizeForApiKey(); - this.ensureHeader('apikey'); - } - } + async _bootstrap() { + let storedKey = null; + if (window.require) { + try { + storedKey = await window + .require('electron') + .ipcRenderer.invoke('get-current-api-key'); + } catch (_) {} + } + this.hasApiKey = !!storedKey; + const user = await new Promise(resolve => { + const unsubscribe = onAuthStateChanged(auth, u => { + unsubscribe(); + resolve(u); + }); + }); + + if (user || this.hasApiKey) { + const permissionResult = await this.checkPermissions(); + + if (permissionResult.success) { + await this._resizeForApp(); + this.ensureHeader('app'); + } else { + await this._resizeForApiKey(); + this.ensureHeader('apikey'); + + setTimeout(() => { + if (this.apiKeyHeader) { + this.apiKeyHeader.errorMessage = permissionResult.error || 'Permission setup required'; + this.apiKeyHeader.requestUpdate(); + } + }, 100); + } + } else { + await this._resizeForApiKey(); + this.ensureHeader('apikey'); + } + } async transitionToAppHeader(animate = true) { if (this.currentHeaderType === 'app') { @@ -249,23 +305,69 @@ class HeaderTransitionManager { } _resizeForApp() { - if (!window.require) return; - return window - .require('electron') - .ipcRenderer.invoke('resize-header-window', { width: 353, height: 60 }) - .catch(() => {}); + if (!window.require) return; + return window + .require('electron') + .ipcRenderer.invoke('resize-header-window', { width: 353, height: 60 }) + .catch(() => {}); + } + + async _resizeForApiKey() { + if (!window.require) return; + return window + .require('electron') + .ipcRenderer.invoke('resize-header-window', { width: 285, height: 220 }) + .catch(() => {}); + } + + async transitionToApiKeyHeader() { + await this._resizeForApiKey(); + + if (this.currentHeaderType !== 'apikey') { + this.ensureHeader('apikey'); + } + + if (this.apiKeyHeader) this.apiKeyHeader.reset(); + } + + async checkPermissions() { + if (!window.require) { + return { success: true }; } - async transitionToApiKeyHeader() { - await window.require('electron') - .ipcRenderer.invoke('resize-header-window', { width: 285, height: 220 }); + const { ipcRenderer } = window.require('electron'); + + try { + // Check permission status + const permissions = await ipcRenderer.invoke('check-system-permissions'); + console.log('[HeaderController] Current permissions:', permissions); - if (this.currentHeaderType !== 'apikey') { - this.ensureHeader('apikey'); - } - - if (this.apiKeyHeader) this.apiKeyHeader.reset(); + if (!permissions.needsSetup) { + return { success: true }; } + + // If permissions are not set up, return false + let errorMessage = ''; + if (!permissions.microphone && !permissions.screen) { + errorMessage = 'Microphone and screen recording access required'; + } else if (!permissions.microphone) { + errorMessage = 'Microphone access required'; + } else if (!permissions.screen) { + errorMessage = 'Screen recording access required'; + } + + return { + success: false, + error: errorMessage + }; + } catch (error) { + console.error('[HeaderController] Error checking permissions:', error); + return { + success: false, + error: 'Failed to check permissions' + }; + } + } } window.addEventListener('DOMContentLoaded', () => { diff --git a/src/electron/windowManager.js b/src/electron/windowManager.js index 5d8a8ab..5f217b1 100644 --- a/src/electron/windowManager.js +++ b/src/electron/windowManager.js @@ -224,7 +224,7 @@ class WindowLayoutManager { const PAD = 8; - /* ① 헤더 중심 X를 “디스플레이 기준 상대좌표”로 변환 */ + /* ① 헤더 중심 X를 "디스플레이 기준 상대좌표"로 변환 */ const headerCenterXRel = headerBounds.x - workAreaX + headerBounds.width / 2; let askBounds = askVisible ? ask.getBounds() : null; @@ -418,6 +418,49 @@ class SmoothMovementManager { this.hiddenPosition = null; this.lastVisiblePosition = null; this.currentDisplayId = null; + this.currentAnimationTimer = null; + this.animationAbortController = null; + this.animationFrameRate = 16; // ~60fps + } + + safeSetPosition(window, x, y) { + if (!window || window.isDestroyed()) { + return false; + } + + let safeX = Number.isFinite(x) ? Math.round(x) : 0; + let safeY = Number.isFinite(y) ? Math.round(y) : 0; + + if (Object.is(safeX, -0)) safeX = 0; + if (Object.is(safeY, -0)) safeY = 0; + + safeX = parseInt(safeX, 10); + safeY = parseInt(safeY, 10); + + if (!Number.isInteger(safeX) || !Number.isInteger(safeY)) { + console.error('[Movement] Invalid position after conversion:', { x: safeX, y: safeY, originalX: x, originalY: y }); + return false; + } + + try { + window.setPosition(safeX, safeY); + return true; + } catch (err) { + console.error('[Movement] setPosition failed with values:', { x: safeX, y: safeY }, err); + return false; + } + } + + cancelCurrentAnimation() { + if (this.currentAnimationTimer) { + clearTimeout(this.currentAnimationTimer); + this.currentAnimationTimer = null; + } + if (this.animationAbortController) { + this.animationAbortController.abort(); + this.animationAbortController = null; + } + this.isAnimating = false; } moveToDisplay(displayId) { @@ -456,50 +499,83 @@ class SmoothMovementManager { this.currentDisplayId = targetDisplay.id; } - hideToEdge(edge, callback) { + hideToEdge(edge, callback, errorCallback) { const header = windowPool.get('header'); - if (!header || !header.isVisible() || this.isAnimating) return; + if (!header || !header.isVisible()) { + if (errorCallback) errorCallback(new Error('Header not available or not visible')); + return; + } + // cancel current animation + this.cancelCurrentAnimation(); console.log(`[Movement] Hiding to ${edge} edge`); - const currentBounds = header.getBounds(); + let currentBounds; + try { + currentBounds = header.getBounds(); + } catch (err) { + console.error('[Movement] Failed to get header bounds:', err); + if (errorCallback) errorCallback(err); + return; + } + 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; + targetY = workAreaY - currentBounds.height - 20; break; case 'bottom': targetY = workAreaY + screenHeight + 20; break; case 'left': - targetX = workAreaX - headerBounds.width - 20; + targetX = workAreaX - currentBounds.width - 20; break; case 'right': targetX = workAreaX + screenWidth + 20; break; } + // 대상 위치 유효성 검사 + if (!Number.isFinite(targetX) || !Number.isFinite(targetY)) { + console.error('[Movement] Invalid target position:', { targetX, targetY }); + if (errorCallback) errorCallback(new Error('Invalid target position')); + return; + } + this.hiddenPosition = { x: targetX, y: targetY, edge }; + // create AbortController + this.animationAbortController = new AbortController(); + const signal = this.animationAbortController.signal; + this.isAnimating = true; const startX = this.headerPosition.x; const startY = this.headerPosition.y; - const duration = 400; + const duration = 300; const startTime = Date.now(); const animate = () => { - if (!header || typeof header.setPosition !== 'function' || header.isDestroyed()) { + // check aborted + if (signal.aborted) { this.isAnimating = false; + if (errorCallback) errorCallback(new Error('Animation aborted')); + return; + } + + // check destroyed + if (!header || header.isDestroyed()) { + this.isAnimating = false; + this.currentAnimationTimer = null; + if (errorCallback) errorCallback(new Error('Window destroyed during animation')); return; } @@ -510,44 +586,33 @@ class SmoothMovementManager { 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); + // set position safe + const success = this.safeSetPosition(header, currentX, currentY); + if (!success) { this.isAnimating = false; + this.currentAnimationTimer = null; + if (errorCallback) errorCallback(new Error('Failed to set position')); return; } if (progress < 1) { - setTimeout(animate, 8); + this.currentAnimationTimer = setTimeout(animate, this.animationFrameRate); } 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); - } - } + + // set final position + this.safeSetPosition(header, targetX, targetY); this.isAnimating = false; + this.currentAnimationTimer = null; + this.animationAbortController = null; - if (typeof callback === 'function') { + if (typeof callback === 'function' && !signal.aborted) { try { callback(); } catch (err) { console.error('[Movement] Callback error:', err); + if (errorCallback) errorCallback(err); } } @@ -555,30 +620,62 @@ class SmoothMovementManager { } }; - animate(); + try { + animate(); + } catch (err) { + console.error('[Movement] Animation start error:', err); + this.isAnimating = false; + if (errorCallback) errorCallback(err); + } } - showFromEdge(callback) { + showFromEdge(callback, errorCallback) { const header = windowPool.get('header'); - if (!header || this.isAnimating || !this.hiddenPosition || !this.lastVisiblePosition) return; + if (!header || !this.hiddenPosition || !this.lastVisiblePosition) { + if (errorCallback) errorCallback(new Error('Cannot show - missing required data')); + return; + } + + this.cancelCurrentAnimation(); console.log(`[Movement] Showing from ${this.hiddenPosition.edge} edge`); - header.setPosition(this.hiddenPosition.x, this.hiddenPosition.y); + if (!this.safeSetPosition(header, this.hiddenPosition.x, this.hiddenPosition.y)) { + if (errorCallback) errorCallback(new Error('Failed to set initial position')); + return; + } + this.headerPosition = { x: this.hiddenPosition.x, y: this.hiddenPosition.y }; const targetX = this.lastVisiblePosition.x; const targetY = this.lastVisiblePosition.y; + if (!Number.isFinite(targetX) || !Number.isFinite(targetY)) { + console.error('[Movement] Invalid target position for show:', { targetX, targetY }); + if (errorCallback) errorCallback(new Error('Invalid target position for show')); + return; + } + + this.animationAbortController = new AbortController(); + const signal = this.animationAbortController.signal; + this.isAnimating = true; const startX = this.headerPosition.x; const startY = this.headerPosition.y; - const duration = 500; + const duration = 400; const startTime = Date.now(); const animate = () => { + if (signal.aborted) { + this.isAnimating = false; + if (errorCallback) errorCallback(new Error('Animation aborted')); + return; + } + if (!header || header.isDestroyed()) { this.isAnimating = false; + this.currentAnimationTimer = null; + if (errorCallback) errorCallback(new Error('Window destroyed during animation')); return; } @@ -592,34 +689,47 @@ class SmoothMovementManager { 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 }); + const success = this.safeSetPosition(header, currentX, currentY); + if (!success) { this.isAnimating = false; + this.currentAnimationTimer = null; + if (errorCallback) errorCallback(new Error('Failed to set position')); return; } - header.setPosition(Math.round(currentX), Math.round(currentY)); - if (progress < 1) { - setTimeout(animate, 8); + this.currentAnimationTimer = setTimeout(animate, this.animationFrameRate); } 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.safeSetPosition(header, targetX, targetY); + this.isAnimating = false; + this.currentAnimationTimer = null; + this.animationAbortController = null; this.hiddenPosition = null; this.lastVisiblePosition = null; - if (callback) callback(); + if (typeof callback === 'function' && !signal.aborted) { + try { + callback(); + } catch (err) { + console.error('[Movement] Show callback error:', err); + if (errorCallback) errorCallback(err); + } + } console.log(`[Movement] Show from edge completed`); } }; - animate(); + try { + animate(); + } catch (err) { + console.error('[Movement] Animation start error:', err); + this.isAnimating = false; + if (errorCallback) errorCallback(err); + } } moveStep(direction) { @@ -682,6 +792,9 @@ class SmoothMovementManager { } animateToPosition(header, targetX, targetY) { + // cancel animation + this.cancelCurrentAnimation(); + this.isAnimating = true; const startX = this.headerPosition.x; @@ -694,9 +807,14 @@ class SmoothMovementManager { return; } + + this.animationAbortController = new AbortController(); + const signal = this.animationAbortController.signal; + const animate = () => { - if (!header || header.isDestroyed()) { + if (signal.aborted || !header || header.isDestroyed()) { this.isAnimating = false; + this.currentAnimationTimer = null; return; } @@ -708,24 +826,24 @@ class SmoothMovementManager { 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 }); + const success = this.safeSetPosition(header, currentX, currentY); + if (!success) { this.isAnimating = false; + this.currentAnimationTimer = null; return; } - header.setPosition(Math.round(currentX), Math.round(currentY)); - if (progress < 1) { - setTimeout(animate, 8); + this.currentAnimationTimer = setTimeout(animate, this.animationFrameRate); } 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.safeSetPosition(header, targetX, targetY); + this.isAnimating = false; + this.currentAnimationTimer = null; + this.animationAbortController = null; updateLayout(); @@ -738,16 +856,23 @@ class SmoothMovementManager { moveToEdge(direction) { const header = windowPool.get('header'); - if (!header || !header.isVisible() || this.isAnimating) return; + if (!header || !header.isVisible()) return; + this.cancelCurrentAnimation(); 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(); + + let currentBounds; + try { + currentBounds = header.getBounds(); + } catch (err) { + console.error('[Movement] Failed to get header bounds:', err); + return; + } - const currentBounds = header.getBounds(); let targetX = currentBounds.x; let targetY = currentBounds.y; @@ -756,23 +881,26 @@ class SmoothMovementManager { targetX = workAreaX; break; case 'right': - targetX = workAreaX + width - headerBounds.width; + targetX = workAreaX + width - currentBounds.width; break; case 'up': targetY = workAreaY; break; case 'down': - targetY = workAreaY + height - headerBounds.height; + targetY = workAreaY + height - currentBounds.height; break; } this.headerPosition = { x: currentBounds.x, y: currentBounds.y }; + this.animationAbortController = new AbortController(); + const signal = this.animationAbortController.signal; + this.isAnimating = true; const startX = this.headerPosition.x; const startY = this.headerPosition.y; - const duration = 400; - const startTime = Date.now(); // 이 줄을 animate 함수 정의 전으로 이동 + const duration = 350; + const startTime = Date.now(); if (!Number.isFinite(targetX) || !Number.isFinite(targetY) || !Number.isFinite(startX) || !Number.isFinite(startY)) { console.error('[Movement] Invalid edge position values:', { startX, startY, targetX, targetY }); @@ -781,8 +909,9 @@ class SmoothMovementManager { } const animate = () => { - if (!header || header.isDestroyed()) { + if (signal.aborted || !header || header.isDestroyed()) { this.isAnimating = false; + this.currentAnimationTimer = null; return; } @@ -794,22 +923,24 @@ class SmoothMovementManager { 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 }); + + const success = this.safeSetPosition(header, currentX, currentY); + if (!success) { this.isAnimating = false; + this.currentAnimationTimer = null; return; } - header.setPosition(Math.round(currentX), Math.round(currentY)); - if (progress < 1) { - setTimeout(animate, 8); + this.currentAnimationTimer = setTimeout(animate, this.animationFrameRate); } else { - if (Number.isFinite(targetX) && Number.isFinite(targetY)) { - header.setPosition(Math.round(targetX), Math.round(targetY)); - } + + this.safeSetPosition(header, targetX, targetY); + this.headerPosition = { x: targetX, y: targetY }; this.isAnimating = false; + this.currentAnimationTimer = null; + this.animationAbortController = null; updateLayout(); @@ -829,6 +960,7 @@ class SmoothMovementManager { } destroy() { + this.cancelCurrentAnimation(); this.isAnimating = false; console.log('[Movement] Destroyed'); } @@ -837,75 +969,219 @@ class SmoothMovementManager { const layoutManager = new WindowLayoutManager(); let movementManager = null; +function isWindowSafe(window) { + return window && !window.isDestroyed() && typeof window.getBounds === 'function'; +} + +function safeWindowOperation(window, operation, fallback = null) { + if (!isWindowSafe(window)) { + console.warn('[WindowManager] Window not safe for operation'); + return fallback; + } + + try { + return operation(window); + } catch (error) { + console.error('[WindowManager] Window operation failed:', error); + return fallback; + } +} + +function safeSetPosition(window, x, y) { + return safeWindowOperation(window, (win) => { + win.setPosition(Math.round(x), Math.round(y)); + return true; + }, false); +} + +function safeGetBounds(window) { + return safeWindowOperation(window, (win) => win.getBounds(), null); +} + +function safeShow(window) { + return safeWindowOperation(window, (win) => { + win.show(); + return true; + }, false); +} + +function safeHide(window) { + return safeWindowOperation(window, (win) => { + win.hide(); + return true; + }, false); +} + +let toggleState = { + isToggling: false, + lastToggleTime: 0, + pendingToggle: null, + toggleDebounceTimer: null, + failsafeTimer: 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); - } + const now = Date.now(); + const timeSinceLastToggle = now - toggleState.lastToggleTime; + + if (timeSinceLastToggle < 200) { + console.log('[Visibility] Toggle ignored - too fast (debounced)'); + return; + } + if (toggleState.isToggling) { + console.log('[Visibility] Toggle in progress, queueing request'); + + if (toggleState.toggleDebounceTimer) { + clearTimeout(toggleState.toggleDebounceTimer); + } + + toggleState.toggleDebounceTimer = setTimeout(() => { + toggleState.toggleDebounceTimer = null; + if (!toggleState.isToggling) { + toggleAllWindowsVisibility(); } - }); + }, 300); + + return; + } + + const header = windowPool.get('header'); + if (!header || header.isDestroyed()) { + console.error('[Visibility] Header window not found or destroyed'); + return; + } - console.log('[Visibility] Visible windows before hide:', Array.from(lastVisibleWindows)); + toggleState.isToggling = true; + toggleState.lastToggleTime = now; + const resetToggleState = () => { + toggleState.isToggling = false; + if (toggleState.toggleDebounceTimer) { + clearTimeout(toggleState.toggleDebounceTimer); + toggleState.toggleDebounceTimer = null; + } + if (toggleState.failsafeTimer) { + clearTimeout(toggleState.failsafeTimer); + toggleState.failsafeTimer = null; + } + }; + toggleState.failsafeTimer = setTimeout(() => { + console.warn('[Visibility] Toggle operation timed out, resetting state'); + resetToggleState(); + }, 2000); - 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)); + try { + if (header.isVisible()) { + console.log('[Visibility] Smart hiding - calculating nearest edge'); - header.show(); + const headerBounds = header.getBounds(); + const display = getCurrentDisplay(header); + const { width: screenWidth, height: screenHeight } = display.workAreaSize; + const { x: workAreaX, y: workAreaY } = display.workArea; - 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'); + const centerX = headerBounds.x + headerBounds.width / 2 - workAreaX; + const centerY = headerBounds.y + headerBounds.height / 2 - workAreaY; + + 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'); + + const hidePromises = []; + windowPool.forEach((win, name) => { + if (win && !win.isDestroyed() && win.isVisible() && name !== 'header') { + lastVisibleWindows.add(name); + + win.webContents.send('window-hide-animation'); + + hidePromises.push(new Promise(resolve => { + setTimeout(() => { + if (!win.isDestroyed()) { + win.hide(); + } + resolve(); + }, 180); // 200ms ->180ms + })); } }); - setImmediate(updateLayout); - setTimeout(updateLayout, 120); + console.log('[Visibility] Visible windows before hide:', Array.from(lastVisibleWindows)); - console.log('[Visibility] Smart show completed'); - }); + Promise.all(hidePromises).then(() => { + if (!movementManager || header.isDestroyed()) { + resetToggleState(); + return; + } + + movementManager.hideToEdge(nearestEdge, () => { + if (!header.isDestroyed()) { + header.hide(); + } + resetToggleState(); + console.log('[Visibility] Smart hide completed'); + }, (error) => { + console.error('[Visibility] Error in hideToEdge:', error); + resetToggleState(); + }); + }).catch(err => { + console.error('[Visibility] Error during hide:', err); + resetToggleState(); + }); + + } else { + console.log('[Visibility] Smart showing from hidden position'); + console.log('[Visibility] Restoring windows:', Array.from(lastVisibleWindows)); + header.show(); + + if (!movementManager) { + console.error('[Visibility] Movement manager not initialized'); + resetToggleState(); + return; + } + + movementManager.showFromEdge(() => { + const showPromises = []; + lastVisibleWindows.forEach(name => { + if (name === 'header') return; + + const win = windowPool.get(name); + if (win && !win.isDestroyed()) { + showPromises.push(new Promise(resolve => { + win.show(); + win.webContents.send('window-show-animation'); + setTimeout(resolve, 100); + })); + } + }); + + Promise.all(showPromises).then(() => { + setImmediate(updateLayout); + setTimeout(updateLayout, 100); + + resetToggleState(); + console.log('[Visibility] Smart show completed'); + }).catch(err => { + console.error('[Visibility] Error during show:', err); + resetToggleState(); + }); + }, (error) => { + console.error('[Visibility] Error in showFromEdge:', error); + resetToggleState(); + }); + } + } catch (error) { + console.error('[Visibility] Unexpected error in toggle:', error); + resetToggleState(); } } @@ -926,6 +1202,21 @@ function ensureDataDirectories() { } function createWindows() { + if (movementManager) { + movementManager.destroy(); + movementManager = null; + } + + toggleState.isToggling = false; + if (toggleState.toggleDebounceTimer) { + clearTimeout(toggleState.toggleDebounceTimer); + toggleState.toggleDebounceTimer = null; + } + if (toggleState.failsafeTimer) { + clearTimeout(toggleState.failsafeTimer); + toggleState.failsafeTimer = null; + } + const primaryDisplay = screen.getPrimaryDisplay(); const { y: workAreaY, width: screenWidth } = primaryDisplay.workArea; @@ -994,153 +1285,171 @@ function createWindows() { loadAndRegisterShortcuts(); }); - ipcMain.handle('toggle-all-windows-visibility', toggleAllWindowsVisibility); + ipcMain.handle('toggle-all-windows-visibility', () => { + try { + toggleAllWindowsVisibility(); + } catch (error) { + console.error('[WindowManager] Error in toggle-all-windows-visibility:', error); + toggleState.isToggling = false; + } + }); 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'); + try { + const header = windowPool.get('header'); + if (!header || header.isDestroyed()) { + console.error('[WindowManager] Header window not available'); 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'); + + if (!windowPool.get(featureName) && currentHeaderState === 'app') { + createFeatureWindows(header); } - } else { + + if (!windowPool.get(featureName) && currentHeaderState === 'app') { + createFeatureWindows(windowPool.get('header')); + } + const windowToToggle = windowPool.get(featureName); if (windowToToggle) { - if (windowToToggle.isDestroyed()) { - console.error(`Window ${featureName} is destroyed, cannot toggle`); + 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 (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 { + if (askWindow.isVisible()) { try { - windowToToggle.show(); - updateLayout(); + 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; + } + })() + `); - if (featureName === 'listen') { - windowToToggle.webContents.send('start-listening-session'); + 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); } - - windowToToggle.webContents.send('window-show-animation'); - } catch (e) { - console.error('Error showing window:', e); + } 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 { - console.error(`Window not found for feature: ${featureName}`); - console.error('Available windows:', Array.from(windowPool.keys())); + 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())); + } } + } catch (error) { + console.error('[WindowManager] Error in toggle-feature:', error); + toggleState.isToggling = false; } }); @@ -1228,12 +1537,50 @@ function loadAndRegisterShortcuts() { } function updateLayout() { - layoutManager.updateLayout(); + if (layoutManager._updateTimer) { + clearTimeout(layoutManager._updateTimer); + } + + layoutManager._updateTimer = setTimeout(() => { + layoutManager._updateTimer = null; + layoutManager.updateLayout(); + }, 16); } function setupIpcHandlers(openaiSessionRef) { const layoutManager = new WindowLayoutManager(); // const movementManager = new SmoothMovementManager(); + + //cleanup + app.on('before-quit', () => { + console.log('[WindowManager] App is quitting, cleaning up...'); + + if (movementManager) { + movementManager.destroy(); + } + + if (toggleState.toggleDebounceTimer) { + clearTimeout(toggleState.toggleDebounceTimer); + toggleState.toggleDebounceTimer = null; + } + + if (toggleState.failsafeTimer) { + clearTimeout(toggleState.failsafeTimer); + toggleState.failsafeTimer = null; + } + + if (settingsHideTimer) { + clearTimeout(settingsHideTimer); + settingsHideTimer = null; + } + + windowPool.forEach((win, name) => { + if (win && !win.isDestroyed()) { + win.destroy(); + } + }); + windowPool.clear(); + }); screen.on('display-added', (event, newDisplay) => { console.log('[Display] New display added:', newDisplay.id); @@ -1823,6 +2170,99 @@ function setupIpcHandlers(openaiSessionRef) { header.webContents.send('request-firebase-logout'); } }); + + ipcMain.handle('check-system-permissions', async () => { + const { systemPreferences } = require('electron'); + const permissions = { + microphone: false, + screen: false, + needsSetup: false + }; + + try { + if (process.platform === 'darwin') { + // Check microphone permission on macOS + const micStatus = systemPreferences.getMediaAccessStatus('microphone'); + permissions.microphone = micStatus === 'granted'; + + try { + const sources = await desktopCapturer.getSources({ + types: ['screen'], + thumbnailSize: { width: 1, height: 1 } + }); + permissions.screen = sources && sources.length > 0; + } catch (err) { + console.log('[Permissions] Screen capture test failed:', err); + permissions.screen = false; + } + + permissions.needsSetup = !permissions.microphone || !permissions.screen; + } else { + permissions.microphone = true; + permissions.screen = true; + permissions.needsSetup = false; + } + + console.log('[Permissions] System permissions status:', permissions); + return permissions; + } catch (error) { + console.error('[Permissions] Error checking permissions:', error); + return { + microphone: false, + screen: false, + 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'); + if (status === 'granted') { + return { success: true, status: 'already-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 { + // Open System Preferences to Privacy & Security > Screen Recording + if (section === 'screen-recording') { + await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'); + } else if (section === 'microphone') { + await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'); + } else { + await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy'); + } + return { success: true }; + } catch (error) { + console.error('[Permissions] Error opening system preferences:', error); + return { success: false, error: error.message }; + } + }); } let storedApiKey = null; diff --git a/src/index.js b/src/index.js index 408ea89..2db9968 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,9 @@ -try { - const reloader = require('electron-reloader'); - reloader(module, { - }); -} catch (err) { -} +// try { +// const reloader = require('electron-reloader'); +// reloader(module, { +// }); +// } catch (err) { +// } require('dotenv').config();