From 7d33ea9ca8263fe39aea9acad776f2c0be34f096 Mon Sep 17 00:00:00 2001 From: Ho Jin Yu <151643898+samtiz@users.noreply.github.com> Date: Sun, 13 Jul 2025 15:31:24 +0900 Subject: [PATCH] [Refactor] full refactor and file structure changed (#125) * refactoring the bridge * Update aec submodule * folder structure refactor * fixing ask logic * resolve import err * fix askview * fix header content html path * fix systemaudiodump path * centralized ask logic * delete legacy code * change askservice to class * settingsService facade * fix getCurrentModelInfo * common service ipc moved to featureBridge * featureBridge init * ui fix * add featureBridge func for listenservice * fix preload conflict * shortcuts seperated * refactor ask * transfer roles from askview to askservice * modifying windowBridge * delete legacy ask code * retrieve conversation history for askserice * fix legacy code * shortcut moved * change naming for featurebridge * screenshot moved from windowManager * rough refactor done --------- Co-authored-by: sanio Co-authored-by: jhyang0 --- aec | 2 +- build.js | 4 +- docs/refactor-plan.md | 19 + electron-builder.yml | 2 +- src/bridge/featureBridge.js | 125 ++ src/bridge/internalBridge.js | 11 + src/bridge/windowBridge.js | 33 + src/electron/windowManager.js | 1514 ----------------- src/features/ask/askService.js | 469 +++-- .../ask/repositories/firebase.repository.js | 4 +- src/features/ask/repositories/index.js | 2 +- .../ask/repositories/sqlite.repository.js | 2 +- src/{ => features}/common/ai/factory.js | 0 .../common/ai/providers/anthropic.js | 0 .../common/ai/providers/gemini.js | 0 .../common/ai/providers/ollama.js | 0 .../common/ai/providers/openai.js | 2 +- .../common/ai/providers/whisper.js | 0 src/{ => features}/common/config/checksums.js | 0 src/{ => features}/common/config/config.js | 0 src/{ => features}/common/config/schema.js | 7 + .../common/prompts/promptBuilder.js | 0 .../common/prompts/promptTemplates.js | 0 .../common/repositories/firestoreConverter.js | 0 .../common/repositories/ollamaModel/index.js | 0 .../ollamaModel/sqlite.repository.js | 0 .../common/repositories/permission}/index.js | 0 .../permission}/sqlite.repository.js | 0 .../preset/firebase.repository.js | 0 .../common/repositories/preset/index.js | 2 +- .../repositories/preset/sqlite.repository.js | 0 .../providerSettings/firebase.repository.js | 0 .../repositories/providerSettings/index.js | 0 .../providerSettings/sqlite.repository.js | 0 .../session/firebase.repository.js | 0 .../common/repositories/session/index.js | 0 .../repositories/session/sqlite.repository.js | 0 .../repositories/user/firebase.repository.js | 0 .../common/repositories/user/index.js | 0 .../repositories/user/sqlite.repository.js | 0 .../firebase.repository.js | 0 .../repositories/userModelSelections/index.js | 0 .../userModelSelections/sqlite.repository.js | 0 .../common/repositories/whisperModel/index.js | 0 .../common/services/authService.js | 15 +- .../common/services/databaseInitializer.js | 0 .../common/services/encryptionService.js | 0 .../common/services/firebaseClient.js | 0 .../common/services/localAIServiceBase.js | 0 .../common/services/localProgressTracker.js | 45 +- .../common/services/migrationService.js | 6 +- .../common/services/modelStateService.js | 123 +- .../common/services/ollamaService.js | 198 +++ .../common/services/permissionService.js | 119 ++ .../common/services/sqliteClient.js | 0 .../common/services/whisperService.js | 45 +- .../common/utils/spawnHelper.js | 0 src/features/listen/listenService.js | 173 +- .../stt/repositories/firebase.repository.js | 4 +- src/features/listen/stt/repositories/index.js | 2 +- .../stt/repositories/sqlite.repository.js | 2 +- src/features/listen/stt/sttService.js | 36 +- .../repositories/firebase.repository.js | 6 +- .../listen/summary/repositories/index.js | 2 +- .../summary/repositories/sqlite.repository.js | 2 +- src/features/listen/summary/summaryService.js | 12 +- .../repositories/firebase.repository.js | 6 +- src/features/settings/repositories/index.js | 2 +- .../repositories/sqlite.repository.js | 2 +- src/features/settings/settingsService.js | 116 +- src/features/shortcuts/repositories/index.js | 1 + .../repositories/sqlite.repository.js | 48 + src/features/shortcuts/shortcutsService.js | 285 ++++ src/index.js | 358 +--- src/preload.js | 295 +++- src/{ => ui}/app/ApiKeyHeader.js | 115 +- src/{ => ui}/app/HeaderController.js | 57 +- src/{ => ui}/app/MainHeader.js | 111 +- src/{ => ui}/app/PermissionHeader.js | 28 +- src/{ => ui}/app/PickleGlassApp.js | 71 +- src/{ => ui}/app/content.html | 20 +- src/{ => ui}/app/header.html | 4 +- src/{features => ui}/ask/AskView.js | 455 ++--- src/{ => ui}/assets/Listen.svg | 0 src/{ => ui}/assets/SystemAudioDump | Bin src/{ => ui}/assets/Vector.svg | 0 src/{ => ui}/assets/command.svg | 0 src/{ => ui}/assets/dompurify-3.0.7.min.js | 0 src/{ => ui}/assets/enter.svg | 0 src/{ => ui}/assets/highlight-11.9.0.min.js | 0 .../assets/highlight-github-dark.min.css | 0 src/{ => ui}/assets/lit-core-2.7.4.min.js | 0 src/{ => ui}/assets/logo.icns | Bin src/{ => ui}/assets/logo.ico | Bin src/{ => ui}/assets/logo.png | Bin src/{ => ui}/assets/marked-4.3.0.min.js | 0 src/{ => ui}/assets/setting.svg | 0 .../listen/ListenView.js} | 16 +- src/{assets => ui/listen/audioCore}/aec.js | 0 .../listen/audioCore}/listenCapture.js | 158 +- .../listen/audioCore}/renderer.js | 3 +- src/{features => ui}/listen/stt/SttView.js | 12 +- .../listen/summary/SummaryView.js | 25 +- src/{features => ui}/settings/SettingsView.js | 353 +--- .../settings/ShortCutSettingsView.js | 23 +- src/{common => ui}/styles/glass-bypass.css | 0 .../smoothMovementManager.js | 0 .../windowLayoutManager.js | 0 src/window/windowManager.js | 764 +++++++++ 109 files changed, 3070 insertions(+), 3246 deletions(-) create mode 100644 docs/refactor-plan.md create mode 100644 src/bridge/featureBridge.js create mode 100644 src/bridge/internalBridge.js create mode 100644 src/bridge/windowBridge.js delete mode 100644 src/electron/windowManager.js rename src/{ => features}/common/ai/factory.js (100%) rename src/{ => features}/common/ai/providers/anthropic.js (100%) rename src/{ => features}/common/ai/providers/gemini.js (100%) rename src/{ => features}/common/ai/providers/ollama.js (100%) rename src/{ => features}/common/ai/providers/openai.js (99%) rename src/{ => features}/common/ai/providers/whisper.js (100%) rename src/{ => features}/common/config/checksums.js (100%) rename src/{ => features}/common/config/config.js (100%) rename src/{ => features}/common/config/schema.js (95%) rename src/{ => features}/common/prompts/promptBuilder.js (100%) rename src/{ => features}/common/prompts/promptTemplates.js (100%) rename src/{ => features}/common/repositories/firestoreConverter.js (100%) rename src/{ => features}/common/repositories/ollamaModel/index.js (100%) rename src/{ => features}/common/repositories/ollamaModel/sqlite.repository.js (100%) rename src/{common/repositories/systemSettings => features/common/repositories/permission}/index.js (100%) rename src/{common/repositories/systemSettings => features/common/repositories/permission}/sqlite.repository.js (100%) rename src/{ => features}/common/repositories/preset/firebase.repository.js (100%) rename src/{ => features}/common/repositories/preset/index.js (93%) rename src/{ => features}/common/repositories/preset/sqlite.repository.js (100%) rename src/{ => features}/common/repositories/providerSettings/firebase.repository.js (100%) rename src/{ => features}/common/repositories/providerSettings/index.js (100%) rename src/{ => features}/common/repositories/providerSettings/sqlite.repository.js (100%) rename src/{ => features}/common/repositories/session/firebase.repository.js (100%) rename src/{ => features}/common/repositories/session/index.js (100%) rename src/{ => features}/common/repositories/session/sqlite.repository.js (100%) rename src/{ => features}/common/repositories/user/firebase.repository.js (100%) rename src/{ => features}/common/repositories/user/index.js (100%) rename src/{ => features}/common/repositories/user/sqlite.repository.js (100%) rename src/{ => features}/common/repositories/userModelSelections/firebase.repository.js (100%) rename src/{ => features}/common/repositories/userModelSelections/index.js (100%) rename src/{ => features}/common/repositories/userModelSelections/sqlite.repository.js (100%) rename src/{ => features}/common/repositories/whisperModel/index.js (100%) rename src/{ => features}/common/services/authService.js (93%) rename src/{ => features}/common/services/databaseInitializer.js (100%) rename src/{ => features}/common/services/encryptionService.js (100%) rename src/{ => features}/common/services/firebaseClient.js (100%) rename src/{ => features}/common/services/localAIServiceBase.js (100%) rename src/{ => features}/common/services/localProgressTracker.js (67%) rename src/{ => features}/common/services/migrationService.js (97%) rename src/{ => features}/common/services/modelStateService.js (88%) rename src/{ => features}/common/services/ollamaService.js (81%) create mode 100644 src/features/common/services/permissionService.js rename src/{ => features}/common/services/sqliteClient.js (100%) rename src/{ => features}/common/services/whisperService.js (91%) rename src/{ => features}/common/utils/spawnHelper.js (100%) create mode 100644 src/features/shortcuts/repositories/index.js create mode 100644 src/features/shortcuts/repositories/sqlite.repository.js create mode 100644 src/features/shortcuts/shortcutsService.js rename src/{ => ui}/app/ApiKeyHeader.js (94%) rename src/{ => ui}/app/HeaderController.js (80%) rename src/{ => ui}/app/MainHeader.js (87%) rename src/{ => ui}/app/PermissionHeader.js (93%) rename src/{ => ui}/app/PickleGlassApp.js (67%) rename src/{ => ui}/app/content.html (94%) rename src/{ => ui}/app/header.html (85%) rename src/{features => ui}/ask/AskView.js (79%) rename src/{ => ui}/assets/Listen.svg (100%) rename src/{ => ui}/assets/SystemAudioDump (100%) rename src/{ => ui}/assets/Vector.svg (100%) rename src/{ => ui}/assets/command.svg (100%) rename src/{ => ui}/assets/dompurify-3.0.7.min.js (100%) rename src/{ => ui}/assets/enter.svg (100%) rename src/{ => ui}/assets/highlight-11.9.0.min.js (100%) rename src/{ => ui}/assets/highlight-github-dark.min.css (100%) rename src/{ => ui}/assets/lit-core-2.7.4.min.js (100%) rename src/{ => ui}/assets/logo.icns (100%) rename src/{ => ui}/assets/logo.ico (100%) rename src/{ => ui}/assets/logo.png (100%) rename src/{ => ui}/assets/marked-4.3.0.min.js (100%) rename src/{ => ui}/assets/setting.svg (100%) rename src/{features/listen/AssistantView.js => ui/listen/ListenView.js} (97%) rename src/{assets => ui/listen/audioCore}/aec.js (100%) rename src/{features/listen/renderer => ui/listen/audioCore}/listenCapture.js (76%) rename src/{features/listen/renderer => ui/listen/audioCore}/renderer.js (88%) rename src/{features => ui}/listen/stt/SttView.js (93%) rename src/{features => ui}/listen/summary/SummaryView.js (95%) rename src/{features => ui}/settings/SettingsView.js (77%) rename src/{features => ui}/settings/ShortCutSettingsView.js (92%) rename src/{common => ui}/styles/glass-bypass.css (100%) rename src/{electron => window}/smoothMovementManager.js (100%) rename src/{electron => window}/windowLayoutManager.js (100%) create mode 100644 src/window/windowManager.js diff --git a/aec b/aec index 3be088c..f00bb1f 160000 --- a/aec +++ b/aec @@ -1 +1 @@ -Subproject commit 3be088c6cff8021c74eca714150e68e2cc74bee0 +Subproject commit f00bb1fb948053c752b916adfee19f90644a0b2f diff --git a/build.js b/build.js index a56b5e2..e9aec55 100644 --- a/build.js +++ b/build.js @@ -14,8 +14,8 @@ const baseConfig = { }; const entryPoints = [ - { in: 'src/app/HeaderController.js', out: 'public/build/header' }, - { in: 'src/app/PickleGlassApp.js', out: 'public/build/content' }, + { in: 'src/ui/app/HeaderController.js', out: 'public/build/header' }, + { in: 'src/ui/app/PickleGlassApp.js', out: 'public/build/content' }, ]; async function build() { diff --git a/docs/refactor-plan.md b/docs/refactor-plan.md new file mode 100644 index 0000000..6fa95a7 --- /dev/null +++ b/docs/refactor-plan.md @@ -0,0 +1,19 @@ +# Refactor Plan: Non-Window Logic Migration from windowManager.js + +## Goal +`windowManager.js`를 순수 창 관리 모듈로 만들기 위해 비즈니스 로직을 해당 서비스와 `featureBridge.js`로 이전. + +## Steps (based on initial plan) +1. **Shortcuts**: Completed. Logic moved to `shortcutsService.js` and IPC to `featureBridge.js`. Used `internalBridge` for coordination. + +2. **Screenshot**: Next. Move `captureScreenshot` function and related IPC handlers from `windowManager.js` to `askService.js` (since it's primarily used there). Update `askService.js` to use its own screenshot method. Add IPC handlers to `featureBridge.js` if needed. + +3. **System Permissions**: Create new `permissionService.js` in `src/features/common/services/`. Move all permission-related logic (check, request, open preferences, mark completed, etc.) and IPC handlers from `windowManager.js` to the new service and `featureBridge.js`. + +4. **API Key / Model State**: Completely remove from `windowManager.js` (e.g., `setupApiKeyIPC` and helpers). Ensure all usages (e.g., in `askService.js`) directly require and use `modelStateService.js` instead. + +## Notes +- Maintain original logic without changes. +- Break circular dependencies if found. +- Use `internalBridge` for inter-module communication where appropriate. +- After each step, verify no errors and test functionality. \ No newline at end of file diff --git a/electron-builder.yml b/electron-builder.yml index 1825140..ea25ae1 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -33,7 +33,7 @@ extraResources: to: out asarUnpack: - - "src/assets/SystemAudioDump" + - "src/ui/assets/SystemAudioDump" - "**/node_modules/sharp/**/*" - "**/node_modules/@img/**/*" diff --git a/src/bridge/featureBridge.js b/src/bridge/featureBridge.js new file mode 100644 index 0000000..7df4a83 --- /dev/null +++ b/src/bridge/featureBridge.js @@ -0,0 +1,125 @@ +// src/bridge/featureBridge.js +const { ipcMain, app } = require('electron'); +const settingsService = require('../features/settings/settingsService'); +const authService = require('../features/common/services/authService'); +const whisperService = require('../features/common/services/whisperService'); +const ollamaService = require('../features/common/services/ollamaService'); +const modelStateService = require('../features/common/services/modelStateService'); +const shortcutsService = require('../features/shortcuts/shortcutsService'); + +const askService = require('../features/ask/askService'); +const listenService = require('../features/listen/listenService'); +const permissionService = require('../features/common/services/permissionService'); + +module.exports = { + // Renderer로부터의 요청을 수신 + initialize() { + + // Settings Service + ipcMain.handle('settings:getPresets', async () => await settingsService.getPresets()); + ipcMain.handle('settings:get-auto-update', async () => await settingsService.getAutoUpdateSetting()); + ipcMain.handle('settings:set-auto-update', async (event, isEnabled) => await settingsService.setAutoUpdateSetting(isEnabled)); + ipcMain.handle('settings:get-model-settings', async () => await settingsService.getModelSettings()); + ipcMain.handle('settings:validate-and-save-key', async (e, { provider, key }) => await settingsService.validateAndSaveKey(provider, key)); + ipcMain.handle('settings:clear-api-key', async (e, { provider }) => await settingsService.clearApiKey(provider)); + ipcMain.handle('settings:set-selected-model', async (e, { type, modelId }) => await settingsService.setSelectedModel(type, modelId)); + + ipcMain.handle('settings:get-ollama-status', async () => await settingsService.getOllamaStatus()); + ipcMain.handle('settings:ensure-ollama-ready', async () => await settingsService.ensureOllamaReady()); + ipcMain.handle('settings:shutdown-ollama', async () => await settingsService.shutdownOllama()); + + // Shortcuts + ipcMain.handle('get-current-shortcuts', async () => await shortcutsService.loadKeybinds()); + ipcMain.handle('get-default-shortcuts', async () => await shortcutsService.handleRestoreDefaults()); + ipcMain.handle('save-shortcuts', async (event, newKeybinds) => await shortcutsService.handleSaveShortcuts(newKeybinds)); + + + // Permissions + ipcMain.handle('check-system-permissions', async () => await permissionService.checkSystemPermissions()); + ipcMain.handle('request-microphone-permission', async () => await permissionService.requestMicrophonePermission()); + ipcMain.handle('open-system-preferences', async (event, section) => await permissionService.openSystemPreferences(section)); + ipcMain.handle('mark-permissions-completed', async () => await permissionService.markPermissionsAsCompleted()); + ipcMain.handle('check-permissions-completed', async () => await permissionService.checkPermissionsCompleted()); + + + // User/Auth + ipcMain.handle('get-current-user', () => authService.getCurrentUser()); + ipcMain.handle('start-firebase-auth', async () => await authService.startFirebaseAuthFlow()); + ipcMain.handle('firebase-logout', async () => await authService.signOut()); + + // App + ipcMain.handle('quit-application', () => app.quit()); + + // Whisper + ipcMain.handle('whisper:download-model', async (event, modelId) => await whisperService.handleDownloadModel(event, modelId)); + ipcMain.handle('whisper:get-installed-models', async () => await whisperService.handleGetInstalledModels()); + + // General + ipcMain.handle('get-preset-templates', () => presetRepository.getPresetTemplates()); + ipcMain.handle('get-web-url', () => process.env.pickleglass_WEB_URL || 'http://localhost:3000'); + + // Ollama + ipcMain.handle('ollama:get-status', async () => await ollamaService.handleGetStatus()); + ipcMain.handle('ollama:install', async (event) => await ollamaService.handleInstall(event)); + ipcMain.handle('ollama:start-service', async (event) => await ollamaService.handleStartService(event)); + ipcMain.handle('ollama:ensure-ready', async () => await ollamaService.handleEnsureReady()); + ipcMain.handle('ollama:get-models', async () => await ollamaService.handleGetModels()); + ipcMain.handle('ollama:get-model-suggestions', async () => await ollamaService.handleGetModelSuggestions()); + ipcMain.handle('ollama:pull-model', async (event, modelName) => await ollamaService.handlePullModel(event, modelName)); + ipcMain.handle('ollama:is-model-installed', async (event, modelName) => await ollamaService.handleIsModelInstalled(modelName)); + ipcMain.handle('ollama:warm-up-model', async (event, modelName) => await ollamaService.handleWarmUpModel(modelName)); + ipcMain.handle('ollama:auto-warm-up', async () => await ollamaService.handleAutoWarmUp()); + ipcMain.handle('ollama:get-warm-up-status', async () => await ollamaService.handleGetWarmUpStatus()); + ipcMain.handle('ollama:shutdown', async (event, force = false) => await ollamaService.handleShutdown(event, force)); + + // Ask + ipcMain.handle('ask:sendQuestionFromAsk', async (event, userPrompt) => await askService.sendMessage(userPrompt)); + ipcMain.handle('ask:sendQuestionFromSummary', async (event, userPrompt) => await askService.sendMessage(userPrompt)); + ipcMain.handle('ask:toggleAskButton', async () => await askService.toggleAskButton()); + + // Listen + ipcMain.handle('listen:sendMicAudio', async (event, { data, mimeType }) => await listenService.handleSendMicAudioContent(data, mimeType)); + ipcMain.handle('listen:sendSystemAudio', async (event, { data, mimeType }) => { + const result = await listenService.sttService.sendSystemAudioContent(data, mimeType); + if(result.success) { + listenService.sendToRenderer('system-audio-data', { data }); + } + return result; + }); + ipcMain.handle('listen:startMacosSystemAudio', async () => await listenService.handleStartMacosAudio()); + ipcMain.handle('listen:stopMacosSystemAudio', async () => await listenService.handleStopMacosAudio()); + ipcMain.handle('update-google-search-setting', async (event, enabled) => await listenService.handleUpdateGoogleSearchSetting(enabled)); + ipcMain.handle('listen:changeSession', async (event, listenButtonText) => { + console.log('[FeatureBridge] listen:changeSession from mainheader', listenButtonText); + try { + await listenService.handleListenRequest(listenButtonText); + return { success: true }; + } catch (error) { + console.error('[FeatureBridge] listen:changeSession failed', error.message); + return { success: false, error: error.message }; + } + }); + + + + // ModelStateService + ipcMain.handle('model:validate-key', async (e, { provider, key }) => await modelStateService.handleValidateKey(provider, key)); + ipcMain.handle('model:get-all-keys', () => modelStateService.getAllApiKeys()); + ipcMain.handle('model:set-api-key', async (e, { provider, key }) => await modelStateService.setApiKey(provider, key)); + ipcMain.handle('model:remove-api-key', async (e, provider) => await modelStateService.handleRemoveApiKey(provider)); + ipcMain.handle('model:get-selected-models', () => modelStateService.getSelectedModels()); + ipcMain.handle('model:set-selected-model', async (e, { type, modelId }) => await modelStateService.handleSetSelectedModel(type, modelId)); + ipcMain.handle('model:get-available-models', (e, { type }) => modelStateService.getAvailableModels(type)); + ipcMain.handle('model:are-providers-configured', () => modelStateService.areProvidersConfigured()); + ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig()); + + + + console.log('[FeatureBridge] Initialized with all feature handlers.'); + }, + + // Renderer로 상태를 전송 + sendAskProgress(win, progress) { + win.webContents.send('feature:ask:progress', progress); + }, +}; \ No newline at end of file diff --git a/src/bridge/internalBridge.js b/src/bridge/internalBridge.js new file mode 100644 index 0000000..3fd8f73 --- /dev/null +++ b/src/bridge/internalBridge.js @@ -0,0 +1,11 @@ +// src/bridge/internalBridge.js +const { EventEmitter } = require('events'); + +// FeatureCore와 WindowCore를 잇는 내부 이벤트 버스 +const internalBridge = new EventEmitter(); +module.exports = internalBridge; + +// 예시 이벤트 +// internalBridge.on('content-protection-changed', (enabled) => { +// // windowManager에서 처리 +// }); \ No newline at end of file diff --git a/src/bridge/windowBridge.js b/src/bridge/windowBridge.js new file mode 100644 index 0000000..c298d93 --- /dev/null +++ b/src/bridge/windowBridge.js @@ -0,0 +1,33 @@ +// src/bridge/windowBridge.js +const { ipcMain, BrowserWindow } = require('electron'); +const windowManager = require('../window/windowManager'); + +module.exports = { + initialize() { + ipcMain.handle('toggle-content-protection', () => windowManager.toggleContentProtection()); + ipcMain.handle('resize-header-window', (event, args) => windowManager.resizeHeaderWindow(args)); + ipcMain.handle('get-content-protection-status', () => windowManager.getContentProtectionStatus()); + ipcMain.handle('open-shortcut-editor', () => windowManager.openShortcutEditor()); + ipcMain.on('show-settings-window', (event, bounds) => windowManager.showSettingsWindow(bounds)); + ipcMain.on('hide-settings-window', () => windowManager.hideSettingsWindow()); + ipcMain.on('cancel-hide-settings-window', () => windowManager.cancelHideSettingsWindow()); + ipcMain.handle('open-login-page', () => windowManager.openLoginPage()); + ipcMain.handle('move-window-step', (event, direction) => windowManager.moveWindowStep(direction)); + ipcMain.on('close-shortcut-editor', () => windowManager.closeWindow('shortcut-settings')); + + // Newly moved handlers from windowManager + ipcMain.on('header-state-changed', (event, state) => windowManager.handleHeaderStateChanged(state)); + ipcMain.on('header-animation-finished', (event, state) => windowManager.handleHeaderAnimationFinished(state)); + ipcMain.handle('get-header-position', () => windowManager.getHeaderPosition()); + ipcMain.handle('move-header', (event, newX, newY) => windowManager.moveHeader(newX, newY)); + ipcMain.handle('move-header-to', (event, newX, newY) => windowManager.moveHeaderTo(newX, newY)); + ipcMain.handle('adjust-window-height', (event, targetHeight) => windowManager.adjustWindowHeight(event.sender, targetHeight)); + ipcMain.handle('toggle-all-windows-visibility', () => windowManager.toggleAllWindowsVisibility()); + ipcMain.on('animation-finished', (event) => windowManager.handleAnimationFinished(event.sender)); + ipcMain.handle('ask:closeAskWindow', () => windowManager.closeAskWindow()); + }, + + notifyFocusChange(win, isFocused) { + win.webContents.send('window:focus-change', isFocused); + } +}; \ No newline at end of file diff --git a/src/electron/windowManager.js b/src/electron/windowManager.js deleted file mode 100644 index c4bbd81..0000000 --- a/src/electron/windowManager.js +++ /dev/null @@ -1,1514 +0,0 @@ -const { BrowserWindow, globalShortcut, ipcMain, screen, app, shell, desktopCapturer } = require('electron'); -const WindowLayoutManager = require('./windowLayoutManager'); -const SmoothMovementManager = require('./smoothMovementManager'); -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); - -// Try to load sharp, but don't fail if it's not available -let sharp; -try { - sharp = require('sharp'); - console.log('[WindowManager] Sharp module loaded successfully'); -} catch (error) { - console.warn('[WindowManager] Sharp module not available:', error.message); - console.warn('[WindowManager] Screenshot functionality will work with reduced image processing capabilities'); - sharp = null; -} -const authService = require('../common/services/authService'); -const systemSettingsRepository = require('../common/repositories/systemSettings'); -const userRepository = require('../common/repositories/user'); -const fetch = require('node-fetch'); -const Store = require('electron-store'); -const shortCutStore = new Store({ - name: 'user-preferences', - defaults: { - customKeybinds: {} - } -}); - -/* ────────────────[ GLASS BYPASS ]─────────────── */ -let liquidGlass; -const isLiquidGlassSupported = () => { - if (process.platform !== 'darwin') { - return false; - } - const majorVersion = parseInt(os.release().split('.')[0], 10); - // return majorVersion >= 25; // macOS 26+ (Darwin 25+) - return majorVersion >= 26; // See you soon! -}; -let shouldUseLiquidGlass = isLiquidGlassSupported(); -if (shouldUseLiquidGlass) { - try { - liquidGlass = require('electron-liquid-glass'); - } catch (e) { - console.warn('Could not load optional dependency "electron-liquid-glass". The feature will be disabled.'); - shouldUseLiquidGlass = false; - } -} -/* ────────────────[ GLASS BYPASS ]─────────────── */ - -let isContentProtectionOn = true; -let currentDisplayId = null; - -let mouseEventsIgnored = false; -let lastVisibleWindows = new Set(['header']); -const HEADER_HEIGHT = 47; -const DEFAULT_WINDOW_WIDTH = 353; - -let currentHeaderState = 'apikey'; -const windowPool = new Map(); -let fixedYPosition = 0; -let lastScreenshot = null; - -let settingsHideTimer = null; - -let selectedCaptureSourceId = null; - -// let shortcutEditorWindow = null; -let layoutManager = null; -function updateLayout() { - if (layoutManager) { - layoutManager.updateLayout(); - } -} - -let movementManager = null; - - -async function toggleFeature(featureName) { - if (!windowPool.get(featureName) && currentHeaderState === 'main') { - createFeatureWindows(windowPool.get('header')); - } - - const header = windowPool.get('header'); - if (featureName === 'listen') { - console.log(`[WindowManager] Toggling feature: ${featureName}`); - const listenWindow = windowPool.get(featureName); - const listenService = global.listenService; - if (listenService && listenService.isSessionActive()) { - console.log('[WindowManager] Listen session is active, closing it via toggle.'); - await listenService.closeSession(); - listenWindow.webContents.send('session-state-changed', { isActive: false }); - header.webContents.send('session-state-text', 'Done'); - // return; - } else { - if (listenWindow.isVisible()) { - listenWindow.webContents.send('window-hide-animation'); - listenWindow.webContents.send('session-state-changed', { isActive: false }); - header.webContents.send('session-state-text', 'Listen'); - } else { - listenWindow.show(); - updateLayout(); - listenWindow.webContents.send('window-show-animation'); - await listenService.initializeSession(); - listenWindow.webContents.send('session-state-changed', { isActive: true }); - header.webContents.send('session-state-text', 'Stop'); - } - } - } - - 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'); - } - } 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 (featureName === 'settings') { - const settingsWindow = windowPool.get(featureName); - - if (settingsWindow) { - if (settingsWindow.isDestroyed()) { - console.error(`Window ${featureName} is destroyed, cannot toggle`); - return; - } - - if (settingsWindow.isVisible()) { - if (featureName === 'settings') { - settingsWindow.webContents.send('settings-window-hide-animation'); - } else { - settingsWindow.webContents.send('window-hide-animation'); - } - } else { - try { - settingsWindow.show(); - updateLayout(); - - settingsWindow.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())); - } - } -} - - -function createFeatureWindows(header, namesToCreate) { - // if (windowPool.has('listen')) return; - - const commonChildOptions = { - parent: header, - show: false, - frame: false, - transparent: true, - vibrancy: false, - hasShadow: false, - skipTaskbar: true, - hiddenInMissionControl: true, - resizable: true, - webPreferences: { nodeIntegration: true, contextIsolation: false }, - }; - - const createFeatureWindow = (name) => { - if (windowPool.has(name)) return; - - switch (name) { - case 'listen': { - const listen = new BrowserWindow({ - ...commonChildOptions, width:400,minWidth:400,maxWidth:900, - maxHeight:900, - }); - listen.setContentProtection(isContentProtectionOn); - listen.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true}); - if (process.platform === 'darwin') { - listen.setWindowButtonVisibility(false); - } - const listenLoadOptions = { query: { view: 'listen' } }; - if (!shouldUseLiquidGlass) { - listen.loadFile(path.join(__dirname, '../app/content.html'), listenLoadOptions); - } - else { - listenLoadOptions.query.glass = 'true'; - listen.loadFile(path.join(__dirname, '../app/content.html'), listenLoadOptions); - listen.webContents.once('did-finish-load', () => { - const viewId = liquidGlass.addView(listen.getNativeWindowHandle()); - if (viewId !== -1) { - liquidGlass.unstable_setVariant(viewId, liquidGlass.GlassMaterialVariant.bubbles); - // liquidGlass.unstable_setScrim(viewId, 1); - // liquidGlass.unstable_setSubdued(viewId, 1); - } - }); - } - if (!app.isPackaged) { - listen.webContents.openDevTools({ mode: 'detach' }); - } - windowPool.set('listen', listen); - break; - } - - // ask - case 'ask': { - const ask = new BrowserWindow({ ...commonChildOptions, width:600 }); - ask.setContentProtection(isContentProtectionOn); - ask.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true}); - if (process.platform === 'darwin') { - ask.setWindowButtonVisibility(false); - } - const askLoadOptions = { query: { view: 'ask' } }; - if (!shouldUseLiquidGlass) { - ask.loadFile(path.join(__dirname, '../app/content.html'), askLoadOptions); - } - else { - askLoadOptions.query.glass = 'true'; - ask.loadFile(path.join(__dirname, '../app/content.html'), askLoadOptions); - ask.webContents.once('did-finish-load', () => { - const viewId = liquidGlass.addView(ask.getNativeWindowHandle()); - if (viewId !== -1) { - liquidGlass.unstable_setVariant(viewId, liquidGlass.GlassMaterialVariant.bubbles); - // liquidGlass.unstable_setScrim(viewId, 1); - // liquidGlass.unstable_setSubdued(viewId, 1); - } - }); - } - - ask.on('blur',()=>ask.webContents.send('window-blur')); - - // Open DevTools in development - if (!app.isPackaged) { - ask.webContents.openDevTools({ mode: 'detach' }); - } - windowPool.set('ask', ask); - break; - } - - // settings - case 'settings': { - const settings = new BrowserWindow({ ...commonChildOptions, width:240, maxHeight:400, parent:undefined }); - settings.setContentProtection(isContentProtectionOn); - settings.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true}); - if (process.platform === 'darwin') { - settings.setWindowButtonVisibility(false); - } - const settingsLoadOptions = { query: { view: 'settings' } }; - if (!shouldUseLiquidGlass) { - settings.loadFile(path.join(__dirname,'../app/content.html'), settingsLoadOptions) - .catch(console.error); - } - else { - settingsLoadOptions.query.glass = 'true'; - settings.loadFile(path.join(__dirname,'../app/content.html'), settingsLoadOptions) - .catch(console.error); - settings.webContents.once('did-finish-load', () => { - const viewId = liquidGlass.addView(settings.getNativeWindowHandle()); - if (viewId !== -1) { - liquidGlass.unstable_setVariant(viewId, liquidGlass.GlassMaterialVariant.bubbles); - // liquidGlass.unstable_setScrim(viewId, 1); - // liquidGlass.unstable_setSubdued(viewId, 1); - } - }); - } - windowPool.set('settings', settings); - break; - } - - case 'shortcut-settings': { - const shortcutEditor = new BrowserWindow({ - ...commonChildOptions, - width: 420, - height: 720, - modal: false, - parent: undefined, - alwaysOnTop: true, - titleBarOverlay: false, - }); - - if (process.platform === 'darwin') { - shortcutEditor.setAlwaysOnTop(true, 'screen-saver'); - } else { - shortcutEditor.setAlwaysOnTop(true); - } - - /* ──────────[ ① 다른 창 클릭 차단 ]────────── */ - const disableClicks = () => { - for (const [name, win] of windowPool) { - if (win !== shortcutEditor && !win.isDestroyed()) { - win.setIgnoreMouseEvents(true, { forward: true }); - } - } - }; - const restoreClicks = () => { - for (const [, win] of windowPool) { - if (!win.isDestroyed()) win.setIgnoreMouseEvents(false); - } - }; - - const header = windowPool.get('header'); - if (header && !header.isDestroyed()) { - const { x, y, width } = header.getBounds(); - shortcutEditor.setBounds({ x, y, width }); - } - - shortcutEditor.once('ready-to-show', () => { - disableClicks(); - shortcutEditor.show(); - }); - - const loadOptions = { query: { view: 'shortcut-settings' } }; - if (!shouldUseLiquidGlass) { - shortcutEditor.loadFile(path.join(__dirname, '../app/content.html'), loadOptions); - } else { - loadOptions.query.glass = 'true'; - shortcutEditor.loadFile(path.join(__dirname, '../app/content.html'), loadOptions); - shortcutEditor.webContents.once('did-finish-load', () => { - const viewId = liquidGlass.addView(shortcutEditor.getNativeWindowHandle()); - if (viewId !== -1) { - liquidGlass.unstable_setVariant(viewId, liquidGlass.GlassMaterialVariant.bubbles); - } - }); - } - - shortcutEditor.on('closed', () => { - restoreClicks(); - windowPool.delete('shortcut-settings'); - console.log('[Shortcuts] Re-enabled after editing.'); - loadAndRegisterShortcuts(movementManager); - }); - - shortcutEditor.webContents.once('dom-ready', async () => { - const savedKeybinds = shortCutStore.get('customKeybinds', {}); - const defaultKeybinds = getDefaultKeybinds(); - const keybinds = { ...defaultKeybinds, ...savedKeybinds }; - shortcutEditor.webContents.send('load-shortcuts', keybinds); - }); - - if (!app.isPackaged) { - shortcutEditor.webContents.openDevTools({ mode: 'detach' }); - } - windowPool.set('shortcut-settings', shortcutEditor); - break; - } - } - }; - - if (Array.isArray(namesToCreate)) { - namesToCreate.forEach(name => createFeatureWindow(name)); - } else if (typeof namesToCreate === 'string') { - createFeatureWindow(namesToCreate); - } else { - createFeatureWindow('listen'); - createFeatureWindow('ask'); - createFeatureWindow('settings'); - } -} - -function destroyFeatureWindows() { - const featureWindows = ['listen','ask','settings','shortcut-settings']; - if (settingsHideTimer) { - clearTimeout(settingsHideTimer); - settingsHideTimer = null; - } - featureWindows.forEach(name=>{ - const win = windowPool.get(name); - if (win && !win.isDestroyed()) win.destroy(); - windowPool.delete(name); - }); -} - - - -function getCurrentDisplay(window) { - if (!window || window.isDestroyed()) return screen.getPrimaryDisplay(); - - const windowBounds = window.getBounds(); - const windowCenter = { - x: windowBounds.x + windowBounds.width / 2, - y: windowBounds.y + windowBounds.height / 2, - }; - - return screen.getDisplayNearestPoint(windowCenter); -} - -function getDisplayById(displayId) { - const displays = screen.getAllDisplays(); - return displays.find(d => d.id === displayId) || screen.getPrimaryDisplay(); -} - - - -function toggleAllWindowsVisibility() { - const header = windowPool.get('header'); - if (!header) return; - - if (header.isVisible()) { - lastVisibleWindows.clear(); - - windowPool.forEach((win, name) => { - if (win && !win.isDestroyed() && win.isVisible()) { - lastVisibleWindows.add(name); - } - }); - - lastVisibleWindows.forEach(name => { - if (name === 'header') return; - const win = windowPool.get(name); - if (win && !win.isDestroyed()) win.hide(); - }); - header.hide(); - - return; - } - - lastVisibleWindows.forEach(name => { - const win = windowPool.get(name); - if (win && !win.isDestroyed()) - win.show(); - }); - } - - -function createWindows() { - const primaryDisplay = screen.getPrimaryDisplay(); - const { y: workAreaY, width: screenWidth } = primaryDisplay.workArea; - - const initialX = Math.round((screenWidth - DEFAULT_WINDOW_WIDTH) / 2); - const initialY = workAreaY + 21; - movementManager = new SmoothMovementManager(windowPool, getDisplayById, getCurrentDisplay, updateLayout); - - const header = new BrowserWindow({ - width: DEFAULT_WINDOW_WIDTH, - height: HEADER_HEIGHT, - x: initialX, - y: initialY, - frame: false, - transparent: true, - vibrancy: false, - alwaysOnTop: true, - skipTaskbar: true, - hiddenInMissionControl: true, - resizable: false, - focusable: true, - acceptFirstMouse: true, - webPreferences: { - nodeIntegration: true, - contextIsolation: false, - backgroundThrottling: false, - webSecurity: false, - enableRemoteModule: false, - // Ensure proper rendering and prevent pixelation - experimentalFeatures: false, - }, - // Prevent pixelation and ensure proper rendering - useContentSize: true, - disableAutoHideCursor: true, - }); - if (process.platform === 'darwin') { - header.setWindowButtonVisibility(false); - } - const headerLoadOptions = {}; - if (!shouldUseLiquidGlass) { - header.loadFile(path.join(__dirname, '../app/header.html'), headerLoadOptions); - } - else { - headerLoadOptions.query = { glass: 'true' }; - header.loadFile(path.join(__dirname, '../app/header.html'), headerLoadOptions); - header.webContents.once('did-finish-load', () => { - const viewId = liquidGlass.addView(header.getNativeWindowHandle()); - if (viewId !== -1) { - liquidGlass.unstable_setVariant(viewId, liquidGlass.GlassMaterialVariant.bubbles); - // liquidGlass.unstable_setScrim(viewId, 1); - // liquidGlass.unstable_setSubdued(viewId, 1); - } - }); - } - windowPool.set('header', header); - header.on('moved', updateLayout); - layoutManager = new WindowLayoutManager(windowPool); - - header.webContents.once('dom-ready', () => { - loadAndRegisterShortcuts(movementManager); - }); - - setupIpcHandlers(movementManager); - - if (currentHeaderState === 'main') { - createFeatureWindows(header, ['listen', 'ask', 'settings', 'shortcut-settings']); - } - - 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', () => { - console.log('[WindowManager] Header resize event triggered'); - updateLayout(); - }); - - ipcMain.handle('toggle-all-windows-visibility', () => toggleAllWindowsVisibility()); - - ipcMain.handle('toggle-feature', async (event, featureName) => { - return toggleFeature(featureName); - }); - - 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(); - } - }); - - return windowPool; -} - -function loadAndRegisterShortcuts(movementManager) { - if (windowPool.has('shortcut-settings')) { - console.log('[Shortcuts] Editing in progress, skipping registration.'); - return; - } - - const defaultKeybinds = getDefaultKeybinds(); - const savedKeybinds = shortCutStore.get('customKeybinds', {}); - const keybinds = { ...defaultKeybinds, ...savedKeybinds }; - - const sendToRenderer = (channel, ...args) => { - windowPool.forEach(win => { - if (win && !win.isDestroyed()) { - try { - win.webContents.send(channel, ...args); - } catch (e) { - // 창이 이미 닫혔을 수 있으므로 오류를 무시합니다. - } - } - }); - }; - - updateGlobalShortcuts(keybinds, windowPool.get('header'), sendToRenderer, movementManager); -} - - -function setupIpcHandlers(movementManager) { - screen.on('display-added', (event, newDisplay) => { - console.log('[Display] New display added:', newDisplay.id); - }); - - screen.on('display-removed', (event, oldDisplay) => { - console.log('[Display] Display removed:', oldDisplay.id); - const header = windowPool.get('header'); - if (header && getCurrentDisplay(header).id === oldDisplay.id) { - const primaryDisplay = screen.getPrimaryDisplay(); - movementManager.moveToDisplay(primaryDisplay.id); - } - }); - - screen.on('display-metrics-changed', (event, display, changedMetrics) => { - console.log('[Display] Display metrics changed:', display.id, changedMetrics); - updateLayout(); - }); - - // 1. 스트리밍 데이터 조각(chunk)을 받아서 ask 창으로 전달 - ipcMain.on('ask-response-chunk', (event, { token }) => { - const askWindow = windowPool.get('ask'); - if (askWindow && !askWindow.isDestroyed()) { - // renderer.js가 보낸 토큰을 AskView.js로 그대로 전달합니다. - askWindow.webContents.send('ask-response-chunk', { token }); - } - }); - - // 2. 스트리밍 종료 신호를 받아서 ask 창으로 전달 - ipcMain.on('ask-response-stream-end', () => { - const askWindow = windowPool.get('ask'); - if (askWindow && !askWindow.isDestroyed()) { - askWindow.webContents.send('ask-response-stream-end'); - } - }); - - ipcMain.on('animation-finished', (event) => { - const win = BrowserWindow.fromWebContents(event.sender); - if (win && !win.isDestroyed()) { - console.log(`[WindowManager] Hiding window after animation.`); - win.hide(); - } - }); - - ipcMain.on('show-settings-window', (event, bounds) => { - if (!bounds) return; - const win = windowPool.get('settings'); - - if (win && !win.isDestroyed()) { - if (settingsHideTimer) { - clearTimeout(settingsHideTimer); - settingsHideTimer = null; - } - - // 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(); - win.setAlwaysOnTop(true); - } - }); - - ipcMain.on('hide-settings-window', (event) => { - const window = windowPool.get("settings"); - if (window && !window.isDestroyed()) { - if (settingsHideTimer) { - clearTimeout(settingsHideTimer); - } - settingsHideTimer = setTimeout(() => { - if (window && !window.isDestroyed()) { - window.setAlwaysOnTop(false); - window.hide(); - } - settingsHideTimer = null; - }, 200); - - window.__lockedByButton = false; - } - }); - - ipcMain.on('cancel-hide-settings-window', (event) => { - if (settingsHideTimer) { - clearTimeout(settingsHideTimer); - settingsHideTimer = null; - } - }); - - ipcMain.handle('quit-application', () => { - app.quit(); - }); - - ipcMain.handle('is-ask-window-visible', (event, windowName) => { - const window = windowPool.get(windowName); - if (window && !window.isDestroyed()) { - return window.isVisible(); - } - return false; - }); - - - ipcMain.handle('toggle-content-protection', () => { - isContentProtectionOn = !isContentProtectionOn; - console.log(`[Protection] Content protection toggled to: ${isContentProtectionOn}`); - windowPool.forEach(win => { - if (win && !win.isDestroyed()) { - win.setContentProtection(isContentProtectionOn); - } - }); - return isContentProtectionOn; - }); - - ipcMain.handle('get-content-protection-status', () => { - return isContentProtectionOn; - }); - - ipcMain.on('header-state-changed', (event, state) => { - console.log(`[WindowManager] Header state changed to: ${state}`); - currentHeaderState = state; - - if (state === 'main') { - createFeatureWindows(windowPool.get('header')); - } else { // 'apikey' | 'permission' - destroyFeatureWindows(); - } - loadAndRegisterShortcuts(movementManager); - }); - - ipcMain.on('update-keybinds', (event, newKeybinds) => { - updateGlobalShortcuts(newKeybinds); - }); - - ipcMain.handle('get-current-shortcuts', () => { - const defaultKeybinds = getDefaultKeybinds(); - const savedKeybinds = shortCutStore.get('customKeybinds', {}); - return { ...defaultKeybinds, ...savedKeybinds }; - }); - - ipcMain.handle('open-shortcut-editor', () => { - const header = windowPool.get('header'); - if (!header) return; - - // 편집기 열기 전 모든 단축키 비활성화 - globalShortcut.unregisterAll(); - console.log('[Shortcuts] Disabled for editing.'); - - createFeatureWindows(header, 'shortcut-settings'); - }); - - ipcMain.handle('get-default-shortcuts', () => { - shortCutStore.set('customKeybinds', {}); - return getDefaultKeybinds(); - }); - - ipcMain.handle('save-shortcuts', async (event, newKeybinds) => { - try { - const defaultKeybinds = getDefaultKeybinds(); - const customKeybinds = {}; - for (const key in newKeybinds) { - if (newKeybinds[key] && newKeybinds[key] !== defaultKeybinds[key]) { - customKeybinds[key] = newKeybinds[key]; - } - } - - shortCutStore.set('customKeybinds', customKeybinds); - console.log('[Shortcuts] Custom keybinds saved to store:', customKeybinds); - - const editor = windowPool.get('shortcut-settings'); - if (editor && !editor.isDestroyed()) { - editor.close(); - } else { - loadAndRegisterShortcuts(movementManager); - } - - return { success: true }; - } catch (error) { - console.error("Failed to save shortcuts:", error); - loadAndRegisterShortcuts(movementManager); - return { success: false, error: error.message }; - } - }); - - ipcMain.on('close-shortcut-editor', () => { - const editor = windowPool.get('shortcut-settings'); - if (editor && !editor.isDestroyed()) { - editor.close(); - } - }); - - 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-header-window', (event, { width, height }) => { - const header = windowPool.get('header'); - if (header) { - console.log(`[WindowManager] Resize request: ${width}x${height}`); - - // Prevent resizing during animations or if already at target size - if (movementManager && movementManager.isAnimating) { - console.log('[WindowManager] Skipping resize during animation'); - return { success: false, error: 'Cannot resize during animation' }; - } - - const currentBounds = header.getBounds(); - console.log(`[WindowManager] Current bounds: ${currentBounds.width}x${currentBounds.height} at (${currentBounds.x}, ${currentBounds.y})`); - - // Skip if already at target size to prevent unnecessary operations - if (currentBounds.width === width && currentBounds.height === height) { - console.log('[WindowManager] Already at target size, skipping resize'); - return { success: true }; - } - - const wasResizable = header.isResizable(); - if (!wasResizable) { - header.setResizable(true); - } - - // Calculate the center point of the current window - const centerX = currentBounds.x + currentBounds.width / 2; - // Calculate new X position to keep the window centered - const newX = Math.round(centerX - width / 2); - - // Get the current display to ensure we stay within bounds - const display = getCurrentDisplay(header); - const { x: workAreaX, width: workAreaWidth } = display.workArea; - - // Clamp the new position to stay within display bounds - const clampedX = Math.max(workAreaX, Math.min(workAreaX + workAreaWidth - width, newX)); - - header.setBounds({ x: clampedX, y: currentBounds.y, width, height }); - - if (!wasResizable) { - header.setResizable(false); - } - - // Update layout after resize - updateLayout(); - - return { success: true }; - } - return { success: false, error: 'Header window not found' }; - }); - - ipcMain.on('header-animation-finished', (event, state) => { - const header = windowPool.get('header'); - if (!header || header.isDestroyed()) return; - - if (state === 'hidden') { - header.hide(); - console.log('[WindowManager] Header hidden after animation.'); - } else if (state === 'visible') { - console.log('[WindowManager] Header shown after animation.'); - updateLayout(); - } - }); - - 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(); - - // Only clamp if the new position would actually go out of bounds - // This prevents progressive restriction of movement - let clampedX = newX; - let clampedY = newY; - - // Check if we need to clamp X position - if (newX < workAreaX) { - clampedX = workAreaX; - } else if (newX + headerBounds.width > workAreaX + width) { - clampedX = workAreaX + width - headerBounds.width; - } - - // Check if we need to clamp Y position - if (newY < workAreaY) { - clampedY = workAreaY; - } else if (newY + headerBounds.height > workAreaY + height) { - clampedY = workAreaY + height - headerBounds.height; - } - - header.setPosition(clampedX, clampedY, false); - - updateLayout(); - } - }); - - - ipcMain.handle('move-window-step', (event, direction) => { - if (movementManager) { - movementManager.moveStep(direction); - } - }); - - ipcMain.handle('force-close-window', (event, windowName) => { - const window = windowPool.get(windowName); - if (window && !window.isDestroyed()) { - console.log(`[WindowManager] Force closing window: ${windowName}`); - - window.webContents.send('window-hide-animation'); - } - }); - - ipcMain.handle('start-screen-capture', async () => { - try { - isCapturing = true; - console.log('Starting screen capture in main process'); - return { success: true }; - } catch (error) { - console.error('Failed to start screen capture:', error); - return { success: false, error: error.message }; - } - }); - - ipcMain.handle('stop-screen-capture', async () => { - try { - isCapturing = false; - lastScreenshot = null; - console.log('Stopped screen capture in main process'); - return { success: true }; - } catch (error) { - console.error('Failed to stop screen capture:', error); - return { success: false, error: error.message }; - } - }); - - ipcMain.handle('capture-screenshot', async (event, options = {}) => { - return captureScreenshot(options); - }); - - ipcMain.handle('get-current-screenshot', async event => { - try { - if (lastScreenshot && Date.now() - lastScreenshot.timestamp < 1000) { - console.log('Returning cached screenshot'); - return { - success: true, - base64: lastScreenshot.base64, - width: lastScreenshot.width, - height: lastScreenshot.height, - }; - } - return { - success: false, - error: 'No screenshot available', - }; - } catch (error) { - console.error('Failed to get current screenshot:', error); - return { - success: false, - error: error.message, - }; - } - }); - - ipcMain.handle('firebase-logout', async () => { - console.log('[WindowManager] Received request to log out.'); - - await authService.signOut(); - }); - - ipcMain.handle('check-system-permissions', async () => { - const { systemPreferences } = require('electron'); - const permissions = { - microphone: 'unknown', - screen: 'unknown', - needsSetup: true - }; - - try { - if (process.platform === 'darwin') { - // Check microphone permission on macOS - const micStatus = systemPreferences.getMediaAccessStatus('microphone'); - console.log('[Permissions] Microphone status:', micStatus); - permissions.microphone = micStatus; - - // Check screen recording permission using the system API - const screenStatus = systemPreferences.getMediaAccessStatus('screen'); - console.log('[Permissions] Screen status:', screenStatus); - permissions.screen = screenStatus; - - permissions.needsSetup = micStatus !== 'granted' || screenStatus !== 'granted'; - } else { - permissions.microphone = 'granted'; - permissions.screen = 'granted'; - permissions.needsSetup = false; - } - - console.log('[Permissions] System permissions status:', permissions); - return permissions; - } catch (error) { - console.error('[Permissions] Error checking permissions:', error); - return { - microphone: 'unknown', - screen: 'unknown', - needsSetup: true, - error: error.message - }; - } - }); - - ipcMain.handle('request-microphone-permission', async () => { - if (process.platform !== 'darwin') { - return { success: true }; - } - - const { systemPreferences } = require('electron'); - try { - const status = systemPreferences.getMediaAccessStatus('microphone'); - console.log('[Permissions] Microphone status:', status); - if (status === 'granted') { - return { success: true, status: 'granted' }; - } - - // Req mic permission - const granted = await systemPreferences.askForMediaAccess('microphone'); - return { - success: granted, - status: granted ? 'granted' : 'denied' - }; - } catch (error) { - console.error('[Permissions] Error requesting microphone permission:', error); - return { - success: false, - error: error.message - }; - } - }); - - ipcMain.handle('open-system-preferences', async (event, section) => { - if (process.platform !== 'darwin') { - return { success: false, error: 'Not supported on this platform' }; - } - - try { - if (section === 'screen-recording') { - // First trigger screen capture request to register the app in system preferences - try { - console.log('[Permissions] Triggering screen capture request to register app...'); - await desktopCapturer.getSources({ - types: ['screen'], - thumbnailSize: { width: 1, height: 1 } - }); - console.log('[Permissions] App registered for screen recording'); - } catch (captureError) { - console.log('[Permissions] Screen capture request triggered (expected to fail):', captureError.message); - } - - // Then open system preferences - // await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'); - } - // if (section === 'microphone') { - // await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'); - // } - return { success: true }; - } catch (error) { - console.error('[Permissions] Error opening system preferences:', error); - return { success: false, error: error.message }; - } - }); - - ipcMain.handle('mark-permissions-completed', async () => { - try { - // This is a system-level setting, not user-specific. - await systemSettingsRepository.markPermissionsAsCompleted(); - console.log('[Permissions] Marked permissions as completed'); - return { success: true }; - } catch (error) { - console.error('[Permissions] Error marking permissions as completed:', error); - return { success: false, error: error.message }; - } - }); - - ipcMain.handle('check-permissions-completed', async () => { - try { - const completed = await systemSettingsRepository.checkPermissionsCompleted(); - console.log('[Permissions] Permissions completed status:', completed); - return completed; - } catch (error) { - console.error('[Permissions] Error checking permissions completed status:', error); - return false; - } - }); - - ipcMain.handle('close-ask-window-if-empty', async () => { - const askWindow = windowPool.get('ask'); - if (askWindow && !askWindow.isFocused()) { - askWindow.hide(); - } - }); -} - - - -//////// after_modelStateService //////// -async function getStoredApiKey() { - if (global.modelStateService) { - const provider = await getStoredProvider(); - return global.modelStateService.getApiKey(provider); - } - return null; // Fallback -} - -async function getStoredProvider() { - if (global.modelStateService) { - return global.modelStateService.getCurrentProvider('llm'); - } - return 'openai'; // Fallback -} - -/** - * - * @param {IpcMainInvokeEvent} event - * @param {{type: 'llm' | 'stt'}} - */ -async function getCurrentModelInfo(event, { type }) { - if (global.modelStateService && (type === 'llm' || type === 'stt')) { - return global.modelStateService.getCurrentModelInfo(type); - } - return null; -} - -function setupApiKeyIPC() { - const { ipcMain } = require('electron'); - - ipcMain.handle('get-stored-api-key', getStoredApiKey); - ipcMain.handle('get-ai-provider', getStoredProvider); - ipcMain.handle('get-current-model-info', getCurrentModelInfo); - - ipcMain.handle('api-key-validated', async (event, data) => { - console.warn("[DEPRECATED] 'api-key-validated' IPC was called. This logic is now handled by 'model:validate-key'."); - return { success: true }; - }); - - ipcMain.handle('remove-api-key', async () => { - console.warn("[DEPRECATED] 'remove-api-key' IPC was called. This is now handled by 'model:remove-api-key'."); - return { success: true }; - }); - - console.log('[WindowManager] API key related IPC handlers have been updated for ModelStateService.'); -} -//////// after_modelStateService //////// - - -function getDefaultKeybinds() { - const isMac = process.platform === 'darwin'; - return { - moveUp: isMac ? 'Cmd+Up' : 'Ctrl+Up', - moveDown: isMac ? 'Cmd+Down' : 'Ctrl+Down', - moveLeft: isMac ? 'Cmd+Left' : 'Ctrl+Left', - moveRight: isMac ? 'Cmd+Right' : 'Ctrl+Right', - toggleVisibility: isMac ? 'Cmd+\\' : 'Ctrl+\\', - toggleClickThrough: isMac ? 'Cmd+M' : 'Ctrl+M', - nextStep: isMac ? 'Cmd+Enter' : 'Ctrl+Enter', - manualScreenshot: isMac ? 'Cmd+Shift+S' : 'Ctrl+Shift+S', - previousResponse: isMac ? 'Cmd+[' : 'Ctrl+[', - nextResponse: isMac ? 'Cmd+]' : 'Ctrl+]', - scrollUp: isMac ? 'Cmd+Shift+Up' : 'Ctrl+Shift+Up', - scrollDown: isMac ? 'Cmd+Shift+Down' : 'Ctrl+Shift+Down', - }; -} - -function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, movementManager) { - globalShortcut.unregisterAll(); - - if (sendToRenderer) { - sendToRenderer('shortcuts-updated', keybinds); - console.log('[Shortcuts] Broadcasted updated shortcuts to all windows.'); - } - - // ✨ 하드코딩된 단축키 등록을 위해 변수 유지 - const isMac = process.platform === 'darwin'; - const modifier = isMac ? 'Cmd' : 'Ctrl'; - const header = windowPool.get('header'); - const state = header?.currentHeaderState || currentHeaderState; - - // ✨ 기능 1: 사용자가 설정할 수 없는 '모니터 이동' 단축키 (기존 로직 유지) - const displays = screen.getAllDisplays(); - if (displays.length > 1) { - displays.forEach((display, index) => { - const key = `${modifier}+Shift+${index + 1}`; - try { - globalShortcut.register(key, () => movementManager.moveToDisplay(display.id)); - console.log(`Registered display switch shortcut: ${key} -> Display ${index + 1}`); - } catch (error) { - console.error(`Failed to register display switch ${key}:`, error); - } - }); - } - - // API 키 입력 상태에서는 필수 단축키(toggleVisibility) 외에는 아무것도 등록하지 않음 - if (state === 'apikey') { - if (keybinds.toggleVisibility) { - try { - globalShortcut.register(keybinds.toggleVisibility, () => toggleAllWindowsVisibility()); - } catch (error) { - console.error(`Failed to register toggleVisibility (${keybinds.toggleVisibility}):`, error); - } - } - console.log('ApiKeyHeader is active, skipping conditional shortcuts'); - return; - } - - // ✨ 기능 2: 사용자가 설정할 수 없는 '화면 가장자리 이동' 단축키 (기존 로직 유지) - 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, () => { - if (header && header.isVisible()) movementManager.moveToEdge(direction); - }); - } catch (error) { - console.error(`Failed to register edge move for ${key}:`, error); - } - }); - - - // ✨ 기능 3: 사용자가 설정 가능한 모든 단축키를 동적으로 등록 (새로운 방식 적용) - for (const action in keybinds) { - const accelerator = keybinds[action]; - if (!accelerator) continue; - - try { - let callback; - switch(action) { - case 'toggleVisibility': - callback = () => toggleAllWindowsVisibility(); - break; - case 'nextStep': - callback = () => toggleFeature('ask'); - break; - case 'scrollUp': - callback = () => { - // 'ask' 창을 명시적으로 가져옵니다. - const askWindow = windowPool.get('ask'); - // 'ask' 창이 존재하고, 파괴되지 않았으며, 보이는 경우에만 이벤트를 전송합니다. - if (askWindow && !askWindow.isDestroyed() && askWindow.isVisible()) { - askWindow.webContents.send('scroll-response-up'); - } - }; - break; - case 'scrollDown': - callback = () => { - // 'ask' 창을 명시적으로 가져옵니다. - const askWindow = windowPool.get('ask'); - // 'ask' 창이 존재하고, 파괴되지 않았으며, 보이는 경우에만 이벤트를 전송합니다. - if (askWindow && !askWindow.isDestroyed() && askWindow.isVisible()) { - askWindow.webContents.send('scroll-response-down'); - } - }; - break; - case 'moveUp': - callback = () => { if (header && header.isVisible()) movementManager.moveStep('up'); }; - break; - case 'moveDown': - callback = () => { if (header && header.isVisible()) movementManager.moveStep('down'); }; - break; - case 'moveLeft': - callback = () => { if (header && header.isVisible()) movementManager.moveStep('left'); }; - break; - case 'moveRight': - callback = () => { if (header && header.isVisible()) movementManager.moveStep('right'); }; - break; - case 'toggleClickThrough': - callback = () => { - mouseEventsIgnored = !mouseEventsIgnored; - if(mainWindow && !mainWindow.isDestroyed()){ - mainWindow.setIgnoreMouseEvents(mouseEventsIgnored, { forward: true }); - mainWindow.webContents.send('click-through-toggled', mouseEventsIgnored); - } - }; - break; - case 'manualScreenshot': - callback = () => { - if(mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.executeJavaScript('window.captureManualScreenshot && window.captureManualScreenshot();'); - } - }; - break; - case 'previousResponse': - callback = () => sendToRenderer('navigate-previous-response'); - break; - case 'nextResponse': - callback = () => sendToRenderer('navigate-next-response'); - break; - } - - if (callback) { - globalShortcut.register(accelerator, callback); - } - } catch(e) { - console.error(`Failed to register shortcut for "${action}" (${accelerator}):`, e.message); - } - } -} - - -async function captureScreenshot(options = {}) { - if (process.platform === 'darwin') { - try { - const tempPath = path.join(os.tmpdir(), `screenshot-${Date.now()}.jpg`); - - await execFile('screencapture', ['-x', '-t', 'jpg', tempPath]); - - const imageBuffer = await fs.promises.readFile(tempPath); - await fs.promises.unlink(tempPath); - - if (sharp) { - try { - // Try using sharp for optimal image processing - 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 (sharpError) { - console.warn('Sharp module failed, falling back to basic image processing:', sharpError.message); - } - } - - // Fallback: Return the original image without resizing - console.log('[WindowManager] Using fallback image processing (no resize/compression)'); - const base64 = imageBuffer.toString('base64'); - - lastScreenshot = { - base64, - width: null, // We don't have metadata without sharp - height: null, - timestamp: Date.now(), - }; - - return { success: true, base64, width: null, height: null }; - } catch (error) { - console.error('Failed to capture screenshot:', error); - return { success: false, error: error.message }; - } - } - - try { - const sources = await desktopCapturer.getSources({ - types: ['screen'], - thumbnailSize: { - width: 1920, - height: 1080, - }, - }); - - if (sources.length === 0) { - throw new Error('No screen sources available'); - } - const source = sources[0]; - const buffer = source.thumbnail.toJPEG(70); - const base64 = buffer.toString('base64'); - const size = source.thumbnail.getSize(); - - return { - success: true, - base64, - width: size.width, - height: size.height, - }; - } catch (error) { - console.error('Failed to capture screenshot using desktopCapturer:', error); - return { - success: false, - error: error.message, - }; - } -} - -module.exports = { - createWindows, - windowPool, - fixedYPosition, - getStoredApiKey, - getStoredProvider, - getCurrentModelInfo, - captureScreenshot, -}; \ No newline at end of file diff --git a/src/features/ask/askService.js b/src/features/ask/askService.js index 33d661e..13653b4 100644 --- a/src/features/ask/askService.js +++ b/src/features/ask/askService.js @@ -1,150 +1,373 @@ -const { ipcMain, BrowserWindow } = require('electron'); -const { createStreamingLLM } = require('../../common/ai/factory'); -const { getStoredApiKey, getStoredProvider, getCurrentModelInfo, windowPool, captureScreenshot } = require('../../electron/windowManager'); -const authService = require('../../common/services/authService'); -const sessionRepository = require('../../common/repositories/session'); +const { BrowserWindow } = require('electron'); +const { createStreamingLLM } = require('../common/ai/factory'); +// Lazy require helper to avoid circular dependency issues +const getWindowManager = () => require('../../window/windowManager'); + +const getWindowPool = () => { + try { + return getWindowManager().windowPool; + } catch { + return null; + } +}; +const updateLayout = () => getWindowManager().updateLayout(); +const ensureAskWindowVisible = () => getWindowManager().ensureAskWindowVisible(); + +const sessionRepository = require('../common/repositories/session'); const askRepository = require('./repositories'); -const { getSystemPrompt } = require('../../common/prompts/promptBuilder'); +const { getSystemPrompt } = require('../common/prompts/promptBuilder'); +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 { desktopCapturer } = require('electron'); +const modelStateService = require('../common/services/modelStateService'); -function formatConversationForPrompt(conversationTexts) { - if (!conversationTexts || conversationTexts.length === 0) return 'No conversation history available.'; - return conversationTexts.slice(-30).join('\n'); +// Try to load sharp, but don't fail if it's not available +let sharp; +try { + sharp = require('sharp'); + console.log('[AskService] Sharp module loaded successfully'); +} catch (error) { + console.warn('[AskService] Sharp module not available:', error.message); + console.warn('[AskService] Screenshot functionality will work with reduced image processing capabilities'); + sharp = null; } +let lastScreenshot = null; -// Access conversation history via the global listenService instance created in index.js -function getConversationHistory() { - const listenService = global.listenService; - return listenService ? listenService.getConversationHistory() : []; -} +async function captureScreenshot(options = {}) { + if (process.platform === 'darwin') { + try { + const tempPath = path.join(os.tmpdir(), `screenshot-${Date.now()}.jpg`); -async function sendMessage(userPrompt) { - if (!userPrompt || userPrompt.trim().length === 0) { - console.warn('[AskService] Cannot process empty message'); - return { success: false, error: 'Empty message' }; + await execFile('screencapture', ['-x', '-t', 'jpg', tempPath]); + + const imageBuffer = await fs.promises.readFile(tempPath); + await fs.promises.unlink(tempPath); + + if (sharp) { + try { + // Try using sharp for optimal image processing + const resizedBuffer = await sharp(imageBuffer) + .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 (sharpError) { + console.warn('Sharp module failed, falling back to basic image processing:', sharpError.message); + } + } + + // Fallback: Return the original image without resizing + console.log('[AskService] Using fallback image processing (no resize/compression)'); + const base64 = imageBuffer.toString('base64'); + + lastScreenshot = { + base64, + width: null, // We don't have metadata without sharp + height: null, + timestamp: Date.now(), + }; + + return { success: true, base64, width: null, height: null }; + } catch (error) { + console.error('Failed to capture screenshot:', error); + return { success: false, error: error.message }; + } } - - const askWindow = windowPool.get('ask'); - if (askWindow && !askWindow.isDestroyed()) { - askWindow.webContents.send('hide-text-input'); - } - - let sessionId; try { - console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`); - - // --- Save user's message immediately --- - // This ensures the user message is always timestamped before the assistant's response. - sessionId = await sessionRepository.getOrCreateActive('ask'); - await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() }); - console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`); - // --- End of user message saving --- - - const modelInfo = await getCurrentModelInfo(null, { type: 'llm' }); - if (!modelInfo || !modelInfo.apiKey) { - throw new Error('AI model or API key not configured.'); - } - console.log(`[AskService] Using model: ${modelInfo.model} for provider: ${modelInfo.provider}`); - - const screenshotResult = await captureScreenshot({ quality: 'medium' }); - const screenshotBase64 = screenshotResult.success ? screenshotResult.base64 : null; - - const conversationHistoryRaw = getConversationHistory(); - const conversationHistory = formatConversationForPrompt(conversationHistoryRaw); - - const systemPrompt = getSystemPrompt('pickle_glass_analysis', conversationHistory, false); - - - const messages = [ - { role: 'system', content: systemPrompt }, - { - role: 'user', - content: [ - { type: 'text', text: `User Request: ${userPrompt.trim()}` }, - ], + const sources = await desktopCapturer.getSources({ + types: ['screen'], + thumbnailSize: { + width: 1920, + height: 1080, }, - ]; - - if (screenshotBase64) { - messages[1].content.push({ - type: 'image_url', - image_url: { url: `data:image/jpeg;base64,${screenshotBase64}` }, - }); - } - - const streamingLLM = createStreamingLLM(modelInfo.provider, { - apiKey: modelInfo.apiKey, - model: modelInfo.model, - temperature: 0.7, - maxTokens: 2048, - usePortkey: modelInfo.provider === 'openai-glass', - portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined, }); - const response = await streamingLLM.streamChat(messages); + 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(); - // --- Stream Processing --- - const reader = response.body.getReader(); + 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, + }; + } +} + +/** + * @class + * @description + */ +class AskService { + constructor() { + this.abortController = null; + this.state = { + isVisible: false, + isLoading: false, + isStreaming: false, + currentQuestion: '', + currentResponse: '', + showTextInput: true, + }; + console.log('[AskService] Service instance created.'); + } + + _broadcastState() { + const askWindow = getWindowPool()?.get('ask'); + if (askWindow && !askWindow.isDestroyed()) { + askWindow.webContents.send('ask:stateUpdate', this.state); + } + } + + async toggleAskButton() { + const askWindow = getWindowPool()?.get('ask'); + + // 답변이 있거나 스트리밍 중일 때 + const hasContent = this.state.isStreaming || (this.state.currentResponse && this.state.currentResponse.length > 0); + + if (askWindow && askWindow.isVisible() && hasContent) { + // 창을 닫는 대신, 텍스트 입력창만 토글합니다. + this.state.showTextInput = !this.state.showTextInput; + this._broadcastState(); // 변경된 상태 전파 + } else { + // 기존의 창 보이기/숨기기 로직 + if (askWindow && askWindow.isVisible()) { + askWindow.webContents.send('window-hide-animation'); + this.state.isVisible = false; + } else { + console.log('[AskService] Showing hidden Ask window'); + this.state.isVisible = true; + askWindow?.show(); + updateLayout(); + askWindow?.webContents.send('window-show-animation'); + } + // 창이 다시 열릴 때를 대비해 상태를 초기화하고 전파합니다. + if (this.state.isVisible) { + this.state.showTextInput = true; + this._broadcastState(); + } + } + } + + + /** + * + * @param {string[]} conversationTexts + * @returns {string} + * @private + */ + _formatConversationForPrompt(conversationTexts) { + if (!conversationTexts || conversationTexts.length === 0) { + return 'No conversation history available.'; + } + return conversationTexts.slice(-30).join('\n'); + } + + /** + * + * @param {string} userPrompt + * @returns {Promise<{success: boolean, response?: string, error?: string}>} + */ + async sendMessage(userPrompt, conversationHistoryRaw=[]) { + ensureAskWindowVisible(); + + if (this.abortController) { + this.abortController.abort('New request received.'); + } + this.abortController = new AbortController(); + const { signal } = this.abortController; + + if (!userPrompt || userPrompt.trim().length === 0) { + console.warn('[AskService] Cannot process empty message'); + return { success: false, error: 'Empty message' }; + } + + let sessionId; + + try { + console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`); + + this.state = { + ...this.state, + isLoading: true, + isStreaming: false, + currentQuestion: userPrompt, + currentResponse: '', + showTextInput: false, + }; + this._broadcastState(); + + sessionId = await sessionRepository.getOrCreateActive('ask'); + await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() }); + console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`); + + const modelInfo = modelStateService.getCurrentModelInfo('llm'); + if (!modelInfo || !modelInfo.apiKey) { + throw new Error('AI model or API key not configured.'); + } + console.log(`[AskService] Using model: ${modelInfo.model} for provider: ${modelInfo.provider}`); + + const screenshotResult = await captureScreenshot({ quality: 'medium' }); + const screenshotBase64 = screenshotResult.success ? screenshotResult.base64 : null; + + const conversationHistory = this._formatConversationForPrompt(conversationHistoryRaw); + + const systemPrompt = getSystemPrompt('pickle_glass_analysis', conversationHistory, false); + + const messages = [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: [ + { type: 'text', text: `User Request: ${userPrompt.trim()}` }, + ], + }, + ]; + + if (screenshotBase64) { + messages[1].content.push({ + type: 'image_url', + image_url: { url: `data:image/jpeg;base64,${screenshotBase64}` }, + }); + } + + const streamingLLM = createStreamingLLM(modelInfo.provider, { + apiKey: modelInfo.apiKey, + model: modelInfo.model, + temperature: 0.7, + maxTokens: 2048, + usePortkey: modelInfo.provider === 'openai-glass', + portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined, + }); + + const response = await streamingLLM.streamChat(messages); + const askWin = getWindowPool()?.get('ask'); + + if (!askWin || askWin.isDestroyed()) { + console.error("[AskService] Ask window is not available to send stream to."); + response.body.getReader().cancel(); + return { success: false, error: 'Ask window is not available.' }; + } + + const reader = response.body.getReader(); + signal.addEventListener('abort', () => { + console.log(`[AskService] Aborting stream reader. Reason: ${signal.reason}`); + reader.cancel(signal.reason).catch(() => { /* 이미 취소된 경우의 오류는 무시 */ }); + }); + + await this._processStream(reader, askWin, sessionId, signal); + + return { success: true }; + + } catch (error) { + if (error.name === 'AbortError') { + console.log('[AskService] SendMessage operation was successfully aborted.'); + return { success: true, response: 'Cancelled' }; + } + + console.error('[AskService] Error processing message:', error); + this.state.isLoading = false; + this.state.error = error.message; + this._broadcastState(); + return { success: false, error: error.message }; + } + } + + /** + * + * @param {ReadableStreamDefaultReader} reader + * @param {BrowserWindow} askWin + * @param {number} sessionId + * @param {AbortSignal} signal + * @returns {Promise} + * @private + */ + async _processStream(reader, askWin, sessionId, signal) { const decoder = new TextDecoder(); let fullResponse = ''; - const askWin = windowPool.get('ask'); - if (!askWin || askWin.isDestroyed()) { - console.error("[AskService] Ask window is not available to send stream to."); - reader.cancel(); - return; - } + try { + this.state.isLoading = false; + this.state.isStreaming = true; + this._broadcastState(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; - while (true) { - const { done, value } = await reader.read(); - if (done) break; + const chunk = decoder.decode(value); + const lines = chunk.split('\n').filter(line => line.trim() !== ''); - const chunk = decoder.decode(value); - const lines = chunk.split('\n').filter(line => line.trim() !== ''); - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.substring(6); - if (data === '[DONE]') { - askWin.webContents.send('ask-response-stream-end'); - - // Save assistant's message to DB + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.substring(6); + if (data === '[DONE]') { + return; + } try { - // sessionId is already available from when we saved the user prompt - await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse }); - console.log(`[AskService] DB: Saved assistant response to session ${sessionId}`); - } catch(dbError) { - console.error("[AskService] DB: Failed to save assistant response:", dbError); + const json = JSON.parse(data); + const token = json.choices[0]?.delta?.content || ''; + if (token) { + fullResponse += token; + this.state.currentResponse = fullResponse; + this._broadcastState(); + } + } catch (error) { } - - return { success: true, response: fullResponse }; - } - try { - const json = JSON.parse(data); - const token = json.choices[0]?.delta?.content || ''; - if (token) { - fullResponse += token; - askWin.webContents.send('ask-response-chunk', { token }); - } - } catch (error) { - // Ignore parsing errors for now } } } + } catch (streamError) { + if (signal.aborted) { + console.log(`[AskService] Stream reading was intentionally cancelled. Reason: ${signal.reason}`); + } else { + console.error('[AskService] Error while processing stream:', streamError); + if (askWin && !askWin.isDestroyed()) { + askWin.webContents.send('ask-response-stream-error', { error: streamError.message }); + } + } + } finally { + this.state.isStreaming = false; + this.state.currentResponse = fullResponse; + this._broadcastState(); + if (fullResponse) { + try { + await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse }); + console.log(`[AskService] DB: Saved partial or full assistant response to session ${sessionId} after stream ended.`); + } catch(dbError) { + console.error("[AskService] DB: Failed to save assistant response after stream ended:", dbError); + } + } } - } catch (error) { - console.error('[AskService] Error processing message:', error); - return { success: false, error: error.message }; } + } -function initialize() { - ipcMain.handle('ask:sendMessage', async (event, userPrompt) => { - return sendMessage(userPrompt); - }); - console.log('[AskService] Initialized and ready.'); -} +const askService = new AskService(); -module.exports = { - initialize, -}; \ No newline at end of file +module.exports = askService; \ No newline at end of file diff --git a/src/features/ask/repositories/firebase.repository.js b/src/features/ask/repositories/firebase.repository.js index 09ef25c..baeede9 100644 --- a/src/features/ask/repositories/firebase.repository.js +++ b/src/features/ask/repositories/firebase.repository.js @@ -1,6 +1,6 @@ const { collection, addDoc, query, getDocs, orderBy, Timestamp } = require('firebase/firestore'); -const { getFirestoreInstance } = require('../../../common/services/firebaseClient'); -const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter'); +const { getFirestoreInstance } = require('../../common/services/firebaseClient'); +const { createEncryptedConverter } = require('../../common/repositories/firestoreConverter'); const aiMessageConverter = createEncryptedConverter(['content']); diff --git a/src/features/ask/repositories/index.js b/src/features/ask/repositories/index.js index f9fdda1..ac2b12a 100644 --- a/src/features/ask/repositories/index.js +++ b/src/features/ask/repositories/index.js @@ -1,6 +1,6 @@ const sqliteRepository = require('./sqlite.repository'); const firebaseRepository = require('./firebase.repository'); -const authService = require('../../../common/services/authService'); +const authService = require('../../common/services/authService'); function getBaseRepository() { const user = authService.getCurrentUser(); diff --git a/src/features/ask/repositories/sqlite.repository.js b/src/features/ask/repositories/sqlite.repository.js index 19962eb..501273f 100644 --- a/src/features/ask/repositories/sqlite.repository.js +++ b/src/features/ask/repositories/sqlite.repository.js @@ -1,4 +1,4 @@ -const sqliteClient = require('../../../common/services/sqliteClient'); +const sqliteClient = require('../../common/services/sqliteClient'); function addAiMessage({ uid, sessionId, role, content, model = 'unknown' }) { // uid is ignored in the SQLite implementation diff --git a/src/common/ai/factory.js b/src/features/common/ai/factory.js similarity index 100% rename from src/common/ai/factory.js rename to src/features/common/ai/factory.js diff --git a/src/common/ai/providers/anthropic.js b/src/features/common/ai/providers/anthropic.js similarity index 100% rename from src/common/ai/providers/anthropic.js rename to src/features/common/ai/providers/anthropic.js diff --git a/src/common/ai/providers/gemini.js b/src/features/common/ai/providers/gemini.js similarity index 100% rename from src/common/ai/providers/gemini.js rename to src/features/common/ai/providers/gemini.js diff --git a/src/common/ai/providers/ollama.js b/src/features/common/ai/providers/ollama.js similarity index 100% rename from src/common/ai/providers/ollama.js rename to src/features/common/ai/providers/ollama.js diff --git a/src/common/ai/providers/openai.js b/src/features/common/ai/providers/openai.js similarity index 99% rename from src/common/ai/providers/openai.js rename to src/features/common/ai/providers/openai.js index fe37c61..eb27d14 100644 --- a/src/common/ai/providers/openai.js +++ b/src/features/common/ai/providers/openai.js @@ -82,7 +82,7 @@ async function createSTT({ apiKey, language = 'en', callbacks = {}, usePortkey = silence_duration_ms: 100, }, input_audio_noise_reduction: { - type: 'far_field' + type: 'near_field' } } }; diff --git a/src/common/ai/providers/whisper.js b/src/features/common/ai/providers/whisper.js similarity index 100% rename from src/common/ai/providers/whisper.js rename to src/features/common/ai/providers/whisper.js diff --git a/src/common/config/checksums.js b/src/features/common/config/checksums.js similarity index 100% rename from src/common/config/checksums.js rename to src/features/common/config/checksums.js diff --git a/src/common/config/config.js b/src/features/common/config/config.js similarity index 100% rename from src/common/config/config.js rename to src/features/common/config/config.js diff --git a/src/common/config/schema.js b/src/features/common/config/schema.js similarity index 95% rename from src/common/config/schema.js rename to src/features/common/config/schema.js index b1cfcd4..ad5c3b6 100644 --- a/src/common/config/schema.js +++ b/src/features/common/config/schema.js @@ -110,6 +110,13 @@ const LATEST_SCHEMA = { { name: 'selected_stt_model', type: 'TEXT' }, { name: 'updated_at', type: 'INTEGER' } ] + }, + shortcuts: { + columns: [ + { name: 'action', type: 'TEXT PRIMARY KEY' }, + { name: 'accelerator', type: 'TEXT NOT NULL' }, + { name: 'created_at', type: 'INTEGER' } + ] } }; diff --git a/src/common/prompts/promptBuilder.js b/src/features/common/prompts/promptBuilder.js similarity index 100% rename from src/common/prompts/promptBuilder.js rename to src/features/common/prompts/promptBuilder.js diff --git a/src/common/prompts/promptTemplates.js b/src/features/common/prompts/promptTemplates.js similarity index 100% rename from src/common/prompts/promptTemplates.js rename to src/features/common/prompts/promptTemplates.js diff --git a/src/common/repositories/firestoreConverter.js b/src/features/common/repositories/firestoreConverter.js similarity index 100% rename from src/common/repositories/firestoreConverter.js rename to src/features/common/repositories/firestoreConverter.js diff --git a/src/common/repositories/ollamaModel/index.js b/src/features/common/repositories/ollamaModel/index.js similarity index 100% rename from src/common/repositories/ollamaModel/index.js rename to src/features/common/repositories/ollamaModel/index.js diff --git a/src/common/repositories/ollamaModel/sqlite.repository.js b/src/features/common/repositories/ollamaModel/sqlite.repository.js similarity index 100% rename from src/common/repositories/ollamaModel/sqlite.repository.js rename to src/features/common/repositories/ollamaModel/sqlite.repository.js diff --git a/src/common/repositories/systemSettings/index.js b/src/features/common/repositories/permission/index.js similarity index 100% rename from src/common/repositories/systemSettings/index.js rename to src/features/common/repositories/permission/index.js diff --git a/src/common/repositories/systemSettings/sqlite.repository.js b/src/features/common/repositories/permission/sqlite.repository.js similarity index 100% rename from src/common/repositories/systemSettings/sqlite.repository.js rename to src/features/common/repositories/permission/sqlite.repository.js diff --git a/src/common/repositories/preset/firebase.repository.js b/src/features/common/repositories/preset/firebase.repository.js similarity index 100% rename from src/common/repositories/preset/firebase.repository.js rename to src/features/common/repositories/preset/firebase.repository.js diff --git a/src/common/repositories/preset/index.js b/src/features/common/repositories/preset/index.js similarity index 93% rename from src/common/repositories/preset/index.js rename to src/features/common/repositories/preset/index.js index ac61784..1591241 100644 --- a/src/common/repositories/preset/index.js +++ b/src/features/common/repositories/preset/index.js @@ -1,6 +1,6 @@ const sqliteRepository = require('./sqlite.repository'); const firebaseRepository = require('./firebase.repository'); -const authService = require('../../../common/services/authService'); +const authService = require('../../services/authService'); function getBaseRepository() { const user = authService.getCurrentUser(); diff --git a/src/common/repositories/preset/sqlite.repository.js b/src/features/common/repositories/preset/sqlite.repository.js similarity index 100% rename from src/common/repositories/preset/sqlite.repository.js rename to src/features/common/repositories/preset/sqlite.repository.js diff --git a/src/common/repositories/providerSettings/firebase.repository.js b/src/features/common/repositories/providerSettings/firebase.repository.js similarity index 100% rename from src/common/repositories/providerSettings/firebase.repository.js rename to src/features/common/repositories/providerSettings/firebase.repository.js diff --git a/src/common/repositories/providerSettings/index.js b/src/features/common/repositories/providerSettings/index.js similarity index 100% rename from src/common/repositories/providerSettings/index.js rename to src/features/common/repositories/providerSettings/index.js diff --git a/src/common/repositories/providerSettings/sqlite.repository.js b/src/features/common/repositories/providerSettings/sqlite.repository.js similarity index 100% rename from src/common/repositories/providerSettings/sqlite.repository.js rename to src/features/common/repositories/providerSettings/sqlite.repository.js diff --git a/src/common/repositories/session/firebase.repository.js b/src/features/common/repositories/session/firebase.repository.js similarity index 100% rename from src/common/repositories/session/firebase.repository.js rename to src/features/common/repositories/session/firebase.repository.js diff --git a/src/common/repositories/session/index.js b/src/features/common/repositories/session/index.js similarity index 100% rename from src/common/repositories/session/index.js rename to src/features/common/repositories/session/index.js diff --git a/src/common/repositories/session/sqlite.repository.js b/src/features/common/repositories/session/sqlite.repository.js similarity index 100% rename from src/common/repositories/session/sqlite.repository.js rename to src/features/common/repositories/session/sqlite.repository.js diff --git a/src/common/repositories/user/firebase.repository.js b/src/features/common/repositories/user/firebase.repository.js similarity index 100% rename from src/common/repositories/user/firebase.repository.js rename to src/features/common/repositories/user/firebase.repository.js diff --git a/src/common/repositories/user/index.js b/src/features/common/repositories/user/index.js similarity index 100% rename from src/common/repositories/user/index.js rename to src/features/common/repositories/user/index.js diff --git a/src/common/repositories/user/sqlite.repository.js b/src/features/common/repositories/user/sqlite.repository.js similarity index 100% rename from src/common/repositories/user/sqlite.repository.js rename to src/features/common/repositories/user/sqlite.repository.js diff --git a/src/common/repositories/userModelSelections/firebase.repository.js b/src/features/common/repositories/userModelSelections/firebase.repository.js similarity index 100% rename from src/common/repositories/userModelSelections/firebase.repository.js rename to src/features/common/repositories/userModelSelections/firebase.repository.js diff --git a/src/common/repositories/userModelSelections/index.js b/src/features/common/repositories/userModelSelections/index.js similarity index 100% rename from src/common/repositories/userModelSelections/index.js rename to src/features/common/repositories/userModelSelections/index.js diff --git a/src/common/repositories/userModelSelections/sqlite.repository.js b/src/features/common/repositories/userModelSelections/sqlite.repository.js similarity index 100% rename from src/common/repositories/userModelSelections/sqlite.repository.js rename to src/features/common/repositories/userModelSelections/sqlite.repository.js diff --git a/src/common/repositories/whisperModel/index.js b/src/features/common/repositories/whisperModel/index.js similarity index 100% rename from src/common/repositories/whisperModel/index.js rename to src/features/common/repositories/whisperModel/index.js diff --git a/src/common/services/authService.js b/src/features/common/services/authService.js similarity index 93% rename from src/common/services/authService.js rename to src/features/common/services/authService.js index 335c769..19cde51 100644 --- a/src/common/services/authService.js +++ b/src/features/common/services/authService.js @@ -1,5 +1,5 @@ const { onAuthStateChanged, signInWithCustomToken, signOut } = require('firebase/auth'); -const { BrowserWindow } = require('electron'); +const { BrowserWindow, shell } = require('electron'); const { getFirebaseAuth } = require('./firebaseClient'); const fetch = require('node-fetch'); const encryptionService = require('./encryptionService'); @@ -131,6 +131,19 @@ class AuthService { return this.initializationPromise; } + async startFirebaseAuthFlow() { + try { + const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000'; + const authUrl = `${webUrl}/login?mode=electron`; + console.log(`[AuthService] Opening Firebase auth URL in browser: ${authUrl}`); + await shell.openExternal(authUrl); + return { success: true }; + } catch (error) { + console.error('[AuthService] Failed to open Firebase auth URL:', error); + return { success: false, error: error.message }; + } + } + async signInWithCustomToken(token) { const auth = getFirebaseAuth(); try { diff --git a/src/common/services/databaseInitializer.js b/src/features/common/services/databaseInitializer.js similarity index 100% rename from src/common/services/databaseInitializer.js rename to src/features/common/services/databaseInitializer.js diff --git a/src/common/services/encryptionService.js b/src/features/common/services/encryptionService.js similarity index 100% rename from src/common/services/encryptionService.js rename to src/features/common/services/encryptionService.js diff --git a/src/common/services/firebaseClient.js b/src/features/common/services/firebaseClient.js similarity index 100% rename from src/common/services/firebaseClient.js rename to src/features/common/services/firebaseClient.js diff --git a/src/common/services/localAIServiceBase.js b/src/features/common/services/localAIServiceBase.js similarity index 100% rename from src/common/services/localAIServiceBase.js rename to src/features/common/services/localAIServiceBase.js diff --git a/src/common/services/localProgressTracker.js b/src/features/common/services/localProgressTracker.js similarity index 67% rename from src/common/services/localProgressTracker.js rename to src/features/common/services/localProgressTracker.js index 48f5fcb..454b431 100644 --- a/src/common/services/localProgressTracker.js +++ b/src/features/common/services/localProgressTracker.js @@ -2,10 +2,10 @@ export class LocalProgressTracker { constructor(serviceName) { this.serviceName = serviceName; this.activeOperations = new Map(); // operationId -> { controller, onProgress } - this.ipcRenderer = window.require?.('electron')?.ipcRenderer; - if (!this.ipcRenderer) { - throw new Error(`${serviceName} requires Electron environment`); + // Check if we're in renderer process with window.api available + if (!window.api) { + throw new Error(`${serviceName} requires Electron environment with contextBridge`); } this.globalProgressHandler = (event, data) => { @@ -15,14 +15,14 @@ export class LocalProgressTracker { } }; - const progressEvents = { - 'ollama': 'ollama:pull-progress', - 'whisper': 'whisper:download-progress' - }; + // Set up progress listeners based on service name + if (serviceName.toLowerCase() === 'ollama') { + window.api.settingsView.onOllamaPullProgress(this.globalProgressHandler); + } else if (serviceName.toLowerCase() === 'whisper') { + window.api.settingsView.onWhisperDownloadProgress(this.globalProgressHandler); + } - const eventName = progressEvents[serviceName.toLowerCase()] || `${serviceName}:progress`; - this.progressEvent = eventName; - this.ipcRenderer.on(eventName, this.globalProgressHandler); + this.progressEvent = serviceName.toLowerCase(); } async trackOperation(operationId, operationType, onProgress) { @@ -35,15 +35,16 @@ export class LocalProgressTracker { this.activeOperations.set(operationId, operation); try { - const ipcChannels = { - 'ollama': { install: 'ollama:pull-model' }, - 'whisper': { download: 'whisper:download-model' } - }; + let result; - const channel = ipcChannels[this.serviceName.toLowerCase()]?.[operationType] || - `${this.serviceName}:${operationType}`; - - const result = await this.ipcRenderer.invoke(channel, operationId); + // Use appropriate API call based on service and operation + if (this.serviceName.toLowerCase() === 'ollama' && operationType === 'install') { + result = await window.api.settingsView.pullOllamaModel(operationId); + } else if (this.serviceName.toLowerCase() === 'whisper' && operationType === 'download') { + result = await window.api.settingsView.downloadWhisperModel(operationId); + } else { + throw new Error(`Unsupported operation: ${this.serviceName}:${operationType}`); + } if (!result.success) { throw new Error(result.error || `${operationType} failed`); @@ -93,8 +94,12 @@ export class LocalProgressTracker { destroy() { this.cancelAllOperations(); - if (this.ipcRenderer) { - this.ipcRenderer.removeListener(this.progressEvent, this.globalProgressHandler); + + // Remove progress listeners based on service name + if (this.progressEvent === 'ollama') { + window.api.settingsView.removeOnOllamaPullProgress(this.globalProgressHandler); + } else if (this.progressEvent === 'whisper') { + window.api.settingsView.removeOnWhisperDownloadProgress(this.globalProgressHandler); } } } diff --git a/src/common/services/migrationService.js b/src/features/common/services/migrationService.js similarity index 97% rename from src/common/services/migrationService.js rename to src/features/common/services/migrationService.js index 8fe13c3..2f9b5ce 100644 --- a/src/common/services/migrationService.js +++ b/src/features/common/services/migrationService.js @@ -5,9 +5,9 @@ const encryptionService = require('../services/encryptionService'); const sqliteSessionRepo = require('../repositories/session/sqlite.repository'); const sqlitePresetRepo = require('../repositories/preset/sqlite.repository'); const sqliteUserRepo = require('../repositories/user/sqlite.repository'); -const sqliteSttRepo = require('../../features/listen/stt/repositories/sqlite.repository'); -const sqliteSummaryRepo = require('../../features/listen/summary/repositories/sqlite.repository'); -const sqliteAiMessageRepo = require('../../features/ask/repositories/sqlite.repository'); +const sqliteSttRepo = require('../../listen/stt/repositories/sqlite.repository'); +const sqliteSummaryRepo = require('../../listen/summary/repositories/sqlite.repository'); +const sqliteAiMessageRepo = require('../../ask/repositories/sqlite.repository'); const MAX_BATCH_OPERATIONS = 500; diff --git a/src/common/services/modelStateService.js b/src/features/common/services/modelStateService.js similarity index 88% rename from src/common/services/modelStateService.js rename to src/features/common/services/modelStateService.js index b5bed8b..e60a2e0 100644 --- a/src/common/services/modelStateService.js +++ b/src/features/common/services/modelStateService.js @@ -6,8 +6,11 @@ const encryptionService = require('./encryptionService'); const providerSettingsRepository = require('../repositories/providerSettings'); const userModelSelectionsRepository = require('../repositories/userModelSelections'); +// Import authService directly (singleton) +const authService = require('./authService'); + class ModelStateService { - constructor(authService) { + constructor() { this.authService = authService; this.store = new Store({ name: 'pickle-glass-model-state' }); this.state = {}; @@ -21,7 +24,6 @@ class ModelStateService { async initialize() { console.log('[ModelStateService] Initializing...'); await this._loadStateForCurrentUser(); - this.setupIpcHandlers(); console.log('[ModelStateService] Initialization complete'); } @@ -34,15 +36,17 @@ class ModelStateService { console.log(`[ModelStateService] Current Selection -> LLM: ${llmModel || 'None'} (Provider: ${llmProvider}), STT: ${sttModel || 'None'} (Provider: ${sttProvider})`); } - _autoSelectAvailableModels() { - console.log('[ModelStateService] Running auto-selection for models...'); + _autoSelectAvailableModels(forceReselectionForTypes = []) { + console.log(`[ModelStateService] Running auto-selection for models. Force re-selection for: [${forceReselectionForTypes.join(', ')}]`); const types = ['llm', 'stt']; types.forEach(type => { const currentModelId = this.state.selectedModels[type]; let isCurrentModelValid = false; - if (currentModelId) { + const forceReselection = forceReselectionForTypes.includes(type); + + if (currentModelId && !forceReselection) { const provider = this.getProviderForModel(type, currentModelId); const apiKey = this.getApiKey(provider); // For Ollama, 'local' is a valid API key @@ -52,7 +56,7 @@ class ModelStateService { } if (!isCurrentModelValid) { - console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected. Finding an alternative...`); + console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected or re-selection forced. Finding an alternative...`); const availableModels = this.getAvailableModels(type); if (availableModels.length > 0) { // Prefer API providers over local providers for auto-selection @@ -326,10 +330,20 @@ class ModelStateService { this._logCurrentSelection(); } - setApiKey(provider, key) { + async setApiKey(provider, key) { if (provider in this.state.apiKeys) { this.state.apiKeys[provider] = key; - this._saveState(); + + const supportedTypes = []; + if (PROVIDERS[provider]?.llmModels.length > 0 || provider === 'ollama') { + supportedTypes.push('llm'); + } + if (PROVIDERS[provider]?.sttModels.length > 0 || provider === 'whisper') { + supportedTypes.push('stt'); + } + + this._autoSelectAvailableModels(supportedTypes); + await this._saveState(); return true; } return false; @@ -345,6 +359,7 @@ class ModelStateService { } removeApiKey(provider) { + console.log(`[ModelStateService] Removing API key for provider: ${provider}`); if (provider in this.state.apiKeys) { this.state.apiKeys[provider] = null; const llmProvider = this.getProviderForModel('llm', this.state.selectedModels.llm); @@ -392,6 +407,8 @@ class ModelStateService { areProvidersConfigured() { if (this.isLoggedInWithFirebase()) return true; + console.log('[DEBUG] Checking configured providers with apiKeys state:', JSON.stringify(this.state.apiKeys, (key, value) => (value ? '***' : null), 2)); + // LLM과 STT 모델을 제공하는 Provider 중 하나라도 API 키가 설정되었는지 확인 const hasLlmKey = Object.entries(this.state.apiKeys).some(([provider, key]) => { if (provider === 'ollama') { @@ -506,6 +523,43 @@ class ModelStateService { } } + getProviderConfig() { + const serializableProviders = {}; + for (const key in PROVIDERS) { + const { handler, ...rest } = PROVIDERS[key]; + serializableProviders[key] = rest; + } + return serializableProviders; + } + + async handleValidateKey(provider, key) { + const result = await this.validateApiKey(provider, key); + if (result.success) { + // Use 'local' as placeholder for local services + const finalKey = (provider === 'ollama' || provider === 'whisper') ? 'local' : key; + await this.setApiKey(provider, finalKey); + } + return result; + } + + async handleRemoveApiKey(provider) { + console.log(`[ModelStateService] handleRemoveApiKey: ${provider}`); + const success = this.removeApiKey(provider); + if (success) { + const selectedModels = this.getSelectedModels(); + if (!selectedModels.llm || !selectedModels.stt) { + webContents.getAllWebContents().forEach(wc => { + wc.send('force-show-apikey-header'); + }); + } + } + return success; + } + + async handleSetSelectedModel(type, modelId) { + return this.setSelectedModel(type, modelId); + } + /** * * @param {('llm' | 'stt')} type @@ -527,55 +581,8 @@ class ModelStateService { return { provider, model, apiKey }; } - setupIpcHandlers() { - ipcMain.handle('model:validate-key', async (e, { provider, key }) => { - const result = await this.validateApiKey(provider, key); - if (result.success) { - // Use 'local' as placeholder for local services - const finalKey = (provider === 'ollama' || provider === 'whisper') ? 'local' : key; - this.setApiKey(provider, finalKey); - // After setting the key, auto-select models - this._autoSelectAvailableModels(); - this._saveState(); // Ensure state is saved after model selection - } - return result; - }); - ipcMain.handle('model:get-all-keys', () => this.getAllApiKeys()); - ipcMain.handle('model:set-api-key', async (e, { provider, key }) => { - const success = this.setApiKey(provider, key); - if (success) { - this._autoSelectAvailableModels(); - await this._saveState(); - } - return success; - }); - ipcMain.handle('model:remove-api-key', async (e, { provider }) => { - const success = this.removeApiKey(provider); - if (success) { - const selectedModels = this.getSelectedModels(); - if (!selectedModels.llm || !selectedModels.stt) { - webContents.getAllWebContents().forEach(wc => { - wc.send('force-show-apikey-header'); - }); - } - } - return success; - }); - ipcMain.handle('model:get-selected-models', () => this.getSelectedModels()); - ipcMain.handle('model:set-selected-model', async (e, { type, modelId }) => this.setSelectedModel(type, modelId)); - ipcMain.handle('model:get-available-models', (e, { type }) => this.getAvailableModels(type)); - ipcMain.handle('model:are-providers-configured', () => this.areProvidersConfigured()); - ipcMain.handle('model:get-current-model-info', (e, { type }) => this.getCurrentModelInfo(type)); - - ipcMain.handle('model:get-provider-config', () => { - const serializableProviders = {}; - for (const key in PROVIDERS) { - const { handler, ...rest } = PROVIDERS[key]; - serializableProviders[key] = rest; - } - return serializableProviders; - }); - } } -module.exports = ModelStateService; \ No newline at end of file +// Export singleton instance +const modelStateService = new ModelStateService(); +module.exports = modelStateService; \ No newline at end of file diff --git a/src/common/services/ollamaService.js b/src/features/common/services/ollamaService.js similarity index 81% rename from src/common/services/ollamaService.js rename to src/features/common/services/ollamaService.js index 13186d5..8506651 100644 --- a/src/common/services/ollamaService.js +++ b/src/features/common/services/ollamaService.js @@ -7,6 +7,7 @@ const { app } = require('electron'); const LocalAIServiceBase = require('./localAIServiceBase'); const { spawnAsync } = require('../utils/spawnHelper'); const { DOWNLOAD_CHECKSUMS } = require('../config/checksums'); +const ollamaModelRepository = require('../repositories/ollamaModel'); class OllamaService extends LocalAIServiceBase { constructor() { @@ -39,6 +40,26 @@ class OllamaService extends LocalAIServiceBase { this._startHealthMonitoring(); } + async getStatus() { + try { + const installed = await this.isInstalled(); + if (!installed) { + return { success: true, installed: false, running: false, models: [] }; + } + + const running = await this.isServiceRunning(); + if (!running) { + return { success: true, installed: true, running: false, models: [] }; + } + + const models = await this.getInstalledModels(); + return { success: true, installed: true, running: true, models }; + } catch (error) { + console.error('[OllamaService] Error getting status:', error); + return { success: false, error: error.message, installed: false, running: false, models: [] }; + } + } + getOllamaCliPath() { if (this.getPlatform() === 'darwin') { return '/Applications/Ollama.app/Contents/Resources/ollama'; @@ -802,6 +823,183 @@ class OllamaService extends LocalAIServiceBase { return models; } + + async handleGetStatus() { + try { + const installed = await this.isInstalled(); + if (!installed) { + return { success: true, installed: false, running: false, models: [] }; + } + + const running = await this.isServiceRunning(); + if (!running) { + return { success: true, installed: true, running: false, models: [] }; + } + + const models = await this.getAllModelsWithStatus(); + return { success: true, installed: true, running: true, models }; + } catch (error) { + console.error('[OllamaService] Error getting status:', error); + return { success: false, error: error.message, installed: false, running: false, models: [] }; + } + } + + async handleInstall(event) { + try { + const onProgress = (data) => { + event.sender.send('ollama:install-progress', data); + }; + + await this.autoInstall(onProgress); + + if (!await this.isServiceRunning()) { + onProgress({ stage: 'starting', message: 'Starting Ollama service...', progress: 0 }); + await this.startService(); + onProgress({ stage: 'starting', message: 'Ollama service started.', progress: 100 }); + } + event.sender.send('ollama:install-complete', { success: true }); + return { success: true }; + } catch (error) { + console.error('[OllamaService] Failed to install:', error); + event.sender.send('ollama:install-complete', { success: false, error: error.message }); + return { success: false, error: error.message }; + } + } + + async handleStartService(event) { + try { + if (!await this.isServiceRunning()) { + console.log('[OllamaService] Starting Ollama service...'); + await this.startService(); + } + event.sender.send('ollama:install-complete', { success: true }); + return { success: true }; + } catch (error) { + console.error('[OllamaService] Failed to start service:', error); + event.sender.send('ollama:install-complete', { success: false, error: error.message }); + return { success: false, error: error.message }; + } + } + + async handleEnsureReady() { + try { + if (await this.isInstalled() && !await this.isServiceRunning()) { + console.log('[OllamaService] Ollama installed but not running, starting service...'); + await this.startService(); + } + return { success: true }; + } catch (error) { + console.error('[OllamaService] Failed to ensure ready:', error); + return { success: false, error: error.message }; + } + } + + async handleGetModels() { + try { + const models = await this.getAllModelsWithStatus(); + return { success: true, models }; + } catch (error) { + console.error('[OllamaService] Failed to get models:', error); + return { success: false, error: error.message }; + } + } + + async handleGetModelSuggestions() { + try { + const suggestions = await this.getModelSuggestions(); + return { success: true, suggestions }; + } catch (error) { + console.error('[OllamaService] Failed to get model suggestions:', error); + return { success: false, error: error.message }; + } + } + + async handlePullModel(event, modelName) { + try { + console.log(`[OllamaService] Starting model pull: ${modelName}`); + + await ollamaModelRepository.updateInstallStatus(modelName, false, true); + + const progressHandler = (data) => { + if (data.model === modelName) { + event.sender.send('ollama:pull-progress', data); + } + }; + + const completeHandler = (data) => { + if (data.model === modelName) { + console.log(`[OllamaService] Model ${modelName} pull completed`); + this.removeListener('pull-progress', progressHandler); + this.removeListener('pull-complete', completeHandler); + } + }; + + this.on('pull-progress', progressHandler); + this.on('pull-complete', completeHandler); + + await this.pullModel(modelName); + + await ollamaModelRepository.updateInstallStatus(modelName, true, false); + + console.log(`[OllamaService] Model ${modelName} pull successful`); + return { success: true }; + } catch (error) { + console.error('[OllamaService] Failed to pull model:', error); + await ollamaModelRepository.updateInstallStatus(modelName, false, false); + return { success: false, error: error.message }; + } + } + + async handleIsModelInstalled(modelName) { + try { + const installed = await this.isModelInstalled(modelName); + return { success: true, installed }; + } catch (error) { + console.error('[OllamaService] Failed to check model installation:', error); + return { success: false, error: error.message }; + } + } + + async handleWarmUpModel(modelName) { + try { + const success = await this.warmUpModel(modelName); + return { success }; + } catch (error) { + console.error('[OllamaService] Failed to warm up model:', error); + return { success: false, error: error.message }; + } + } + + async handleAutoWarmUp() { + try { + const success = await this.autoWarmUpSelectedModel(); + return { success }; + } catch (error) { + console.error('[OllamaService] Failed to auto warm-up:', error); + return { success: false, error: error.message }; + } + } + + async handleGetWarmUpStatus() { + try { + const status = this.getWarmUpStatus(); + return { success: true, status }; + } catch (error) { + console.error('[OllamaService] Failed to get warm-up status:', error); + return { success: false, error: error.message }; + } + } + + async handleShutdown(event, force = false) { + try { + console.log(`[OllamaService] Manual shutdown requested (force: ${force})`); + const success = await this.shutdown(force); + return { success }; + } catch (error) { + console.error('[OllamaService] Failed to shutdown Ollama:', error); + return { success: false, error: error.message }; + } + } } // Export singleton instance diff --git a/src/features/common/services/permissionService.js b/src/features/common/services/permissionService.js new file mode 100644 index 0000000..d94ac04 --- /dev/null +++ b/src/features/common/services/permissionService.js @@ -0,0 +1,119 @@ +const { systemPreferences, shell, desktopCapturer } = require('electron'); +const permissionRepository = require('../repositories/permission'); + +class PermissionService { + async checkSystemPermissions() { + const permissions = { + microphone: 'unknown', + screen: 'unknown', + needsSetup: true + }; + + try { + if (process.platform === 'darwin') { + const micStatus = systemPreferences.getMediaAccessStatus('microphone'); + console.log('[Permissions] Microphone status:', micStatus); + permissions.microphone = micStatus; + + 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 + }; + } + } + + async requestMicrophonePermission() { + if (process.platform !== 'darwin') { + return { success: true }; + } + + try { + const status = systemPreferences.getMediaAccessStatus('microphone'); + console.log('[Permissions] Microphone status:', status); + if (status === 'granted') { + return { success: true, status: 'granted' }; + } + + 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 + }; + } + } + + async openSystemPreferences(section) { + if (process.platform !== 'darwin') { + return { success: false, error: 'Not supported on this platform' }; + } + + try { + if (section === 'screen-recording') { + 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); + } + + // await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'); + } + return { success: true }; + } catch (error) { + console.error('[Permissions] Error opening system preferences:', error); + return { success: false, error: error.message }; + } + } + + async markPermissionsAsCompleted() { + try { + await permissionRepository.markPermissionsAsCompleted(); + console.log('[Permissions] Marked permissions as completed'); + return { success: true }; + } catch (error) { + console.error('[Permissions] Error marking permissions as completed:', error); + return { success: false, error: error.message }; + } + } + + async checkPermissionsCompleted() { + try { + const completed = await permissionRepository.checkPermissionsCompleted(); + console.log('[Permissions] Permissions completed status:', completed); + return completed; + } catch (error) { + console.error('[Permissions] Error checking permissions completed status:', error); + return false; + } + } +} + +const permissionService = new PermissionService(); +module.exports = permissionService; \ No newline at end of file diff --git a/src/common/services/sqliteClient.js b/src/features/common/services/sqliteClient.js similarity index 100% rename from src/common/services/sqliteClient.js rename to src/features/common/services/sqliteClient.js diff --git a/src/common/services/whisperService.js b/src/features/common/services/whisperService.js similarity index 91% rename from src/common/services/whisperService.js rename to src/features/common/services/whisperService.js index 684c055..5d14f44 100644 --- a/src/common/services/whisperService.js +++ b/src/features/common/services/whisperService.js @@ -169,6 +169,47 @@ class WhisperService extends LocalAIServiceBase { console.log(`[WhisperService] Model ${modelId} downloaded successfully`); } + async handleDownloadModel(event, modelId) { + try { + console.log(`[WhisperService] Handling download for model: ${modelId}`); + + if (!this.isInitialized) { + await this.initialize(); + } + + const progressHandler = (data) => { + if (data.modelId === modelId && event && event.sender) { + event.sender.send('whisper:download-progress', data); + } + }; + + this.on('downloadProgress', progressHandler); + + try { + await this.ensureModelAvailable(modelId); + } finally { + this.removeListener('downloadProgress', progressHandler); + } + + return { success: true }; + } catch (error) { + console.error(`[WhisperService] Failed to handle download for model ${modelId}:`, error); + return { success: false, error: error.message }; + } + } + + async handleGetInstalledModels() { + try { + if (!this.isInitialized) { + await this.initialize(); + } + const models = await this.getInstalledModels(); + return { success: true, models }; + } catch (error) { + console.error('[WhisperService] Failed to get installed models:', error); + return { success: false, error: error.message }; + } + } async getModelPath(modelId) { if (!this.isInitialized || !this.modelsDir) { @@ -448,4 +489,6 @@ class WhisperService extends LocalAIServiceBase { } } -module.exports = { WhisperService }; \ No newline at end of file +// Export singleton instance +const whisperService = new WhisperService(); +module.exports = whisperService; \ No newline at end of file diff --git a/src/common/utils/spawnHelper.js b/src/features/common/utils/spawnHelper.js similarity index 100% rename from src/common/utils/spawnHelper.js rename to src/features/common/utils/spawnHelper.js diff --git a/src/features/listen/listenService.js b/src/features/listen/listenService.js index a4f4946..2137d57 100644 --- a/src/features/listen/listenService.js +++ b/src/features/listen/listenService.js @@ -1,8 +1,8 @@ -const { BrowserWindow, app } = require('electron'); +const { BrowserWindow } = require('electron'); const SttService = require('./stt/sttService'); const SummaryService = require('./summary/summaryService'); -const authService = require('../../common/services/authService'); -const sessionRepository = require('../../common/repositories/session'); +const authService = require('../common/services/authService'); +const sessionRepository = require('../common/repositories/session'); const sttRepository = require('./stt/repositories'); class ListenService { @@ -11,8 +11,9 @@ class ListenService { this.summaryService = new SummaryService(); this.currentSessionId = null; this.isInitializingSession = false; - + this.setupServiceCallbacks(); + console.log('[ListenService] Service instance created.'); } setupServiceCallbacks() { @@ -45,6 +46,52 @@ class ListenService { }); } + initialize() { + this.setupIpcHandlers(); + console.log('[ListenService] Initialized and ready.'); + } + + async handleListenRequest(listenButtonText) { + const { windowPool, updateLayout } = require('../../window/windowManager'); + const listenWindow = windowPool.get('listen'); + const header = windowPool.get('header'); + + try { + switch (listenButtonText) { + case 'Listen': + console.log('[ListenService] changeSession to "Listen"'); + listenWindow.show(); + updateLayout(); + listenWindow.webContents.send('window-show-animation'); + await this.initializeSession(); + listenWindow.webContents.send('session-state-changed', { isActive: true }); + break; + + case 'Stop': + console.log('[ListenService] changeSession to "Stop"'); + await this.closeSession(); + listenWindow.webContents.send('session-state-changed', { isActive: false }); + break; + + case 'Done': + console.log('[ListenService] changeSession to "Done"'); + listenWindow.webContents.send('window-hide-animation'); + listenWindow.webContents.send('session-state-changed', { isActive: false }); + break; + + default: + throw new Error(`[ListenService] unknown listenButtonText: ${listenButtonText}`); + } + + header.webContents.send('listen:changeSessionResult', { success: true }); + + } catch (error) { + console.error('[ListenService] error in handleListenRequest:', error); + header.webContents.send('listen:changeSessionResult', { success: false }); + throw error; + } + } + async handleTranscriptionComplete(speaker, text) { console.log(`[ListenService] Transcription complete: ${speaker} - ${text}`); @@ -158,8 +205,8 @@ class ListenService { } } - async sendAudioContent(data, mimeType) { - return await this.sttService.sendAudioContent(data, mimeType); + async sendMicAudioContent(data, mimeType) { + return await this.sttService.sendMicAudioContent(data, mimeType); } async startMacOSAudioCapture() { @@ -183,6 +230,8 @@ class ListenService { // Close STT sessions await this.sttService.closeSessions(); + await this.stopMacOSAudioCapture(); + // End database session if (this.currentSessionId) { await sessionRepository.end(this.currentSessionId); @@ -193,8 +242,6 @@ class ListenService { this.currentSessionId = null; this.summaryService.resetConversationHistory(); - this.sendToRenderer('session-did-close'); - console.log('Listen service session closed.'); return { success: true }; } catch (error) { @@ -216,88 +263,58 @@ class ListenService { return this.summaryService.getConversationHistory(); } - setupIpcHandlers() { - const { ipcMain } = require('electron'); - - ipcMain.handle('is-session-active', async () => { - const isActive = this.isSessionActive(); - console.log(`Checking session status. Active: ${isActive}`); - return isActive; - }); - - ipcMain.handle('initialize-openai', async (event, profile = 'interview', language = 'en') => { - console.log(`Received initialize-openai request with profile: ${profile}, language: ${language}`); - const success = await this.initializeSession(language); - return success; - }); - - ipcMain.handle('send-audio-content', async (event, { data, mimeType }) => { + _createHandler(asyncFn, successMessage, errorMessage) { + return async (...args) => { try { - await this.sendAudioContent(data, mimeType); - return { success: true }; + const result = await asyncFn.apply(this, args); + if (successMessage) console.log(successMessage); + // `startMacOSAudioCapture`는 성공 시 { success, error } 객체를 반환하지 않으므로, + // 핸들러가 일관된 응답을 보내도록 여기서 success 객체를 반환합니다. + // 다른 함수들은 이미 success 객체를 반환합니다. + return result && typeof result.success !== 'undefined' ? result : { success: true }; } catch (e) { - console.error('Error sending user audio:', e); + console.error(errorMessage, e); return { success: false, error: e.message }; } - }); + }; + } - ipcMain.handle('send-system-audio-content', async (event, { data, mimeType }) => { - try { - await this.sttService.sendSystemAudioContent(data, mimeType); - - // Send system audio data back to renderer for AEC reference (like macOS does) - this.sendToRenderer('system-audio-data', { data }); - - return { success: true }; - } catch (error) { - console.error('Error sending system audio:', error); - return { success: false, error: error.message }; - } - }); + // `_createHandler`를 사용하여 핸들러들을 동적으로 생성합니다. + handleSendMicAudioContent = this._createHandler( + this.sendMicAudioContent, + null, + 'Error sending user audio:' + ); - ipcMain.handle('start-macos-audio', async () => { + handleStartMacosAudio = this._createHandler( + async () => { if (process.platform !== 'darwin') { return { success: false, error: 'macOS audio capture only available on macOS' }; } if (this.sttService.isMacOSAudioRunning?.()) { return { success: false, error: 'already_running' }; } + await this.startMacOSAudioCapture(); + return { success: true, error: null }; + }, + 'macOS audio capture started.', + 'Error starting macOS audio capture:' + ); + + handleStopMacosAudio = this._createHandler( + this.stopMacOSAudioCapture, + 'macOS audio capture stopped.', + 'Error stopping macOS audio capture:' + ); - try { - const success = await this.startMacOSAudioCapture(); - return { success, error: null }; - } catch (error) { - console.error('Error starting macOS audio capture:', error); - return { success: false, error: error.message }; - } - }); - - ipcMain.handle('stop-macos-audio', async () => { - try { - this.stopMacOSAudioCapture(); - return { success: true }; - } catch (error) { - console.error('Error stopping macOS audio capture:', error); - return { success: false, error: error.message }; - } - }); - - // ipcMain.handle('close-session', async () => { - // return await this.closeSession(); - // }); - - ipcMain.handle('update-google-search-setting', async (event, enabled) => { - try { - console.log('Google Search setting updated to:', enabled); - return { success: true }; - } catch (error) { - console.error('Error updating Google Search setting:', error); - return { success: false, error: error.message }; - } - }); - - console.log('✅ Listen service IPC handlers registered'); - } + handleUpdateGoogleSearchSetting = this._createHandler( + async (enabled) => { + console.log('Google Search setting updated to:', enabled); + }, + null, + 'Error updating Google Search setting:' + ); } -module.exports = ListenService; \ No newline at end of file +const listenService = new ListenService(); +module.exports = listenService; \ No newline at end of file diff --git a/src/features/listen/stt/repositories/firebase.repository.js b/src/features/listen/stt/repositories/firebase.repository.js index 3fb1606..77a6e15 100644 --- a/src/features/listen/stt/repositories/firebase.repository.js +++ b/src/features/listen/stt/repositories/firebase.repository.js @@ -1,6 +1,6 @@ const { collection, addDoc, query, getDocs, orderBy, Timestamp } = require('firebase/firestore'); -const { getFirestoreInstance } = require('../../../../common/services/firebaseClient'); -const { createEncryptedConverter } = require('../../../../common/repositories/firestoreConverter'); +const { getFirestoreInstance } = require('../../../common/services/firebaseClient'); +const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter'); const transcriptConverter = createEncryptedConverter(['text']); diff --git a/src/features/listen/stt/repositories/index.js b/src/features/listen/stt/repositories/index.js index 8554bc5..ba5b873 100644 --- a/src/features/listen/stt/repositories/index.js +++ b/src/features/listen/stt/repositories/index.js @@ -1,6 +1,6 @@ const sqliteRepository = require('./sqlite.repository'); const firebaseRepository = require('./firebase.repository'); -const authService = require('../../../../common/services/authService'); +const authService = require('../../../common/services/authService'); function getBaseRepository() { const user = authService.getCurrentUser(); diff --git a/src/features/listen/stt/repositories/sqlite.repository.js b/src/features/listen/stt/repositories/sqlite.repository.js index be4d00f..f140315 100644 --- a/src/features/listen/stt/repositories/sqlite.repository.js +++ b/src/features/listen/stt/repositories/sqlite.repository.js @@ -1,4 +1,4 @@ -const sqliteClient = require('../../../../common/services/sqliteClient'); +const sqliteClient = require('../../../common/services/sqliteClient'); function addTranscript({ uid, sessionId, speaker, text }) { // uid is ignored in the SQLite implementation diff --git a/src/features/listen/stt/sttService.js b/src/features/listen/stt/sttService.js index 39f7aa8..4109dd7 100644 --- a/src/features/listen/stt/sttService.js +++ b/src/features/listen/stt/sttService.js @@ -1,7 +1,8 @@ const { BrowserWindow } = require('electron'); const { spawn } = require('child_process'); -const { createSTT } = require('../../../common/ai/factory'); -const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../electron/windowManager'); +const { createSTT } = require('../../common/ai/factory'); +const modelStateService = require('../../common/services/modelStateService'); +// const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager'); const COMPLETION_DEBOUNCE_MS = 2000; @@ -41,6 +42,17 @@ class SttService { }); } + async handleSendSystemAudioContent(data, mimeType) { + try { + await this.sendSystemAudioContent(data, mimeType); + this.sendToRenderer('system-audio-data', { data }); + return { success: true }; + } catch (error) { + console.error('Error sending system audio:', error); + return { success: false, error: error.message }; + } + } + flushMyCompletion() { const finalText = (this.myCompletionBuffer + this.myCurrentUtterance).trim(); if (!this.modelInfo || !finalText) return; @@ -120,7 +132,7 @@ class SttService { async initializeSttSessions(language = 'en') { const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en'; - const modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); + const modelInfo = modelStateService.getCurrentModelInfo('stt'); if (!modelInfo || !modelInfo.apiKey) { throw new Error('AI model or API key is not configured.'); } @@ -132,6 +144,7 @@ class SttService { console.log('[SttService] Ignoring message - session already closed'); return; } + console.log('[SttService] handleMyMessage', message); if (this.modelInfo.provider === 'whisper') { // Whisper STT emits 'transcription' events with different structure @@ -367,11 +380,6 @@ class SttService { onclose: event => console.log('Their STT session closed:', event.reason), }, }; - - // Determine auth options for providers that support it - // const authService = require('../../../common/services/authService'); - // const userState = authService.getCurrentUser(); - // const loggedIn = userState.isLoggedIn; const sttOptions = { apiKey: this.modelInfo.apiKey, @@ -393,7 +401,7 @@ class SttService { return true; } - async sendAudioContent(data, mimeType) { + async sendMicAudioContent(data, mimeType) { // const provider = await this.getAiProvider(); // const isGemini = provider === 'gemini'; @@ -404,7 +412,7 @@ class SttService { let modelInfo = this.modelInfo; if (!modelInfo) { console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); - modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); + modelInfo = modelStateService.getCurrentModelInfo('stt'); } if (!modelInfo) { throw new Error('STT model info could not be retrieved.'); @@ -425,7 +433,7 @@ class SttService { let modelInfo = this.modelInfo; if (!modelInfo) { console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); - modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); + modelInfo = modelStateService.getCurrentModelInfo('stt'); } if (!modelInfo) { throw new Error('STT model info could not be retrieved.'); @@ -476,8 +484,8 @@ class SttService { const { app } = require('electron'); const path = require('path'); const systemAudioPath = app.isPackaged - ? path.join(process.resourcesPath, 'app.asar.unpacked', 'src', 'assets', 'SystemAudioDump') - : path.join(app.getAppPath(), 'src', 'assets', 'SystemAudioDump'); + ? path.join(process.resourcesPath, 'app.asar.unpacked', 'src', 'ui', 'assets', 'SystemAudioDump') + : path.join(app.getAppPath(), 'src', 'ui', 'assets', 'SystemAudioDump'); console.log('SystemAudioDump path:', systemAudioPath); @@ -506,7 +514,7 @@ class SttService { let modelInfo = this.modelInfo; if (!modelInfo) { console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); - modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); + modelInfo = modelStateService.getCurrentModelInfo('stt'); } if (!modelInfo) { throw new Error('STT model info could not be retrieved.'); diff --git a/src/features/listen/summary/repositories/firebase.repository.js b/src/features/listen/summary/repositories/firebase.repository.js index 573c299..454797e 100644 --- a/src/features/listen/summary/repositories/firebase.repository.js +++ b/src/features/listen/summary/repositories/firebase.repository.js @@ -1,7 +1,7 @@ const { collection, doc, setDoc, getDoc, Timestamp } = require('firebase/firestore'); -const { getFirestoreInstance } = require('../../../../common/services/firebaseClient'); -const { createEncryptedConverter } = require('../../../../common/repositories/firestoreConverter'); -const encryptionService = require('../../../../common/services/encryptionService'); +const { getFirestoreInstance } = require('../../../common/services/firebaseClient'); +const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter'); +const encryptionService = require('../../../common/services/encryptionService'); const fieldsToEncrypt = ['tldr', 'text', 'bullet_json', 'action_json']; const summaryConverter = createEncryptedConverter(fieldsToEncrypt); diff --git a/src/features/listen/summary/repositories/index.js b/src/features/listen/summary/repositories/index.js index 21934ed..5bf7ba7 100644 --- a/src/features/listen/summary/repositories/index.js +++ b/src/features/listen/summary/repositories/index.js @@ -1,6 +1,6 @@ const sqliteRepository = require('./sqlite.repository'); const firebaseRepository = require('./firebase.repository'); -const authService = require('../../../../common/services/authService'); +const authService = require('../../../common/services/authService'); function getBaseRepository() { const user = authService.getCurrentUser(); diff --git a/src/features/listen/summary/repositories/sqlite.repository.js b/src/features/listen/summary/repositories/sqlite.repository.js index 1e319cb..eed4bfa 100644 --- a/src/features/listen/summary/repositories/sqlite.repository.js +++ b/src/features/listen/summary/repositories/sqlite.repository.js @@ -1,4 +1,4 @@ -const sqliteClient = require('../../../../common/services/sqliteClient'); +const sqliteClient = require('../../../common/services/sqliteClient'); function saveSummary({ uid, sessionId, tldr, text, bullet_json, action_json, model = 'unknown' }) { // uid is ignored in the SQLite implementation diff --git a/src/features/listen/summary/summaryService.js b/src/features/listen/summary/summaryService.js index a3dc065..29f92a3 100644 --- a/src/features/listen/summary/summaryService.js +++ b/src/features/listen/summary/summaryService.js @@ -1,10 +1,10 @@ const { BrowserWindow } = require('electron'); -const { getSystemPrompt } = require('../../../common/prompts/promptBuilder.js'); -const { createLLM } = require('../../../common/ai/factory'); -const authService = require('../../../common/services/authService'); -const sessionRepository = require('../../../common/repositories/session'); +const { getSystemPrompt } = require('../../common/prompts/promptBuilder.js'); +const { createLLM } = require('../../common/ai/factory'); +const sessionRepository = require('../../common/repositories/session'); const summaryRepository = require('./repositories'); -const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../electron/windowManager'); +const modelStateService = require('../../common/services/modelStateService'); +// const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager.js'); class SummaryService { constructor() { @@ -98,7 +98,7 @@ Please build upon this context while analyzing the new conversation segments. await sessionRepository.touch(this.currentSessionId); } - const modelInfo = await getCurrentModelInfo(null, { type: 'llm' }); + const modelInfo = modelStateService.getCurrentModelInfo('llm'); if (!modelInfo || !modelInfo.apiKey) { throw new Error('AI model or API key is not configured.'); } diff --git a/src/features/settings/repositories/firebase.repository.js b/src/features/settings/repositories/firebase.repository.js index e007398..50ac7d8 100644 --- a/src/features/settings/repositories/firebase.repository.js +++ b/src/features/settings/repositories/firebase.repository.js @@ -1,7 +1,7 @@ const { collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy } = require('firebase/firestore'); -const { getFirestoreInstance } = require('../../../common/services/firebaseClient'); -const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter'); -const encryptionService = require('../../../common/services/encryptionService'); +const { getFirestoreInstance } = require('../../common/services/firebaseClient'); +const { createEncryptedConverter } = require('../../common/repositories/firestoreConverter'); +const encryptionService = require('../../common/services/encryptionService'); const userPresetConverter = createEncryptedConverter(['prompt', 'title']); diff --git a/src/features/settings/repositories/index.js b/src/features/settings/repositories/index.js index bca7a9b..cb023d0 100644 --- a/src/features/settings/repositories/index.js +++ b/src/features/settings/repositories/index.js @@ -1,6 +1,6 @@ const sqliteRepository = require('./sqlite.repository'); const firebaseRepository = require('./firebase.repository'); -const authService = require('../../../common/services/authService'); +const authService = require('../../common/services/authService'); function getBaseRepository() { const user = authService.getCurrentUser(); diff --git a/src/features/settings/repositories/sqlite.repository.js b/src/features/settings/repositories/sqlite.repository.js index 86769d9..a1f76b0 100644 --- a/src/features/settings/repositories/sqlite.repository.js +++ b/src/features/settings/repositories/sqlite.repository.js @@ -1,4 +1,4 @@ -const sqliteClient = require('../../../common/services/sqliteClient'); +const sqliteClient = require('../../common/services/sqliteClient'); function getPresets(uid) { const db = sqliteClient.getDb(); diff --git a/src/features/settings/settingsService.js b/src/features/settings/settingsService.js index 069c41d..58e0a98 100644 --- a/src/features/settings/settingsService.js +++ b/src/features/settings/settingsService.js @@ -1,8 +1,13 @@ const { ipcMain, BrowserWindow } = require('electron'); const Store = require('electron-store'); -const authService = require('../../common/services/authService'); +const authService = require('../common/services/authService'); const settingsRepository = require('./repositories'); -const { getStoredApiKey, getStoredProvider, windowPool } = require('../../electron/windowManager'); +const { getStoredApiKey, getStoredProvider, windowPool } = require('../../window/windowManager'); + +// New imports for common services +const modelStateService = require('../common/services/modelStateService'); +const ollamaService = require('../common/services/ollamaService'); +const whisperService = require('../common/services/whisperService'); const store = new Store({ name: 'pickle-glass-settings', @@ -19,6 +24,51 @@ const NOTIFICATION_CONFIG = { RETRY_BASE_DELAY: 1000, // exponential backoff base (ms) }; +// New facade functions for model state management +async function getModelSettings() { + try { + const [config, storedKeys, availableLlm, availableStt, selectedModels] = await Promise.all([ + modelStateService.getProviderConfig(), + modelStateService.getAllApiKeys(), + modelStateService.getAvailableModels('llm'), + modelStateService.getAvailableModels('stt'), + modelStateService.getSelectedModels(), + ]); + return { success: true, data: { config, storedKeys, availableLlm, availableStt, selectedModels } }; + } catch (error) { + console.error('[SettingsService] Error getting model settings:', error); + return { success: false, error: error.message }; + } +} + +async function validateAndSaveKey(provider, key) { + return modelStateService.handleValidateKey(provider, key); +} + +async function clearApiKey(provider) { + const success = await modelStateService.handleRemoveApiKey(provider); + return { success }; +} + +async function setSelectedModel(type, modelId) { + const success = await modelStateService.handleSetSelectedModel(type, modelId); + return { success }; +} + +// Ollama facade functions +async function getOllamaStatus() { + return ollamaService.getStatus(); +} + +async function ensureOllamaReady() { + return ollamaService.ensureReady(); +} + +async function shutdownOllama() { + return ollamaService.shutdown(false); // false for graceful shutdown +} + + // window targeting system class WindowNotificationManager { constructor() { @@ -324,6 +374,7 @@ async function removeApiKey() { } }); + console.log('[SettingsService] API key removed for all providers'); return { success: true }; } catch (error) { console.error('[SettingsService] Error removing API key:', error); @@ -373,57 +424,6 @@ function initialize() { // cleanup windowNotificationManager.cleanup(); - // IPC handlers for settings - ipcMain.handle('settings:getSettings', async () => { - return await getSettings(); - }); - - ipcMain.handle('settings:saveSettings', async (event, settings) => { - return await saveSettings(settings); - }); - - // IPC handlers for presets - ipcMain.handle('settings:getPresets', async () => { - return await getPresets(); - }); - - ipcMain.handle('settings:getPresetTemplates', async () => { - return await getPresetTemplates(); - }); - - ipcMain.handle('settings:createPreset', async (event, title, prompt) => { - return await createPreset(title, prompt); - }); - - ipcMain.handle('settings:updatePreset', async (event, id, title, prompt) => { - return await updatePreset(id, title, prompt); - }); - - ipcMain.handle('settings:deletePreset', async (event, id) => { - return await deletePreset(id); - }); - - ipcMain.handle('settings:saveApiKey', async (event, apiKey, provider) => { - return await saveApiKey(apiKey, provider); - }); - - ipcMain.handle('settings:removeApiKey', async () => { - return await removeApiKey(); - }); - - ipcMain.handle('settings:updateContentProtection', async (event, enabled) => { - return await updateContentProtection(enabled); - }); - - ipcMain.handle('settings:get-auto-update', async () => { - return await getAutoUpdateSetting(); - }); - - ipcMain.handle('settings:set-auto-update', async (event, isEnabled) => { - console.log('[SettingsService] Setting auto update setting:', isEnabled); - return await setAutoUpdateSetting(isEnabled); - }); - console.log('[SettingsService] Initialized and ready.'); } @@ -455,4 +455,14 @@ module.exports = { removeApiKey, updateContentProtection, getAutoUpdateSetting, + setAutoUpdateSetting, + // Model settings facade + getModelSettings, + validateAndSaveKey, + clearApiKey, + setSelectedModel, + // Ollama facade + getOllamaStatus, + ensureOllamaReady, + shutdownOllama }; \ No newline at end of file diff --git a/src/features/shortcuts/repositories/index.js b/src/features/shortcuts/repositories/index.js new file mode 100644 index 0000000..7685628 --- /dev/null +++ b/src/features/shortcuts/repositories/index.js @@ -0,0 +1 @@ +module.exports = require('./sqlite.repository'); \ No newline at end of file diff --git a/src/features/shortcuts/repositories/sqlite.repository.js b/src/features/shortcuts/repositories/sqlite.repository.js new file mode 100644 index 0000000..ce770ec --- /dev/null +++ b/src/features/shortcuts/repositories/sqlite.repository.js @@ -0,0 +1,48 @@ +const sqliteClient = require('../../common/services/sqliteClient'); +const crypto = require('crypto'); + +function getAllKeybinds() { + const db = sqliteClient.getDb(); + const query = 'SELECT * FROM shortcuts'; + try { + return db.prepare(query).all(); + } catch (error) { + console.error(`[DB] Failed to get keybinds:`, error); + return []; + } +} + +function upsertKeybinds(keybinds) { + if (!keybinds || keybinds.length === 0) return; + + const db = sqliteClient.getDb(); + const upsert = db.transaction((items) => { + const query = ` + INSERT INTO shortcuts (action, accelerator, created_at) + VALUES (@action, @accelerator, @created_at) + ON CONFLICT(action) DO UPDATE SET + accelerator = excluded.accelerator; + `; + const insert = db.prepare(query); + + for (const item of items) { + insert.run({ + action: item.action, + accelerator: item.accelerator, + created_at: Math.floor(Date.now() / 1000) + }); + } + }); + + try { + upsert(keybinds); + } catch (error) { + console.error('[DB] Failed to upsert keybinds:', error); + throw error; + } +} + +module.exports = { + getAllKeybinds, + upsertKeybinds +}; \ No newline at end of file diff --git a/src/features/shortcuts/shortcutsService.js b/src/features/shortcuts/shortcutsService.js new file mode 100644 index 0000000..744ff66 --- /dev/null +++ b/src/features/shortcuts/shortcutsService.js @@ -0,0 +1,285 @@ +const { globalShortcut, screen } = require('electron'); +const shortcutsRepository = require('./repositories'); +const internalBridge = require('../../bridge/internalBridge'); +const askService = require('../ask/askService'); + + +class ShortcutsService { + constructor() { + this.lastVisibleWindows = new Set(['header']); + this.mouseEventsIgnored = false; + this.movementManager = null; + this.windowPool = null; + } + + initialize(movementManager, windowPool) { + this.movementManager = movementManager; + this.windowPool = windowPool; + internalBridge.on('reregister-shortcuts', () => { + console.log('[ShortcutsService] Reregistering shortcuts due to header state change.'); + this.registerShortcuts(); + }); + console.log('[ShortcutsService] Initialized with dependencies and event listener.'); + } + + 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', + }; + } + + async loadKeybinds() { + let keybindsArray = await shortcutsRepository.getAllKeybinds(); + + if (!keybindsArray || keybindsArray.length === 0) { + console.log(`[Shortcuts] No keybinds found. Loading defaults.`); + const defaults = this.getDefaultKeybinds(); + await this.saveKeybinds(defaults); + return defaults; + } + + const keybinds = {}; + keybindsArray.forEach(k => { + keybinds[k.action] = k.accelerator; + }); + + const defaults = this.getDefaultKeybinds(); + let needsUpdate = false; + for (const action in defaults) { + if (!keybinds[action]) { + keybinds[action] = defaults[action]; + needsUpdate = true; + } + } + + if (needsUpdate) { + console.log('[Shortcuts] Updating missing keybinds with defaults.'); + await this.saveKeybinds(keybinds); + } + + return keybinds; + } + + async handleSaveShortcuts(newKeybinds) { + try { + await this.saveKeybinds(newKeybinds); + const shortcutEditor = this.windowPool.get('shortcut-settings'); + if (shortcutEditor && !shortcutEditor.isDestroyed()) { + shortcutEditor.close(); // This will trigger re-registration on 'closed' event in windowManager + } else { + // If editor wasn't open, re-register immediately + await this.registerShortcuts(); + } + return { success: true }; + } catch (error) { + console.error("Failed to save shortcuts:", error); + // On failure, re-register old shortcuts to be safe + await this.registerShortcuts(); + return { success: false, error: error.message }; + } + } + + async handleRestoreDefaults() { + const defaults = this.getDefaultKeybinds(); + await this.saveKeybinds(defaults); + await this.registerShortcuts(); + return defaults; + } + + async saveKeybinds(newKeybinds) { + const keybindsToSave = []; + for (const action in newKeybinds) { + if (Object.prototype.hasOwnProperty.call(newKeybinds, action)) { + keybindsToSave.push({ + action: action, + accelerator: newKeybinds[action], + }); + } + } + await shortcutsRepository.upsertKeybinds(keybindsToSave); + console.log(`[Shortcuts] Saved keybinds.`); + } + + toggleAllWindowsVisibility(windowPool) { + const header = windowPool.get('header'); + if (!header) return; + + if (header.isVisible()) { + this.lastVisibleWindows.clear(); + + windowPool.forEach((win, name) => { + if (win && !win.isDestroyed() && win.isVisible()) { + this.lastVisibleWindows.add(name); + } + }); + + this.lastVisibleWindows.forEach(name => { + if (name === 'header') return; + const win = windowPool.get(name); + if (win && !win.isDestroyed()) win.hide(); + }); + header.hide(); + + return; + } + + this.lastVisibleWindows.forEach(name => { + const win = windowPool.get(name); + if (win && !win.isDestroyed()) { + win.show(); + } + }); + } + + async registerShortcuts() { + if (!this.movementManager || !this.windowPool) { + console.error('[Shortcuts] Service not initialized. Cannot register shortcuts.'); + return; + } + const keybinds = await this.loadKeybinds(); + globalShortcut.unregisterAll(); + + const header = this.windowPool.get('header'); + const mainWindow = header; + + const sendToRenderer = (channel, ...args) => { + this.windowPool.forEach(win => { + if (win && !win.isDestroyed()) { + try { + win.webContents.send(channel, ...args); + } catch (e) { + // Ignore errors for destroyed windows + } + } + }); + }; + + sendToRenderer('shortcuts-updated', keybinds); + + // --- Hardcoded shortcuts --- + const isMac = process.platform === 'darwin'; + const modifier = isMac ? 'Cmd' : 'Ctrl'; + + // Monitor switching + const displays = screen.getAllDisplays(); + if (displays.length > 1) { + displays.forEach((display, index) => { + const key = `${modifier}+Shift+${index + 1}`; + globalShortcut.register(key, () => this.movementManager.moveToDisplay(display.id)); + }); + } + + // Edge snapping + const edgeDirections = [ + { key: `${modifier}+Shift+Left`, direction: 'left' }, + { key: `${modifier}+Shift+Right`, direction: 'right' }, + ]; + edgeDirections.forEach(({ key, direction }) => { + globalShortcut.register(key, () => { + if (header && header.isVisible()) this.movementManager.moveToEdge(direction); + }); + }); + + // --- User-configurable shortcuts --- + if (header?.currentHeaderState === 'apikey') { + if (keybinds.toggleVisibility) { + globalShortcut.register(keybinds.toggleVisibility, () => this.toggleAllWindowsVisibility(this.windowPool)); + } + console.log('[Shortcuts] ApiKeyHeader is active, only toggleVisibility shortcut is registered.'); + return; + } + + for (const action in keybinds) { + const accelerator = keybinds[action]; + if (!accelerator) continue; + + let callback; + switch(action) { + case 'toggleVisibility': + callback = () => this.toggleAllWindowsVisibility(this.windowPool); + break; + case 'nextStep': + callback = () => askService.toggleAskButton(); + break; + case 'scrollUp': + callback = () => { + const askWindow = this.windowPool.get('ask'); + if (askWindow && !askWindow.isDestroyed() && askWindow.isVisible()) { + askWindow.webContents.send('scroll-response-up'); + } + }; + break; + case 'scrollDown': + callback = () => { + const askWindow = this.windowPool.get('ask'); + if (askWindow && !askWindow.isDestroyed() && askWindow.isVisible()) { + askWindow.webContents.send('scroll-response-down'); + } + }; + break; + case 'moveUp': + callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('up'); }; + break; + case 'moveDown': + callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('down'); }; + break; + case 'moveLeft': + callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('left'); }; + break; + case 'moveRight': + callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('right'); }; + break; + case 'toggleClickThrough': + callback = () => { + this.mouseEventsIgnored = !this.mouseEventsIgnored; + if(mainWindow && !mainWindow.isDestroyed()){ + mainWindow.setIgnoreMouseEvents(this.mouseEventsIgnored, { forward: true }); + mainWindow.webContents.send('click-through-toggled', this.mouseEventsIgnored); + } + }; + break; + case 'manualScreenshot': + callback = () => { + if(mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.executeJavaScript('window.captureManualScreenshot && window.captureManualScreenshot();'); + } + }; + break; + case 'previousResponse': + callback = () => sendToRenderer('navigate-previous-response'); + break; + case 'nextResponse': + callback = () => sendToRenderer('navigate-next-response'); + break; + } + + if (callback) { + try { + globalShortcut.register(accelerator, callback); + } catch(e) { + console.error(`[Shortcuts] Failed to register shortcut for "${action}" (${accelerator}):`, e.message); + } + } + } + console.log('[Shortcuts] All shortcuts have been registered.'); + } + + unregisterAll() { + globalShortcut.unregisterAll(); + console.log('[Shortcuts] All shortcuts have been unregistered.'); + } +} + +module.exports = new ShortcutsService(); \ No newline at end of file diff --git a/src/index.js b/src/index.js index c0e1a6c..627c560 100644 --- a/src/index.js +++ b/src/index.js @@ -12,11 +12,11 @@ if (require('electron-squirrel-startup')) { } const { app, BrowserWindow, shell, ipcMain, dialog, desktopCapturer, session } = require('electron'); -const { createWindows } = require('./electron/windowManager.js'); -const ListenService = require('./features/listen/listenService'); -const { initializeFirebase } = require('./common/services/firebaseClient'); -const databaseInitializer = require('./common/services/databaseInitializer'); -const authService = require('./common/services/authService'); +const { createWindows } = require('./window/windowManager.js'); +const listenService = require('./features/listen/listenService'); +const { initializeFirebase } = require('./features/common/services/firebaseClient'); +const databaseInitializer = require('./features/common/services/databaseInitializer'); +const authService = require('./features/common/services/authService'); const path = require('node:path'); const express = require('express'); const fetch = require('node-fetch'); @@ -24,27 +24,23 @@ const { autoUpdater } = require('electron-updater'); const { EventEmitter } = require('events'); const askService = require('./features/ask/askService'); const settingsService = require('./features/settings/settingsService'); -const sessionRepository = require('./common/repositories/session'); -const ModelStateService = require('./common/services/modelStateService'); -const sqliteClient = require('./common/services/sqliteClient'); +const sessionRepository = require('./features/common/repositories/session'); +const modelStateService = require('./features/common/services/modelStateService'); +const featureBridge = require('./bridge/featureBridge'); +const windowBridge = require('./bridge/windowBridge'); // Global variables const eventBridge = new EventEmitter(); let WEB_PORT = 3000; let isShuttingDown = false; // Flag to prevent infinite shutdown loop -const listenService = new ListenService(); -// Make listenService globally accessible so other modules (e.g., windowManager, askService) can reuse the same instance -global.listenService = listenService; - //////// after_modelStateService //////// -const modelStateService = new ModelStateService(authService); global.modelStateService = modelStateService; //////// after_modelStateService //////// // Import and initialize OllamaService -const ollamaService = require('./common/services/ollamaService'); -const ollamaModelRepository = require('./common/repositories/ollamaModel'); +const ollamaService = require('./features/common/services/ollamaService'); +const ollamaModelRepository = require('./features/common/repositories/ollamaModel'); // Native deep link handling - cross-platform compatible let pendingDeepLinkUrl = null; @@ -123,7 +119,7 @@ function setupProtocolHandling() { } function focusMainWindow() { - const { windowPool } = require('./electron/windowManager'); + const { windowPool } = require('./window/windowManager.js'); if (windowPool) { const header = windowPool.get('header'); if (header && !header.isDestroyed()) { @@ -202,12 +198,9 @@ app.whenReady().then(async () => { await modelStateService.initialize(); //////// after_modelStateService //////// - listenService.setupIpcHandlers(); - askService.initialize(); - settingsService.initialize(); - setupGeneralIpcHandlers(); - setupOllamaIpcHandlers(); - setupWhisperIpcHandlers(); + featureBridge.initialize(); // 추가: featureBridge 초기화 + windowBridge.initialize(); + setupWebDataHandlers(); // Initialize Ollama models in database await ollamaModelRepository.initializeDefaultModels(); @@ -248,13 +241,6 @@ app.whenReady().then(async () => { } }); -app.on('window-all-closed', () => { - listenService.stopMacOSAudioCapture(); - if (process.platform !== 'darwin') { - app.quit(); - } -}); - app.on('before-quit', async (event) => { // Prevent infinite loop by checking if shutdown is already in progress if (isShuttingDown) { @@ -272,7 +258,7 @@ app.on('before-quit', async (event) => { try { // 1. Stop audio capture first (immediate) - listenService.stopMacOSAudioCapture(); + await listenService.closeSession(); console.log('[Shutdown] Audio capture stopped'); // 2. End all active sessions (database operations) - with error handling @@ -328,309 +314,13 @@ app.on('activate', () => { } }); -function setupWhisperIpcHandlers() { - const { WhisperService } = require('./common/services/whisperService'); - const whisperService = new WhisperService(); - - // Forward download progress events to renderer - whisperService.on('downloadProgress', (data) => { - const windows = BrowserWindow.getAllWindows(); - windows.forEach(window => { - window.webContents.send('whisper:download-progress', data); - }); - }); - - // IPC handlers for Whisper operations - ipcMain.handle('whisper:download-model', async (event, modelId) => { - try { - console.log(`[Whisper IPC] Starting download for model: ${modelId}`); - - // Ensure WhisperService is initialized first - if (!whisperService.isInitialized) { - console.log('[Whisper IPC] Initializing WhisperService...'); - await whisperService.initialize(); - } - - // Set up progress listener - const progressHandler = (data) => { - if (data.modelId === modelId) { - event.sender.send('whisper:download-progress', data); - } - }; - - whisperService.on('downloadProgress', progressHandler); - - try { - await whisperService.ensureModelAvailable(modelId); - console.log(`[Whisper IPC] Model ${modelId} download completed successfully`); - } finally { - // Cleanup listener - whisperService.removeListener('downloadProgress', progressHandler); - } - - return { success: true }; - } catch (error) { - console.error(`[Whisper IPC] Failed to download model ${modelId}:`, error); - return { success: false, error: error.message }; - } - }); - - ipcMain.handle('whisper:get-installed-models', async () => { - try { - // Ensure WhisperService is initialized first - if (!whisperService.isInitialized) { - console.log('[Whisper IPC] Initializing WhisperService for model list...'); - await whisperService.initialize(); - } - - const models = await whisperService.getInstalledModels(); - return { success: true, models }; - } catch (error) { - console.error('[Whisper IPC] Failed to get installed models:', error); - return { success: false, error: error.message }; - } - }); -} - -function setupGeneralIpcHandlers() { - const userRepository = require('./common/repositories/user'); - const presetRepository = require('./common/repositories/preset'); - - ipcMain.handle('get-user-presets', () => { - // The adapter injects the UID. - return presetRepository.getPresets(); - }); - - ipcMain.handle('get-preset-templates', () => { - return presetRepository.getPresetTemplates(); - }); - - ipcMain.handle('start-firebase-auth', async () => { - try { - const authUrl = `http://localhost:${WEB_PORT}/login?mode=electron`; - console.log(`[Auth] Opening Firebase auth URL in browser: ${authUrl}`); - await shell.openExternal(authUrl); - return { success: true }; - } catch (error) { - console.error('[Auth] Failed to open Firebase auth URL:', error); - return { success: false, error: error.message }; - } - }); - - ipcMain.handle('get-web-url', () => { - return process.env.pickleglass_WEB_URL || 'http://localhost:3000'; - }); - - ipcMain.handle('get-current-user', () => { - return authService.getCurrentUser(); - }); - - // --- Web UI Data Handlers (New) --- - setupWebDataHandlers(); -} - -function setupOllamaIpcHandlers() { - // Ollama status and installation - ipcMain.handle('ollama:get-status', async () => { - try { - const installed = await ollamaService.isInstalled(); - const running = installed ? await ollamaService.isServiceRunning() : false; - const models = await ollamaService.getAllModelsWithStatus(); - - return { - installed, - running, - models, - success: true - }; - } catch (error) { - console.error('[Ollama IPC] Failed to get status:', error); - return { success: false, error: error.message }; - } - }); - - ipcMain.handle('ollama:install', async (event) => { - try { - const onProgress = (data) => { - event.sender.send('ollama:install-progress', data); - }; - - await ollamaService.autoInstall(onProgress); - - if (!await ollamaService.isServiceRunning()) { - onProgress({ stage: 'starting', message: 'Starting Ollama service...', progress: 0 }); - await ollamaService.startService(); - onProgress({ stage: 'starting', message: 'Ollama service started.', progress: 100 }); - } - event.sender.send('ollama:install-complete', { success: true }); - return { success: true }; - } catch (error) { - console.error('[Ollama IPC] Failed to install:', error); - event.sender.send('ollama:install-complete', { success: false, error: error.message }); - return { success: false, error: error.message }; - } - }); - - ipcMain.handle('ollama:start-service', async (event) => { - try { - if (!await ollamaService.isServiceRunning()) { - console.log('[Ollama IPC] Starting Ollama service...'); - await ollamaService.startService(); - } - event.sender.send('ollama:install-complete', { success: true }); - return { success: true }; - } catch (error) { - console.error('[Ollama IPC] Failed to start service:', error); - event.sender.send('ollama:install-complete', { success: false, error: error.message }); - return { success: false, error: error.message }; - } - }); - - // Ensure Ollama is ready (starts service if installed but not running) - ipcMain.handle('ollama:ensure-ready', async () => { - try { - if (await ollamaService.isInstalled() && !await ollamaService.isServiceRunning()) { - console.log('[Ollama IPC] Ollama installed but not running, starting service...'); - await ollamaService.startService(); - } - return { success: true }; - } catch (error) { - console.error('[Ollama IPC] Failed to ensure ready:', error); - return { success: false, error: error.message }; - } - }); - - // Get all models with their status - ipcMain.handle('ollama:get-models', async () => { - try { - const models = await ollamaService.getAllModelsWithStatus(); - return { success: true, models }; - } catch (error) { - console.error('[Ollama IPC] Failed to get models:', error); - return { success: false, error: error.message }; - } - }); - - // Get model suggestions for autocomplete - ipcMain.handle('ollama:get-model-suggestions', async () => { - try { - const suggestions = await ollamaService.getModelSuggestions(); - return { success: true, suggestions }; - } catch (error) { - console.error('[Ollama IPC] Failed to get model suggestions:', error); - return { success: false, error: error.message }; - } - }); - - // Pull/install a specific model - ipcMain.handle('ollama:pull-model', async (event, modelName) => { - try { - console.log(`[Ollama IPC] Starting model pull: ${modelName}`); - - // Update DB status to installing - await ollamaModelRepository.updateInstallStatus(modelName, false, true); - - // Set up progress listener for real-time updates - const progressHandler = (data) => { - if (data.model === modelName) { - event.sender.send('ollama:pull-progress', data); - } - }; - - const completeHandler = (data) => { - if (data.model === modelName) { - console.log(`[Ollama IPC] Model ${modelName} pull completed`); - // Clean up listeners - ollamaService.removeListener('pull-progress', progressHandler); - ollamaService.removeListener('pull-complete', completeHandler); - } - }; - - ollamaService.on('pull-progress', progressHandler); - ollamaService.on('pull-complete', completeHandler); - - // Pull the model using REST API - await ollamaService.pullModel(modelName); - - // Update DB status to installed - await ollamaModelRepository.updateInstallStatus(modelName, true, false); - - console.log(`[Ollama IPC] Model ${modelName} pull successful`); - return { success: true }; - } catch (error) { - console.error('[Ollama IPC] Failed to pull model:', error); - // Reset status on error - await ollamaModelRepository.updateInstallStatus(modelName, false, false); - return { success: false, error: error.message }; - } - }); - - // Check if a specific model is installed - ipcMain.handle('ollama:is-model-installed', async (event, modelName) => { - try { - const installed = await ollamaService.isModelInstalled(modelName); - return { success: true, installed }; - } catch (error) { - console.error('[Ollama IPC] Failed to check model installation:', error); - return { success: false, error: error.message }; - } - }); - - // Warm up a specific model - ipcMain.handle('ollama:warm-up-model', async (event, modelName) => { - try { - const success = await ollamaService.warmUpModel(modelName); - return { success }; - } catch (error) { - console.error('[Ollama IPC] Failed to warm up model:', error); - return { success: false, error: error.message }; - } - }); - - // Auto warm-up currently selected model - ipcMain.handle('ollama:auto-warm-up', async () => { - try { - const success = await ollamaService.autoWarmUpSelectedModel(); - return { success }; - } catch (error) { - console.error('[Ollama IPC] Failed to auto warm-up:', error); - return { success: false, error: error.message }; - } - }); - - // Get warm-up status for debugging - ipcMain.handle('ollama:get-warm-up-status', async () => { - try { - const status = ollamaService.getWarmUpStatus(); - return { success: true, status }; - } catch (error) { - console.error('[Ollama IPC] Failed to get warm-up status:', error); - return { success: false, error: error.message }; - } - }); - - // Shutdown Ollama service manually - ipcMain.handle('ollama:shutdown', async (event, force = false) => { - try { - console.log(`[Ollama IPC] Manual shutdown requested (force: ${force})`); - const success = await ollamaService.shutdown(force); - return { success }; - } catch (error) { - console.error('[Ollama IPC] Failed to shutdown Ollama:', error); - return { success: false, error: error.message }; - } - }); - - console.log('[Ollama IPC] Handlers registered'); -} - function setupWebDataHandlers() { - const sessionRepository = require('./common/repositories/session'); + const sessionRepository = require('./features/common/repositories/session'); const sttRepository = require('./features/listen/stt/repositories'); const summaryRepository = require('./features/listen/summary/repositories'); const askRepository = require('./features/ask/repositories'); - const userRepository = require('./common/repositories/user'); - const presetRepository = require('./common/repositories/preset'); + const userRepository = require('./features/common/repositories/user'); + const presetRepository = require('./features/common/repositories/preset'); const handleRequest = async (channel, responseChannel, payload) => { let result; @@ -788,7 +478,7 @@ async function handleCustomUrl(url) { handlePersonalizeFromUrl(params); break; default: - const { windowPool } = require('./electron/windowManager'); + const { windowPool } = require('./window/windowManager.js'); const header = windowPool.get('header'); if (header) { if (header.isMinimized()) header.restore(); @@ -806,7 +496,7 @@ async function handleCustomUrl(url) { } async function handleFirebaseAuthCallback(params) { - const userRepository = require('./common/repositories/user'); + const userRepository = require('./features/common/repositories/user'); const { token: idToken } = params; if (!idToken) { @@ -850,7 +540,7 @@ async function handleFirebaseAuthCallback(params) { console.log('[Auth] Main process sign-in initiated. Waiting for onAuthStateChanged...'); // 3. Focus the app window - const { windowPool } = require('./electron/windowManager'); + const { windowPool } = require('./window/windowManager.js'); const header = windowPool.get('header'); if (header) { if (header.isMinimized()) header.restore(); @@ -863,7 +553,7 @@ async function handleFirebaseAuthCallback(params) { console.error('[Auth] Error during custom token exchange or sign-in:', error); // The UI will not change, and the user can try again. // Optionally, send a generic error event to the renderer. - const { windowPool } = require('./electron/windowManager'); + const { windowPool } = require('./window/windowManager.js'); const header = windowPool.get('header'); if (header) { header.webContents.send('auth-failed', { message: error.message }); @@ -874,7 +564,7 @@ async function handleFirebaseAuthCallback(params) { function handlePersonalizeFromUrl(params) { console.log('[Custom URL] Personalize params:', params); - const { windowPool } = require('./electron/windowManager'); + const { windowPool } = require('./window/windowManager.js'); const header = windowPool.get('header'); if (header) { diff --git a/src/preload.js b/src/preload.js index 5e9d369..762a382 100644 --- a/src/preload.js +++ b/src/preload.js @@ -1,2 +1,293 @@ -// See the Electron documentation for details on how to use preload scripts: -// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts +// src/preload.js +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('api', { + // Platform information for renderer processes + platform: { + isLinux: process.platform === 'linux', + isMacOS: process.platform === 'darwin', + isWindows: process.platform === 'win32', + platform: process.platform + }, + + // Common utilities used across multiple components + common: { + // User & Auth + getCurrentUser: () => ipcRenderer.invoke('get-current-user'), + startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'), + firebaseLogout: () => ipcRenderer.invoke('firebase-logout'), + + // App Control + quitApplication: () => ipcRenderer.invoke('quit-application'), + + // User state listener (used by multiple components) + onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback), + removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback), + }, + + // UI Component specific namespaces + // src/ui/app/ApiKeyHeader.js + apiKeyHeader: { + // Model & Provider Management + getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'), + getOllamaStatus: () => ipcRenderer.invoke('ollama:get-status'), + getModelSuggestions: () => ipcRenderer.invoke('ollama:get-model-suggestions'), + ensureOllamaReady: () => ipcRenderer.invoke('ollama:ensure-ready'), + installOllama: () => ipcRenderer.invoke('ollama:install'), + startOllamaService: () => ipcRenderer.invoke('ollama:start-service'), + pullOllamaModel: (modelName) => ipcRenderer.invoke('ollama:pull-model', modelName), + downloadWhisperModel: (modelId) => ipcRenderer.invoke('whisper:download-model', modelId), + validateKey: (data) => ipcRenderer.invoke('model:validate-key', data), + setSelectedModel: (data) => ipcRenderer.invoke('model:set-selected-model', data), + areProvidersConfigured: () => ipcRenderer.invoke('model:are-providers-configured'), + + // Window Management + getHeaderPosition: () => ipcRenderer.invoke('get-header-position'), + moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y), + + // Listeners + onOllamaInstallProgress: (callback) => ipcRenderer.on('ollama:install-progress', callback), + removeOnOllamaInstallProgress: (callback) => ipcRenderer.removeListener('ollama:install-progress', callback), + onceOllamaInstallComplete: (callback) => ipcRenderer.once('ollama:install-complete', callback), + removeOnceOllamaInstallComplete: (callback) => ipcRenderer.removeListener('ollama:install-complete', callback), + onOllamaPullProgress: (callback) => ipcRenderer.on('ollama:pull-progress', callback), + removeOnOllamaPullProgress: (callback) => ipcRenderer.removeListener('ollama:pull-progress', callback), + onWhisperDownloadProgress: (callback) => ipcRenderer.on('whisper:download-progress', callback), + removeOnWhisperDownloadProgress: (callback) => ipcRenderer.removeListener('whisper:download-progress', callback), + + // Remove all listeners (for cleanup) + removeAllListeners: () => { + ipcRenderer.removeAllListeners('whisper:download-progress'); + ipcRenderer.removeAllListeners('ollama:install-progress'); + ipcRenderer.removeAllListeners('ollama:pull-progress'); + ipcRenderer.removeAllListeners('ollama:install-complete'); + } + }, + + // src/ui/app/HeaderController.js + headerController: { + // State Management + sendHeaderStateChanged: (state) => ipcRenderer.send('header-state-changed', state), + + // Window Management + resizeHeaderWindow: (dimensions) => ipcRenderer.invoke('resize-header-window', dimensions), + + // Permissions + checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'), + checkPermissionsCompleted: () => ipcRenderer.invoke('check-permissions-completed'), + + // Listeners + onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback), + removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback), + onAuthFailed: (callback) => ipcRenderer.on('auth-failed', callback), + removeOnAuthFailed: (callback) => ipcRenderer.removeListener('auth-failed', callback), + onForceShowApiKeyHeader: (callback) => ipcRenderer.on('force-show-apikey-header', callback), + removeOnForceShowApiKeyHeader: (callback) => ipcRenderer.removeListener('force-show-apikey-header', callback) + }, + + // src/ui/app/MainHeader.js + mainHeader: { + // Window Management + getHeaderPosition: () => ipcRenderer.invoke('get-header-position'), + moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y), + sendHeaderAnimationFinished: (state) => ipcRenderer.send('header-animation-finished', state), + + // Settings Window Management + cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'), + showSettingsWindow: (bounds) => ipcRenderer.send('show-settings-window', bounds), + hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'), + + // Generic invoke (for dynamic channel names) + invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), + + // Listeners + onListenChangeSessionResult: (callback) => ipcRenderer.on('listen:changeSessionResult', callback), + removeOnListenChangeSessionResult: (callback) => ipcRenderer.removeListener('listen:changeSessionResult', callback), + onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback), + removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback) + }, + + // src/ui/app/PermissionHeader.js + permissionHeader: { + // Permission Management + checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'), + requestMicrophonePermission: () => ipcRenderer.invoke('request-microphone-permission'), + openSystemPreferences: (preference) => ipcRenderer.invoke('open-system-preferences', preference), + markPermissionsCompleted: () => ipcRenderer.invoke('mark-permissions-completed') + }, + + // src/ui/app/PickleGlassApp.js + pickleGlassApp: { + // Listeners + onClickThroughToggled: (callback) => ipcRenderer.on('click-through-toggled', callback), + removeOnClickThroughToggled: (callback) => ipcRenderer.removeListener('click-through-toggled', callback), + removeAllClickThroughListeners: () => ipcRenderer.removeAllListeners('click-through-toggled') + }, + + // src/ui/ask/AskView.js + askView: { + // Window Management + closeAskWindow: () => ipcRenderer.invoke('ask:closeAskWindow'), + adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height), + + // Message Handling + sendMessage: (text) => ipcRenderer.invoke('ask:sendQuestionFromAsk', text), + + // Listeners + onAskStateUpdate: (callback) => ipcRenderer.on('ask:stateUpdate', callback), + removeOnAskStateUpdate: (callback) => ipcRenderer.removeListener('ask:stateUpdate', callback), + + // Listeners + onShowTextInput: (callback) => ipcRenderer.on('ask:showTextInput', callback), + removeOnShowTextInput: (callback) => ipcRenderer.removeListener('ask:showTextInput', callback), + + onScrollResponseUp: (callback) => ipcRenderer.on('aks:scrollResponseUp', callback), + removeOnScrollResponseUp: (callback) => ipcRenderer.removeListener('aks:scrollResponseUp', callback), + onScrollResponseDown: (callback) => ipcRenderer.on('aks:scrollResponseDown', callback), + removeOnScrollResponseDown: (callback) => ipcRenderer.removeListener('aks:scrollResponseDown', callback) + }, + + // src/ui/listen/ListenView.js + listenView: { + // Window Management + adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height), + + // Listeners + onSessionStateChanged: (callback) => ipcRenderer.on('session-state-changed', callback), + removeOnSessionStateChanged: (callback) => ipcRenderer.removeListener('session-state-changed', callback) + }, + + // src/ui/listen/stt/SttView.js + sttView: { + // Listeners + onSttUpdate: (callback) => ipcRenderer.on('stt-update', callback), + removeOnSttUpdate: (callback) => ipcRenderer.removeListener('stt-update', callback) + }, + + // src/ui/listen/summary/SummaryView.js + summaryView: { + // Message Handling + sendQuestionFromSummary: (text) => ipcRenderer.invoke('ask:sendQuestionFromSummary', text), + + // Listeners + onSummaryUpdate: (callback) => ipcRenderer.on('summary-update', callback), + removeOnSummaryUpdate: (callback) => ipcRenderer.removeListener('summary-update', callback), + removeAllSummaryUpdateListeners: () => ipcRenderer.removeAllListeners('summary-update') + }, + + // src/ui/settings/SettingsView.js + settingsView: { + // User & Auth + getCurrentUser: () => ipcRenderer.invoke('get-current-user'), + openPersonalizePage: () => ipcRenderer.invoke('open-personalize-page'), + firebaseLogout: () => ipcRenderer.invoke('firebase-logout'), + startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'), + + // Model & Provider Management + getModelSettings: () => ipcRenderer.invoke('settings:get-model-settings'), // Facade call + getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'), + getAllKeys: () => ipcRenderer.invoke('model:get-all-keys'), + getAvailableModels: (type) => ipcRenderer.invoke('model:get-available-models', type), + getSelectedModels: () => ipcRenderer.invoke('model:get-selected-models'), + validateKey: (data) => ipcRenderer.invoke('model:validate-key', data), + saveApiKey: (key) => ipcRenderer.invoke('model:save-api-key', key), + removeApiKey: (provider) => ipcRenderer.invoke('model:remove-api-key', provider), + setSelectedModel: (data) => ipcRenderer.invoke('model:set-selected-model', data), + + // Ollama Management + getOllamaStatus: () => ipcRenderer.invoke('ollama:get-status'), + ensureOllamaReady: () => ipcRenderer.invoke('ollama:ensure-ready'), + shutdownOllama: (graceful) => ipcRenderer.invoke('ollama:shutdown', graceful), + + // Whisper Management + getWhisperInstalledModels: () => ipcRenderer.invoke('whisper:get-installed-models'), + downloadWhisperModel: (modelId) => ipcRenderer.invoke('whisper:download-model', modelId), + + // Settings Management + getPresets: () => ipcRenderer.invoke('settings:getPresets'), + getAutoUpdate: () => ipcRenderer.invoke('settings:get-auto-update'), + setAutoUpdate: (isEnabled) => ipcRenderer.invoke('settings:set-auto-update', isEnabled), + getContentProtectionStatus: () => ipcRenderer.invoke('get-content-protection-status'), + toggleContentProtection: () => ipcRenderer.invoke('toggle-content-protection'), + getCurrentShortcuts: () => ipcRenderer.invoke('get-current-shortcuts'), + openShortcutEditor: () => ipcRenderer.invoke('open-shortcut-editor'), + + // Window Management + moveWindowStep: (direction) => ipcRenderer.invoke('move-window-step', direction), + cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'), + hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'), + + // App Control + quitApplication: () => ipcRenderer.invoke('quit-application'), + + // Progress Tracking + pullOllamaModel: (modelName) => ipcRenderer.invoke('ollama:pull-model', modelName), + + // Listeners + onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback), + removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback), + onSettingsUpdated: (callback) => ipcRenderer.on('settings-updated', callback), + removeOnSettingsUpdated: (callback) => ipcRenderer.removeListener('settings-updated', callback), + onPresetsUpdated: (callback) => ipcRenderer.on('presets-updated', callback), + removeOnPresetsUpdated: (callback) => ipcRenderer.removeListener('presets-updated', callback), + onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback), + removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback), + onWhisperDownloadProgress: (callback) => ipcRenderer.on('whisper:download-progress', callback), + removeOnWhisperDownloadProgress: (callback) => ipcRenderer.removeListener('whisper:download-progress', callback), + onOllamaPullProgress: (callback) => ipcRenderer.on('ollama:pull-progress', callback), + removeOnOllamaPullProgress: (callback) => ipcRenderer.removeListener('ollama:pull-progress', callback) + }, + + // src/ui/settings/ShortCutSettingsView.js + shortcutSettingsView: { + // Shortcut Management + saveShortcuts: (shortcuts) => ipcRenderer.invoke('save-shortcuts', shortcuts), + getDefaultShortcuts: () => ipcRenderer.invoke('get-default-shortcuts'), + closeShortcutEditor: () => ipcRenderer.send('close-shortcut-editor'), + + // Listeners + onLoadShortcuts: (callback) => ipcRenderer.on('load-shortcuts', callback), + removeOnLoadShortcuts: (callback) => ipcRenderer.removeListener('load-shortcuts', callback) + }, + + // src/ui/app/content.html inline scripts + content: { + // Animation Management + sendAnimationFinished: () => ipcRenderer.send('animation-finished'), + + // Listeners + onWindowShowAnimation: (callback) => ipcRenderer.on('window-show-animation', callback), + removeOnWindowShowAnimation: (callback) => ipcRenderer.removeListener('window-show-animation', callback), + onWindowHideAnimation: (callback) => ipcRenderer.on('window-hide-animation', callback), + removeOnWindowHideAnimation: (callback) => ipcRenderer.removeListener('window-hide-animation', callback), + onSettingsWindowHideAnimation: (callback) => ipcRenderer.on('settings-window-hide-animation', callback), + removeOnSettingsWindowHideAnimation: (callback) => ipcRenderer.removeListener('settings-window-hide-animation', callback), + onListenWindowMoveToCenter: (callback) => ipcRenderer.on('listen-window-move-to-center', callback), + removeOnListenWindowMoveToCenter: (callback) => ipcRenderer.removeListener('listen-window-move-to-center', callback), + onListenWindowMoveToLeft: (callback) => ipcRenderer.on('listen-window-move-to-left', callback), + removeOnListenWindowMoveToLeft: (callback) => ipcRenderer.removeListener('listen-window-move-to-left', callback) + }, + + // src/ui/listen/audioCore/listenCapture.js + listenCapture: { + // Audio Management + sendMicAudioContent: (data) => ipcRenderer.invoke('listen:sendMicAudio', data), + sendSystemAudioContent: (data) => ipcRenderer.invoke('listen:sendSystemAudio', data), + startMacosSystemAudio: () => ipcRenderer.invoke('listen:startMacosSystemAudio'), + stopMacosSystemAudio: () => ipcRenderer.invoke('listen:stopMacosSystemAudio'), + + // Session Management + isSessionActive: () => ipcRenderer.invoke('is-session-active'), + + // Listeners + onSystemAudioData: (callback) => ipcRenderer.on('system-audio-data', callback), + removeOnSystemAudioData: (callback) => ipcRenderer.removeListener('system-audio-data', callback) + }, + + // src/ui/listen/audioCore/renderer.js + renderer: { + // Listeners + onChangeListenCaptureState: (callback) => ipcRenderer.on('change-listen-capture-state', callback), + removeOnChangeListenCaptureState: (callback) => ipcRenderer.removeListener('change-listen-capture-state', callback) + } +}); \ No newline at end of file diff --git a/src/app/ApiKeyHeader.js b/src/ui/app/ApiKeyHeader.js similarity index 94% rename from src/app/ApiKeyHeader.js rename to src/ui/app/ApiKeyHeader.js index 276fa85..0bdbe03 100644 --- a/src/app/ApiKeyHeader.js +++ b/src/ui/app/ApiKeyHeader.js @@ -1,5 +1,5 @@ import { html, css, LitElement } from "../assets/lit-core-2.7.4.min.js" -import { getOllamaProgressTracker } from "../common/services/localProgressTracker.js" +import { getOllamaProgressTracker } from "../../features/common/services/localProgressTracker.js" export class ApiKeyHeader extends LitElement { //////// after_modelStateService //////// @@ -370,13 +370,12 @@ export class ApiKeyHeader extends LitElement { } async loadProviderConfig() { - if (!window.require) return; - const { ipcRenderer } = window.require('electron'); + if (!window.api) return; try { const [config, ollamaStatus] = await Promise.all([ - ipcRenderer.invoke('model:get-provider-config'), - ipcRenderer.invoke('ollama:get-status') + window.api.apiKeyHeader.getProviderConfig(), + window.api.apiKeyHeader.getOllamaStatus() ]); const llmProviders = []; @@ -428,8 +427,7 @@ export class ApiKeyHeader extends LitElement { e.preventDefault() - const { ipcRenderer } = window.require("electron") - const initialPosition = await ipcRenderer.invoke("get-header-position") + const initialPosition = await window.api.apiKeyHeader.getHeaderPosition() this.dragState = { initialMouseX: e.screenX, @@ -456,8 +454,7 @@ export class ApiKeyHeader extends LitElement { const newWindowX = this.dragState.initialWindowX + (e.screenX - this.dragState.initialMouseX) const newWindowY = this.dragState.initialWindowY + (e.screenY - this.dragState.initialMouseY) - const { ipcRenderer } = window.require("electron") - ipcRenderer.invoke("move-header-to", newWindowX, newWindowY) + window.api.apiKeyHeader.moveHeaderTo(newWindowX, newWindowY) } handleMouseUp(e) { @@ -652,9 +649,8 @@ export class ApiKeyHeader extends LitElement { try { // Lightweight health check - just ping the service const isHealthy = await this._executeOperation('health_check', async () => { - if (!window.require) return false; - const { ipcRenderer } = window.require('electron'); - const result = await ipcRenderer.invoke('ollama:get-status'); + if (!window.api) return false; + const result = await window.api.apiKeyHeader.getOllamaStatus(); return result?.success && result?.running; }, { timeout: 5000, priority: 'low' }); @@ -928,14 +924,13 @@ export class ApiKeyHeader extends LitElement { } async refreshOllamaStatus() { - if (!window.require) return; + if (!window.api) return; try { this._updateConnectionState('connecting', 'Checking Ollama status'); const result = await this._executeOperation('ollama_status', async () => { - const { ipcRenderer } = window.require('electron'); - return await ipcRenderer.invoke('ollama:get-status'); + return await window.api.apiKeyHeader.getOllamaStatus(); }); if (result?.success) { @@ -960,12 +955,11 @@ export class ApiKeyHeader extends LitElement { } async loadModelSuggestions() { - if (!window.require) return; + if (!window.api) return; try { const result = await this._executeOperation('model_suggestions', async () => { - const { ipcRenderer } = window.require('electron'); - return await ipcRenderer.invoke('ollama:get-model-suggestions'); + return await window.api.apiKeyHeader.getModelSuggestions(); }); if (result?.success) { @@ -988,14 +982,13 @@ export class ApiKeyHeader extends LitElement { } async ensureOllamaReady() { - if (!window.require) return false; + if (!window.api) return false; try { this._updateConnectionState('connecting', 'Ensuring Ollama is ready'); const result = await this._executeOperation('ollama_ensure_ready', async () => { - const { ipcRenderer } = window.require('electron'); - return await ipcRenderer.invoke('ollama:ensure-ready'); + return await window.api.apiKeyHeader.ensureOllamaReady(); }, { timeout: this.operationTimeout }); if (result?.success) { @@ -1015,8 +1008,7 @@ export class ApiKeyHeader extends LitElement { } async ensureOllamaReadyWithUI() { - if (!window.require) return false; - const { ipcRenderer } = window.require("electron"); + if (!window.api) return false; this.installingModel = "Setting up Ollama"; this.installProgress = 0; @@ -1074,21 +1066,21 @@ export class ApiKeyHeader extends LitElement { operationCompleted = true; clearTimeout(completionTimeout); - ipcRenderer.removeListener("ollama:install-progress", progressHandler); + window.api.apiKeyHeader.removeOnOllamaInstallProgress(progressHandler); await this._handleOllamaSetupCompletion(result.success, result.error); }; - ipcRenderer.once("ollama:install-complete", completionHandler); - ipcRenderer.on("ollama:install-progress", progressHandler); + window.api.apiKeyHeader.onceOllamaInstallComplete(completionHandler); + window.api.apiKeyHeader.onOllamaInstallProgress(progressHandler); try { let result; if (!this.ollamaStatus.installed) { console.log("[ApiKeyHeader] Ollama not installed. Starting installation."); - result = await ipcRenderer.invoke("ollama:install"); + result = await window.api.apiKeyHeader.installOllama(); } else { console.log("[ApiKeyHeader] Ollama installed. Starting service."); - result = await ipcRenderer.invoke("ollama:start-service"); + result = await window.api.apiKeyHeader.startOllamaService(); } // If IPC call succeeds but no event received, handle completion manually @@ -1106,8 +1098,8 @@ export class ApiKeyHeader extends LitElement { operationCompleted = true; clearTimeout(completionTimeout); console.error("[ApiKeyHeader] Ollama setup failed:", error); - ipcRenderer.removeListener("ollama:install-progress", progressHandler); - ipcRenderer.removeListener("ollama:install-complete", completionHandler); + window.api.apiKeyHeader.removeOnOllamaInstallProgress(progressHandler); + window.api.apiKeyHeader.removeOnceOllamaInstallComplete(completionHandler); await this._handleOllamaSetupCompletion(false, error.message); } } @@ -1229,7 +1221,6 @@ export class ApiKeyHeader extends LitElement { this.clearMessages(); this.requestUpdate(); - const { ipcRenderer } = window.require('electron'); let progressHandler = null; try { @@ -1249,10 +1240,10 @@ export class ApiKeyHeader extends LitElement { }; // Set up progress tracking - ipcRenderer.on('ollama:pull-progress', progressHandler); + window.api.apiKeyHeader.onOllamaPullProgress(progressHandler); // Execute the model pull with timeout - const installPromise = ipcRenderer.invoke('ollama:pull-model', modelName); + const installPromise = window.api.apiKeyHeader.pullOllamaModel(modelName); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Installation timeout after 10 minutes')), 600000) ); @@ -1281,7 +1272,7 @@ export class ApiKeyHeader extends LitElement { } finally { // Comprehensive cleanup if (progressHandler) { - ipcRenderer.removeListener('ollama:pull-progress', progressHandler); + window.api.apiKeyHeader.removeOnOllamaPullProgress(progressHandler); } this.installingModel = null; @@ -1307,7 +1298,6 @@ export class ApiKeyHeader extends LitElement { this.clearMessages(); this.requestUpdate(); - const { ipcRenderer } = window.require('electron'); let progressHandler = null; try { @@ -1321,10 +1311,10 @@ export class ApiKeyHeader extends LitElement { } }; - ipcRenderer.on('whisper:download-progress', progressHandler); + window.api.apiKeyHeader.onWhisperDownloadProgress(progressHandler); // Start download with timeout protection - const downloadPromise = ipcRenderer.invoke('whisper:download-model', modelId); + const downloadPromise = window.api.apiKeyHeader.downloadWhisperModel(modelId); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Download timeout after 10 minutes')), 600000) ); @@ -1351,7 +1341,7 @@ export class ApiKeyHeader extends LitElement { } finally { // Cleanup if (progressHandler) { - ipcRenderer.removeListener('whisper:download-progress', progressHandler); + window.api.apiKeyHeader.removeOnWhisperDownloadProgress(progressHandler); } delete this.whisperInstallingModels[modelId]; this.requestUpdate(); @@ -1411,8 +1401,6 @@ export class ApiKeyHeader extends LitElement { this.isLoading = true; this.clearMessages(); this.requestUpdate(); - - const { ipcRenderer } = window.require('electron'); try { // Handle LLM provider @@ -1436,14 +1424,14 @@ export class ApiKeyHeader extends LitElement { } // Validate Ollama is working - llmResult = await ipcRenderer.invoke('model:validate-key', { + llmResult = await window.api.apiKeyHeader.validateKey({ provider: 'ollama', key: 'local' }); if (llmResult.success) { // Set the selected model - await ipcRenderer.invoke('model:set-selected-model', { + await window.api.apiKeyHeader.setSelectedModel({ type: 'llm', modelId: this.selectedLlmModel }); @@ -1454,7 +1442,7 @@ export class ApiKeyHeader extends LitElement { throw new Error('Please enter LLM API key'); } - llmResult = await ipcRenderer.invoke('model:validate-key', { + llmResult = await window.api.apiKeyHeader.validateKey({ provider: this.llmProvider, key: this.llmApiKey.trim() }); @@ -1467,14 +1455,14 @@ export class ApiKeyHeader extends LitElement { sttResult = { success: true }; } else if (this.sttProvider === 'whisper') { // For Whisper, just validate it's enabled (model download already handled in handleSttModelChange) - sttResult = await ipcRenderer.invoke('model:validate-key', { + sttResult = await window.api.apiKeyHeader.validateKey({ provider: 'whisper', key: 'local' }); if (sttResult.success && this.selectedSttModel) { // Set the selected model - await ipcRenderer.invoke('model:set-selected-model', { + await window.api.apiKeyHeader.setSelectedModel({ type: 'stt', modelId: this.selectedSttModel }); @@ -1485,7 +1473,7 @@ export class ApiKeyHeader extends LitElement { throw new Error('Please enter STT API key'); } - sttResult = await ipcRenderer.invoke('model:validate-key', { + sttResult = await window.api.apiKeyHeader.validateKey({ provider: this.sttProvider, key: this.sttApiKey.trim() }); @@ -1522,15 +1510,15 @@ export class ApiKeyHeader extends LitElement { e.preventDefault() console.log("Requesting Firebase authentication from main process...") - if (window.require) { - window.require("electron").ipcRenderer.invoke("start-firebase-auth") + if (window.api) { + window.api.common.startFirebaseAuth() } } handleClose() { console.log("Close button clicked") - if (window.require) { - window.require("electron").ipcRenderer.invoke("quit-application") + if (window.api) { + window.api.common.quitApplication() } } @@ -1541,10 +1529,10 @@ export class ApiKeyHeader extends LitElement { this.classList.remove("sliding-out"); this.classList.add("hidden"); - console.log('[ApiKeyHeader] handleAnimationEnd: Animation completed, transitioning to next state...'); + console.log('[ApiKeyHeader] handleAnimationEnd: Transition completed, transitioning to next state...'); - if (!window.require) { - console.error('[ApiKeyHeader] handleAnimationEnd: window.require not available'); + if (!window.api) { + console.error('[ApiKeyHeader] handleAnimationEnd: window.api not available'); return; } @@ -1553,14 +1541,12 @@ export class ApiKeyHeader extends LitElement { return; } - const { ipcRenderer } = window.require('electron'); - - ipcRenderer.invoke('get-current-user') + window.api.common.getCurrentUser() .then(userState => { console.log('[ApiKeyHeader] handleAnimationEnd: User state retrieved:', userState); // Additional validation for local providers - return ipcRenderer.invoke('model:are-providers-configured').then(isConfigured => { + return window.api.apiKeyHeader.areProvidersConfigured().then(isConfigured => { console.log('[ApiKeyHeader] handleAnimationEnd: Providers configured check:', isConfigured); if (!isConfigured) { @@ -1585,7 +1571,8 @@ export class ApiKeyHeader extends LitElement { connectedCallback() { super.connectedCallback() - this.addEventListener("animationend", this.handleAnimationEnd) + // this.addEventListener("animationend", this.handleAnimationEnd) + this.addEventListener("transitionend", this.handleAnimationEnd) } handleMessageFadeEnd(e) { @@ -1603,8 +1590,8 @@ export class ApiKeyHeader extends LitElement { disconnectedCallback() { super.disconnectedCallback() - this.removeEventListener("animationend", this.handleAnimationEnd) - + // this.removeEventListener("animationend", this.handleAnimationEnd) + this.removeEventListener("transitionend", this.handleAnimationEnd) // Professional cleanup of all resources this._performCompleteCleanup(); } @@ -1624,12 +1611,8 @@ export class ApiKeyHeader extends LitElement { } // Cleanup event listeners - if (window.require) { - const { ipcRenderer } = window.require('electron'); - ipcRenderer.removeAllListeners('whisper:download-progress'); - ipcRenderer.removeAllListeners('ollama:install-progress'); - ipcRenderer.removeAllListeners('ollama:pull-progress'); - ipcRenderer.removeAllListeners('ollama:install-complete'); + if (window.api) { + window.api.apiKeyHeader.removeAllListeners(); } // Cancel any ongoing downloads diff --git a/src/app/HeaderController.js b/src/ui/app/HeaderController.js similarity index 80% rename from src/app/HeaderController.js rename to src/ui/app/HeaderController.js index e2c5fe8..37c5aa9 100644 --- a/src/app/HeaderController.js +++ b/src/ui/app/HeaderController.js @@ -32,6 +32,7 @@ class HeaderTransitionManager { this.apiKeyHeader = document.createElement('apikey-header'); this.apiKeyHeader.stateUpdateCallback = (userState) => this.handleStateUpdate(userState); this.headerContainer.appendChild(this.apiKeyHeader); + console.log('[HeaderController] ensureHeader: Header of type:', type, 'created.'); } else if (type === 'permission') { this.permissionHeader = document.createElement('permission-setup'); this.permissionHeader.continueCallback = () => this.transitionToMainHeader(); @@ -50,41 +51,39 @@ class HeaderTransitionManager { this._bootstrap(); - if (window.require) { - const { ipcRenderer } = window.require('electron'); - - ipcRenderer.on('user-state-changed', (event, userState) => { + if (window.api) { + window.api.headerController.onUserStateChanged((event, userState) => { console.log('[HeaderController] Received user state change:', userState); this.handleStateUpdate(userState); }); - ipcRenderer.on('auth-failed', (event, { message }) => { + window.api.headerController.onAuthFailed((event, { message }) => { console.error('[HeaderController] Received auth failure from main process:', message); if (this.apiKeyHeader) { this.apiKeyHeader.errorMessage = 'Authentication failed. Please try again.'; this.apiKeyHeader.isLoading = false; } }); - ipcRenderer.on('force-show-apikey-header', async () => { + window.api.headerController.onForceShowApiKeyHeader(async () => { console.log('[HeaderController] Received broadcast to show apikey header. Switching now.'); await this._resizeForApiKey(); this.ensureHeader('apikey'); - }); + }); } } notifyHeaderState(stateOverride) { const state = stateOverride || this.currentHeaderType || 'apikey'; - if (window.require) { - window.require('electron').ipcRenderer.send('header-state-changed', state); + if (window.api) { + window.api.headerController.sendHeaderStateChanged(state); } } async _bootstrap() { // The initial state will be sent by the main process via 'user-state-changed' // We just need to request it. - if (window.require) { - const userState = await window.require('electron').ipcRenderer.invoke('get-current-user'); + if (window.api) { + const userState = await window.api.common.getCurrentUser(); console.log('[HeaderController] Bootstrapping with initial user state:', userState); this.handleStateUpdate(userState); } else { @@ -96,8 +95,7 @@ class HeaderTransitionManager { //////// after_modelStateService //////// async handleStateUpdate(userState) { - const { ipcRenderer } = window.require('electron'); - const isConfigured = await ipcRenderer.invoke('model:are-providers-configured'); + const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured(); if (isConfigured) { const { isLoggedIn } = userState; @@ -126,10 +124,9 @@ class HeaderTransitionManager { } // Check if permissions were previously completed - if (window.require) { - const { ipcRenderer } = window.require('electron'); + if (window.api) { try { - const permissionsCompleted = await ipcRenderer.invoke('check-permissions-completed'); + const permissionsCompleted = await window.api.headerController.checkPermissionsCompleted(); if (permissionsCompleted) { console.log('[HeaderController] Permissions were previously completed, checking current status...'); @@ -161,39 +158,33 @@ class HeaderTransitionManager { this.ensureHeader('main'); } - _resizeForMain() { - if (!window.require) return; - return window - .require('electron') - .ipcRenderer.invoke('resize-header-window', { width: 353, height: 47 }) + async _resizeForMain() { + if (!window.api) return; + console.log('[HeaderController] _resizeForMain: Resizing window to 353x47'); + return window.api.headerController.resizeHeaderWindow({ width: 353, height: 47 }) .catch(() => {}); } async _resizeForApiKey() { - if (!window.require) return; - return window - .require('electron') - .ipcRenderer.invoke('resize-header-window', { width: 350, height: 300 }) + if (!window.api) return; + console.log('[HeaderController] _resizeForApiKey: Resizing window to 350x300'); + return window.api.headerController.resizeHeaderWindow({ width: 350, height: 300 }) .catch(() => {}); } async _resizeForPermissionHeader() { - if (!window.require) return; - return window - .require('electron') - .ipcRenderer.invoke('resize-header-window', { width: 285, height: 220 }) + if (!window.api) return; + return window.api.headerController.resizeHeaderWindow({ width: 285, height: 220 }) .catch(() => {}); } async checkPermissions() { - if (!window.require) { + if (!window.api) { return { success: true }; } - - const { ipcRenderer } = window.require('electron'); try { - const permissions = await ipcRenderer.invoke('check-system-permissions'); + const permissions = await window.api.headerController.checkSystemPermissions(); console.log('[HeaderController] Current permissions:', permissions); if (!permissions.needsSetup) { diff --git a/src/app/MainHeader.js b/src/ui/app/MainHeader.js similarity index 87% rename from src/app/MainHeader.js rename to src/ui/app/MainHeader.js index 3f78f67..1ace9ea 100644 --- a/src/app/MainHeader.js +++ b/src/ui/app/MainHeader.js @@ -4,8 +4,8 @@ export class MainHeader extends LitElement { static properties = { // isSessionActive: { type: Boolean, state: true }, isTogglingSession: { type: Boolean, state: true }, - actionText: { type: String, state: true }, shortcuts: { type: Object, state: true }, + listenSessionStatus: { type: String, state: true }, }; static styles = css` @@ -348,9 +348,8 @@ export class MainHeader extends LitElement { this.isAnimating = false; this.hasSlidIn = false; this.settingsHideTimer = null; - // this.isSessionActive = false; this.isTogglingSession = false; - this.actionText = 'Listen'; + this.listenSessionStatus = 'beforeSession'; this.animationEndTimer = null; this.handleAnimationEnd = this.handleAnimationEnd.bind(this); this.handleMouseMove = this.handleMouseMove.bind(this); @@ -359,11 +358,19 @@ export class MainHeader extends LitElement { this.wasJustDragged = false; } + _getListenButtonText(status) { + switch (status) { + case 'beforeSession': return 'Listen'; + case 'inSession' : return 'Stop'; + case 'afterSession': return 'Done'; + default : return 'Listen'; + } + } + async handleMouseDown(e) { e.preventDefault(); - const { ipcRenderer } = window.require('electron'); - const initialPosition = await ipcRenderer.invoke('get-header-position'); + const initialPosition = await window.api.mainHeader.getHeaderPosition(); this.dragState = { initialMouseX: e.screenX, @@ -390,8 +397,7 @@ export class MainHeader extends LitElement { const newWindowX = this.dragState.initialWindowX + (e.screenX - this.dragState.initialMouseX); const newWindowY = this.dragState.initialWindowY + (e.screenY - this.dragState.initialMouseY); - const { ipcRenderer } = window.require('electron'); - ipcRenderer.invoke('move-header-to', newWindowX, newWindowY); + window.api.mainHeader.moveHeaderTo(newWindowX, newWindowY); } handleMouseUp(e) { @@ -447,12 +453,12 @@ export class MainHeader extends LitElement { if (this.classList.contains('hiding')) { this.classList.add('hidden'); - if (window.require) { - window.require('electron').ipcRenderer.send('header-animation-finished', 'hidden'); + if (window.api) { + window.api.mainHeader.sendHeaderAnimationFinished('hidden'); } } else if (this.classList.contains('showing')) { - if (window.require) { - window.require('electron').ipcRenderer.send('header-animation-finished', 'visible'); + if (window.api) { + window.api.mainHeader.sendHeaderAnimationFinished('visible'); } } } @@ -466,26 +472,27 @@ export class MainHeader extends LitElement { super.connectedCallback(); this.addEventListener('animationend', this.handleAnimationEnd); - if (window.require) { - const { ipcRenderer } = window.require('electron'); + if (window.api) { - this._sessionStateTextListener = (event, text) => { - this.actionText = text; - this.isTogglingSession = false; + this._sessionStateTextListener = (event, { success }) => { + if (success) { + this.listenSessionStatus = ({ + beforeSession: 'inSession', + inSession: 'afterSession', + afterSession: 'beforeSession', + })[this.listenSessionStatus] || 'beforeSession'; + } else { + this.listenSessionStatus = 'beforeSession'; + } + this.isTogglingSession = false; // ✨ 로딩 상태만 해제 }; - ipcRenderer.on('session-state-text', this._sessionStateTextListener); + window.api.mainHeader.onListenChangeSessionResult(this._sessionStateTextListener); - - // this._sessionStateListener = (event, { isActive }) => { - // this.isSessionActive = isActive; - // this.isTogglingSession = false; - // }; - // ipcRenderer.on('session-state-changed', this._sessionStateListener); this._shortcutListener = (event, keybinds) => { console.log('[MainHeader] Received updated shortcuts:', keybinds); this.shortcuts = keybinds; }; - ipcRenderer.on('shortcuts-updated', this._shortcutListener); + window.api.mainHeader.onShortcutsUpdated(this._shortcutListener); } } @@ -498,39 +505,34 @@ export class MainHeader extends LitElement { this.animationEndTimer = null; } - if (window.require) { - const { ipcRenderer } = window.require('electron'); + if (window.api) { if (this._sessionStateTextListener) { - ipcRenderer.removeListener('session-state-text', this._sessionStateTextListener); + window.api.mainHeader.removeOnListenChangeSessionResult(this._sessionStateTextListener); } - // if (this._sessionStateListener) { - // ipcRenderer.removeListener('session-state-changed', this._sessionStateListener); - // } if (this._shortcutListener) { - ipcRenderer.removeListener('shortcuts-updated', this._shortcutListener); + window.api.mainHeader.removeOnShortcutsUpdated(this._shortcutListener); } } } invoke(channel, ...args) { if (this.wasJustDragged) return; - if (window.require) { - window.require('electron').ipcRenderer.invoke(channel, ...args); + if (window.api) { + window.api.mainHeader.invoke(channel, ...args); } // return Promise.resolve(); } showSettingsWindow(element) { if (this.wasJustDragged) return; - if (window.require) { - const { ipcRenderer } = window.require('electron'); + if (window.api) { console.log(`[MainHeader] showSettingsWindow called at ${Date.now()}`); - ipcRenderer.send('cancel-hide-settings-window'); + window.api.mainHeader.cancelHideSettingsWindow(); if (element) { const { left, top, width, height } = element.getBoundingClientRect(); - ipcRenderer.send('show-settings-window', { + window.api.mainHeader.showSettingsWindow({ x: left, y: top, width, @@ -542,9 +544,9 @@ export class MainHeader extends LitElement { hideSettingsWindow() { if (this.wasJustDragged) return; - if (window.require) { + if (window.api) { console.log(`[MainHeader] hideSettingsWindow called at ${Date.now()}`); - window.require('electron').ipcRenderer.send('hide-settings-window'); + window.api.mainHeader.hideSettingsWindow(); } } @@ -557,15 +559,26 @@ export class MainHeader extends LitElement { this.isTogglingSession = true; try { - const channel = 'toggle-feature'; - const args = ['listen']; - await this.invoke(channel, ...args); + const channel = 'listen:changeSession'; + const listenButtonText = this._getListenButtonText(this.listenSessionStatus); + await this.invoke(channel, listenButtonText); } catch (error) { - console.error('IPC invoke for session toggle failed:', error); + console.error('IPC invoke for session change failed:', error); this.isTogglingSession = false; } } + async _handleAskClick() { + if (this.wasJustDragged) return; + + try { + const channel = 'ask:toggleAskButton'; + await this.invoke(channel); + } catch (error) { + console.error('IPC invoke for ask button failed:', error); + } + } + renderShortcut(accelerator) { if (!accelerator) return html``; @@ -591,11 +604,13 @@ export class MainHeader extends LitElement { } render() { + const listenButtonText = this._getListenButtonText(this.listenSessionStatus); + const buttonClasses = { - active: this.actionText === 'Stop', - done: this.actionText === 'Done', + active: listenButtonText === 'Stop', + done: listenButtonText === 'Done', }; - const showStopIcon = this.actionText === 'Stop' || this.actionText === 'Done'; + const showStopIcon = listenButtonText === 'Stop' || listenButtonText === 'Done'; return html`
@@ -612,7 +627,7 @@ export class MainHeader extends LitElement { ` : html`
-
${this.actionText}
+
${listenButtonText}
${showStopIcon @@ -632,7 +647,7 @@ export class MainHeader extends LitElement { `} -
this.invoke('toggle-feature', 'ask')}> +
this._handleAskClick()}>
Ask
diff --git a/src/app/PermissionHeader.js b/src/ui/app/PermissionHeader.js similarity index 93% rename from src/app/PermissionHeader.js rename to src/ui/app/PermissionHeader.js index 96a01c1..d5761b1 100644 --- a/src/app/PermissionHeader.js +++ b/src/ui/app/PermissionHeader.js @@ -288,13 +288,12 @@ export class PermissionHeader extends LitElement { } async checkPermissions() { - if (!window.require || this.isChecking) return; + if (!window.api || this.isChecking) return; this.isChecking = true; - const { ipcRenderer } = window.require('electron'); try { - const permissions = await ipcRenderer.invoke('check-system-permissions'); + const permissions = await window.api.permissionHeader.checkSystemPermissions(); console.log('[PermissionHeader] Permission check result:', permissions); const prevMic = this.microphoneGranted; @@ -324,13 +323,12 @@ export class PermissionHeader extends LitElement { } async handleMicrophoneClick() { - if (!window.require || this.microphoneGranted === 'granted') return; + if (!window.api || this.microphoneGranted === 'granted') return; console.log('[PermissionHeader] Requesting microphone permission...'); - const { ipcRenderer } = window.require('electron'); try { - const result = await ipcRenderer.invoke('check-system-permissions'); + const result = await window.api.permissionHeader.checkSystemPermissions(); console.log('[PermissionHeader] Microphone permission result:', result); if (result.microphone === 'granted') { @@ -340,7 +338,7 @@ export class PermissionHeader extends LitElement { } if (result.microphone === 'not-determined' || result.microphone === 'denied' || result.microphone === 'unknown' || result.microphone === 'restricted') { - const res = await ipcRenderer.invoke('request-microphone-permission'); + const res = await window.api.permissionHeader.requestMicrophonePermission(); if (res.status === 'granted' || res.success === true) { this.microphoneGranted = 'granted'; this.requestUpdate(); @@ -357,13 +355,12 @@ export class PermissionHeader extends LitElement { } async handleScreenClick() { - if (!window.require || this.screenGranted === 'granted') return; + if (!window.api || this.screenGranted === 'granted') return; console.log('[PermissionHeader] Checking screen recording permission...'); - const { ipcRenderer } = window.require('electron'); try { - const permissions = await ipcRenderer.invoke('check-system-permissions'); + const permissions = await window.api.permissionHeader.checkSystemPermissions(); console.log('[PermissionHeader] Screen permission check result:', permissions); if (permissions.screen === 'granted') { @@ -373,7 +370,7 @@ export class PermissionHeader extends LitElement { } if (permissions.screen === 'not-determined' || permissions.screen === 'denied' || permissions.screen === 'unknown' || permissions.screen === 'restricted') { console.log('[PermissionHeader] Opening screen recording preferences...'); - await ipcRenderer.invoke('open-system-preferences', 'screen-recording'); + await window.api.permissionHeader.openSystemPreferences('screen-recording'); } // Check permissions again after a delay @@ -389,10 +386,9 @@ export class PermissionHeader extends LitElement { this.microphoneGranted === 'granted' && this.screenGranted === 'granted') { // Mark permissions as completed - if (window.require) { - const { ipcRenderer } = window.require('electron'); + if (window.api) { try { - await ipcRenderer.invoke('mark-permissions-completed'); + await window.api.permissionHeader.markPermissionsCompleted(); console.log('[PermissionHeader] Marked permissions as completed'); } catch (error) { console.error('[PermissionHeader] Error marking permissions as completed:', error); @@ -405,8 +401,8 @@ export class PermissionHeader extends LitElement { handleClose() { console.log('Close button clicked'); - if (window.require) { - window.require('electron').ipcRenderer.invoke('quit-application'); + if (window.api) { + window.api.common.quitApplication(); } } diff --git a/src/app/PickleGlassApp.js b/src/ui/app/PickleGlassApp.js similarity index 67% rename from src/app/PickleGlassApp.js rename to src/ui/app/PickleGlassApp.js index b5aa9d0..df920c3 100644 --- a/src/app/PickleGlassApp.js +++ b/src/ui/app/PickleGlassApp.js @@ -1,10 +1,10 @@ import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js'; -import { SettingsView } from '../features/settings/SettingsView.js'; -import { AssistantView } from '../features/listen/AssistantView.js'; -import { AskView } from '../features/ask/AskView.js'; -import { ShortcutSettingsView } from '../features/settings/ShortCutSettingsView.js'; +import { SettingsView } from '../settings/SettingsView.js'; +import { ListenView } from '../listen/ListenView.js'; +import { AskView } from '../ask/AskView.js'; +import { ShortcutSettingsView } from '../settings/ShortCutSettingsView.js'; -import '../features/listen/renderer/renderer.js'; +import '../listen/audioCore/renderer.js'; export class PickleGlassApp extends LitElement { static styles = css` @@ -17,7 +17,7 @@ export class PickleGlassApp extends LitElement { border-radius: 7px; } - assistant-view { + listen-view { display: block; width: 100%; height: 100%; @@ -74,33 +74,21 @@ export class PickleGlassApp extends LitElement { connectedCallback() { super.connectedCallback(); - if (window.require) { - const { ipcRenderer } = window.require('electron'); - - ipcRenderer.on('click-through-toggled', (_, isEnabled) => { + if (window.api) { + window.api.pickleGlassApp.onClickThroughToggled((_, isEnabled) => { this._isClickThrough = isEnabled; }); - // ipcRenderer.on('start-listening-session', () => { - // console.log('Received start-listening-session command, calling handleListenClick.'); - // this.handleListenClick(); - // }); } } disconnectedCallback() { super.disconnectedCallback(); - if (window.require) { - const { ipcRenderer } = window.require('electron'); - ipcRenderer.removeAllListeners('click-through-toggled'); - // ipcRenderer.removeAllListeners('start-listening-session'); + if (window.api) { + window.api.pickleGlassApp.removeAllClickThroughListeners(); } } updated(changedProperties) { - // if (changedProperties.has('isMainViewVisible') || changedProperties.has('currentView')) { - // this.requestWindowResize(); - // } - if (changedProperties.has('currentView')) { const viewContainer = this.shadowRoot?.querySelector('.view-container'); if (viewContainer) { @@ -129,40 +117,9 @@ export class PickleGlassApp extends LitElement { } } - - // async handleListenClick() { - // if (window.require) { - // const { ipcRenderer } = window.require('electron'); - // const isActive = await ipcRenderer.invoke('is-session-active'); - // // if (isActive) { - // // console.log('Session is already active. No action needed.'); - // // return; - // // } - // } - - // if (window.pickleGlass) { - // // await window.pickleGlass.initializeopenai(this.selectedProfile, this.selectedLanguage); - // window.pickleGlass.startCapture(this.selectedScreenshotInterval, this.selectedImageQuality); - // } - - // // 🔄 Clear previous summary/analysis when a new listening session begins - // this.structuredData = { - // summary: [], - // topic: { header: '', bullets: [] }, - // actions: [], - // followUps: [], - // }; - - // this.currentResponseIndex = -1; - // this.startTime = Date.now(); - // this.currentView = 'listen'; - // this.isMainViewVisible = true; - // } - async handleClose() { - if (window.require) { - const { ipcRenderer } = window.require('electron'); - await ipcRenderer.invoke('quit-application'); + if (window.api) { + await window.api.common.quitApplication(); } } @@ -172,12 +129,12 @@ export class PickleGlassApp extends LitElement { render() { switch (this.currentView) { case 'listen': - return html` (this.currentResponseIndex = e.detail.index)} - >`; + >`; case 'ask': return html``; case 'settings': diff --git a/src/app/content.html b/src/ui/app/content.html similarity index 94% rename from src/app/content.html rename to src/ui/app/content.html index 9615f39..8757011 100644 --- a/src/app/content.html +++ b/src/ui/app/content.html @@ -1,7 +1,7 @@ - + Pickle Glass Content