Compare commits

..

No commits in common. "main" and "refactor/localmodel" have entirely different histories.

34 changed files with 1877 additions and 1367 deletions

View File

@ -11,7 +11,6 @@ const localAIManager = require('../features/common/services/localAIManager');
const askService = require('../features/ask/askService'); const askService = require('../features/ask/askService');
const listenService = require('../features/listen/listenService'); const listenService = require('../features/listen/listenService');
const permissionService = require('../features/common/services/permissionService'); const permissionService = require('../features/common/services/permissionService');
const encryptionService = require('../features/common/services/encryptionService');
module.exports = { module.exports = {
// Renderer로부터의 요청을 수신하고 서비스로 전달 // Renderer로부터의 요청을 수신하고 서비스로 전달
@ -21,6 +20,7 @@ module.exports = {
ipcMain.handle('settings:get-auto-update', async () => await settingsService.getAutoUpdateSetting()); 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:set-auto-update', async (event, isEnabled) => await settingsService.setAutoUpdateSetting(isEnabled));
ipcMain.handle('settings:get-model-settings', async () => await settingsService.getModelSettings()); 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: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:set-selected-model', async (e, { type, modelId }) => await settingsService.setSelectedModel(type, modelId));
@ -40,13 +40,10 @@ module.exports = {
ipcMain.handle('check-system-permissions', async () => await permissionService.checkSystemPermissions()); ipcMain.handle('check-system-permissions', async () => await permissionService.checkSystemPermissions());
ipcMain.handle('request-microphone-permission', async () => await permissionService.requestMicrophonePermission()); ipcMain.handle('request-microphone-permission', async () => await permissionService.requestMicrophonePermission());
ipcMain.handle('open-system-preferences', async (event, section) => await permissionService.openSystemPreferences(section)); ipcMain.handle('open-system-preferences', async (event, section) => await permissionService.openSystemPreferences(section));
ipcMain.handle('mark-keychain-completed', async () => await permissionService.markKeychainCompleted());
ipcMain.handle('check-keychain-completed', async () => await permissionService.checkKeychainCompleted()); //TODO: Need to Remove this
ipcMain.handle('initialize-encryption-key', async () => { ipcMain.handle('mark-permissions-completed', async () => await permissionService.markPermissionsAsCompleted());
const userId = authService.getCurrentUserId(); ipcMain.handle('check-permissions-completed', async () => await permissionService.checkPermissionsCompleted());
await encryptionService.initializeKey(userId);
return { success: true };
});
// User/Auth // User/Auth
ipcMain.handle('get-current-user', () => authService.getCurrentUser()); ipcMain.handle('get-current-user', () => authService.getCurrentUser());
@ -96,7 +93,6 @@ module.exports = {
ipcMain.handle('listen:startMacosSystemAudio', async () => await listenService.handleStartMacosAudio()); ipcMain.handle('listen:startMacosSystemAudio', async () => await listenService.handleStartMacosAudio());
ipcMain.handle('listen:stopMacosSystemAudio', async () => await listenService.handleStopMacosAudio()); ipcMain.handle('listen:stopMacosSystemAudio', async () => await listenService.handleStopMacosAudio());
ipcMain.handle('update-google-search-setting', async (event, enabled) => await listenService.handleUpdateGoogleSearchSetting(enabled)); ipcMain.handle('update-google-search-setting', async (event, enabled) => await listenService.handleUpdateGoogleSearchSetting(enabled));
ipcMain.handle('listen:isSessionActive', async () => await listenService.isSessionActive());
ipcMain.handle('listen:changeSession', async (event, listenButtonText) => { ipcMain.handle('listen:changeSession', async (event, listenButtonText) => {
console.log('[FeatureBridge] listen:changeSession from mainheader', listenButtonText); console.log('[FeatureBridge] listen:changeSession from mainheader', listenButtonText);
try { try {
@ -110,15 +106,14 @@ module.exports = {
// ModelStateService // ModelStateService
ipcMain.handle('model:validate-key', async (e, { provider, key }) => await modelStateService.handleValidateKey(provider, key)); ipcMain.handle('model:validate-key', async (e, { provider, key }) => await modelStateService.handleValidateKey(provider, key));
ipcMain.handle('model:get-all-keys', async () => await modelStateService.getAllApiKeys()); 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: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:remove-api-key', async (e, provider) => await modelStateService.handleRemoveApiKey(provider));
ipcMain.handle('model:get-selected-models', async () => await modelStateService.getSelectedModels()); 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:set-selected-model', async (e, { type, modelId }) => await modelStateService.handleSetSelectedModel(type, modelId));
ipcMain.handle('model:get-available-models', async (e, { type }) => await modelStateService.getAvailableModels(type)); ipcMain.handle('model:get-available-models', (e, { type }) => modelStateService.getAvailableModels(type));
ipcMain.handle('model:are-providers-configured', async () => await modelStateService.areProvidersConfigured()); ipcMain.handle('model:are-providers-configured', () => modelStateService.areProvidersConfigured());
ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig()); ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig());
ipcMain.handle('model:re-initialize-state', async () => await modelStateService.initialize());
// LocalAIManager 이벤트를 모든 윈도우에 브로드캐스트 // LocalAIManager 이벤트를 모든 윈도우에 브로드캐스트
localAIManager.on('install-progress', (service, data) => { localAIManager.on('install-progress', (service, data) => {

View File

@ -24,8 +24,9 @@ module.exports = {
ipcMain.on('header-state-changed', (event, state) => windowManager.handleHeaderStateChanged(state)); ipcMain.on('header-state-changed', (event, state) => windowManager.handleHeaderStateChanged(state));
ipcMain.on('header-animation-finished', (event, state) => windowManager.handleHeaderAnimationFinished(state)); ipcMain.on('header-animation-finished', (event, state) => windowManager.handleHeaderAnimationFinished(state));
ipcMain.handle('get-header-position', () => windowManager.getHeaderPosition()); 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('move-header-to', (event, newX, newY) => windowManager.moveHeaderTo(newX, newY));
ipcMain.handle('adjust-window-height', (event, { winName, height }) => windowManager.adjustWindowHeight(winName, height)); ipcMain.handle('adjust-window-height', (event, targetHeight) => windowManager.adjustWindowHeight(event.sender, targetHeight));
}, },
notifyFocusChange(win, isFocused) { notifyFocusChange(win, isFocused) {

View File

@ -243,7 +243,7 @@ class AskService {
await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() }); await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() });
console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`); console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`);
const modelInfo = await modelStateService.getCurrentModelInfo('llm'); const modelInfo = modelStateService.getCurrentModelInfo('llm');
if (!modelInfo || !modelInfo.apiKey) { if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key not configured.'); throw new Error('AI model or API key not configured.');
} }

View File

@ -91,6 +91,7 @@ const LATEST_SCHEMA = {
}, },
provider_settings: { provider_settings: {
columns: [ columns: [
{ name: 'uid', type: 'TEXT NOT NULL' },
{ name: 'provider', type: 'TEXT NOT NULL' }, { name: 'provider', type: 'TEXT NOT NULL' },
{ name: 'api_key', type: 'TEXT' }, { name: 'api_key', type: 'TEXT' },
{ name: 'selected_llm_model', type: 'TEXT' }, { name: 'selected_llm_model', type: 'TEXT' },
@ -100,7 +101,7 @@ const LATEST_SCHEMA = {
{ name: 'created_at', type: 'INTEGER' }, { name: 'created_at', type: 'INTEGER' },
{ name: 'updated_at', type: 'INTEGER' } { name: 'updated_at', type: 'INTEGER' }
], ],
constraints: ['PRIMARY KEY (provider)'] constraints: ['PRIMARY KEY (uid, provider)']
}, },
shortcuts: { shortcuts: {
columns: [ columns: [
@ -108,12 +109,6 @@ const LATEST_SCHEMA = {
{ name: 'accelerator', type: 'TEXT NOT NULL' }, { name: 'accelerator', type: 'TEXT NOT NULL' },
{ name: 'created_at', type: 'INTEGER' } { name: 'created_at', type: 'INTEGER' }
] ]
},
permissions: {
columns: [
{ name: 'uid', type: 'TEXT PRIMARY KEY' },
{ name: 'keychain_completed', type: 'INTEGER DEFAULT 0' }
]
} }
}; };

View File

@ -33,13 +33,7 @@ function createEncryptedConverter(fieldsToEncrypt = []) {
for (const field of fieldsToEncrypt) { for (const field of fieldsToEncrypt) {
if (Object.prototype.hasOwnProperty.call(appObject, field) && appObject[field] != null) { if (Object.prototype.hasOwnProperty.call(appObject, field) && appObject[field] != null) {
try { appObject[field] = encryptionService.decrypt(appObject[field]);
appObject[field] = encryptionService.decrypt(appObject[field]);
} catch (error) {
console.warn(`[FirestoreConverter] Failed to decrypt field '${field}' (possibly plaintext or key mismatch):`, error.message);
// Keep the original value instead of failing
// appObject[field] remains as is
}
} }
} }

View File

@ -6,6 +6,6 @@ function getRepository() {
} }
module.exports = { module.exports = {
markKeychainCompleted: (...args) => getRepository().markKeychainCompleted(...args), markPermissionsAsCompleted: (...args) => getRepository().markPermissionsAsCompleted(...args),
checkKeychainCompleted: (...args) => getRepository().checkKeychainCompleted(...args), checkPermissionsCompleted: (...args) => getRepository().checkPermissionsCompleted(...args),
}; };

View File

@ -1,18 +1,14 @@
const sqliteClient = require('../../services/sqliteClient'); const sqliteClient = require('../../services/sqliteClient');
function markKeychainCompleted(uid) { async function markPermissionsAsCompleted() {
return sqliteClient.query( return sqliteClient.markPermissionsAsCompleted();
'INSERT OR REPLACE INTO permissions (uid, keychain_completed) VALUES (?, 1)',
[uid]
);
} }
function checkKeychainCompleted(uid) { async function checkPermissionsCompleted() {
const row = sqliteClient.query('SELECT keychain_completed FROM permissions WHERE uid = ?', [uid]); return sqliteClient.checkPermissionsCompleted();
return row.length > 0 && row[0].keychain_completed === 1;
} }
module.exports = { module.exports = {
markKeychainCompleted, markPermissionsAsCompleted,
checkKeychainCompleted checkPermissionsCompleted,
}; };

View File

@ -0,0 +1,161 @@
const { collection, doc, getDoc, getDocs, setDoc, deleteDoc, query, where } = require('firebase/firestore');
const { getFirestoreInstance: getFirestore } = require('../../services/firebaseClient');
const { createEncryptedConverter } = require('../firestoreConverter');
// Create encrypted converter for provider settings
const providerSettingsConverter = createEncryptedConverter([
'api_key', // Encrypt API keys
'selected_llm_model', // Encrypt model selections for privacy
'selected_stt_model'
]);
function providerSettingsCol() {
const db = getFirestore();
return collection(db, 'provider_settings').withConverter(providerSettingsConverter);
}
async function getByProvider(uid, provider) {
try {
const docRef = doc(providerSettingsCol(), `${uid}_${provider}`);
const docSnap = await getDoc(docRef);
return docSnap.exists() ? { id: docSnap.id, ...docSnap.data() } : null;
} catch (error) {
console.error('[ProviderSettings Firebase] Error getting provider settings:', error);
return null;
}
}
async function getAllByUid(uid) {
try {
const q = query(providerSettingsCol(), where('uid', '==', uid));
const querySnapshot = await getDocs(q);
return querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
} catch (error) {
console.error('[ProviderSettings Firebase] Error getting all provider settings:', error);
return [];
}
}
async function upsert(uid, provider, settings) {
try {
const docRef = doc(providerSettingsCol(), `${uid}_${provider}`);
await setDoc(docRef, settings, { merge: true });
return { changes: 1 };
} catch (error) {
console.error('[ProviderSettings Firebase] Error upserting provider settings:', error);
throw error;
}
}
async function remove(uid, provider) {
try {
const docRef = doc(providerSettingsCol(), `${uid}_${provider}`);
await deleteDoc(docRef);
return { changes: 1 };
} catch (error) {
console.error('[ProviderSettings Firebase] Error removing provider settings:', error);
throw error;
}
}
async function removeAllByUid(uid) {
try {
const settings = await getAllByUid(uid);
const deletePromises = settings.map(setting => {
const docRef = doc(providerSettingsCol(), setting.id);
return deleteDoc(docRef);
});
await Promise.all(deletePromises);
return { changes: settings.length };
} catch (error) {
console.error('[ProviderSettings Firebase] Error removing all provider settings:', error);
throw error;
}
}
// Get active provider for a specific type (llm or stt)
async function getActiveProvider(uid, type) {
try {
const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt';
const q = query(providerSettingsCol(),
where('uid', '==', uid),
where(column, '==', true)
);
const querySnapshot = await getDocs(q);
if (querySnapshot.empty) {
return null;
}
const doc = querySnapshot.docs[0];
return { id: doc.id, ...doc.data() };
} catch (error) {
console.error('[ProviderSettings Firebase] Error getting active provider:', error);
return null;
}
}
// Set active provider for a specific type
async function setActiveProvider(uid, provider, type) {
try {
const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt';
// First, deactivate all providers for this type
const allSettings = await getAllByUid(uid);
const updatePromises = allSettings.map(setting => {
const docRef = doc(providerSettingsCol(), setting.id);
return setDoc(docRef, { [column]: false }, { merge: true });
});
await Promise.all(updatePromises);
// Then activate the specified provider
if (provider) {
const docRef = doc(providerSettingsCol(), `${uid}_${provider}`);
await setDoc(docRef, { [column]: true }, { merge: true });
}
return { success: true };
} catch (error) {
console.error('[ProviderSettings Firebase] Error setting active provider:', error);
throw error;
}
}
// Get all active settings (both llm and stt)
async function getActiveSettings(uid) {
try {
// Firebase doesn't support OR queries in this way, so we'll get all settings and filter
const allSettings = await getAllByUid(uid);
const activeSettings = {
llm: null,
stt: null
};
allSettings.forEach(setting => {
if (setting.is_active_llm) {
activeSettings.llm = setting;
}
if (setting.is_active_stt) {
activeSettings.stt = setting;
}
});
return activeSettings;
} catch (error) {
console.error('[ProviderSettings Firebase] Error getting active settings:', error);
return { llm: null, stt: null };
}
}
module.exports = {
getByProvider,
getAllByUid,
upsert,
remove,
removeAllByUid,
getActiveProvider,
setActiveProvider,
getActiveSettings
};

View File

@ -1,68 +1,83 @@
const firebaseRepository = require('./firebase.repository');
const sqliteRepository = require('./sqlite.repository'); const sqliteRepository = require('./sqlite.repository');
let authService = null;
function setAuthService(service) {
authService = service;
}
function getBaseRepository() { function getBaseRepository() {
// For now, we only have sqlite. This could be expanded later. if (!authService) {
return sqliteRepository; throw new Error('AuthService not set for providerSettings repository');
}
const user = authService.getCurrentUser();
return user.isLoggedIn ? firebaseRepository : sqliteRepository;
} }
const providerSettingsRepositoryAdapter = { const providerSettingsRepositoryAdapter = {
// Core CRUD operations // Core CRUD operations
async getByProvider(provider) { async getByProvider(provider) {
const repo = getBaseRepository(); const repo = getBaseRepository();
return await repo.getByProvider(provider); const uid = authService.getCurrentUserId();
return await repo.getByProvider(uid, provider);
}, },
async getAll() { async getAllByUid() {
const repo = getBaseRepository(); const repo = getBaseRepository();
return await repo.getAll(); const uid = authService.getCurrentUserId();
return await repo.getAllByUid(uid);
}, },
async upsert(provider, settings) { async upsert(provider, settings) {
const repo = getBaseRepository(); const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
const now = Date.now(); const now = Date.now();
const settingsWithMeta = { const settingsWithMeta = {
...settings, ...settings,
uid,
provider, provider,
updated_at: now, updated_at: now,
created_at: settings.created_at || now created_at: settings.created_at || now
}; };
return await repo.upsert(provider, settingsWithMeta); return await repo.upsert(uid, provider, settingsWithMeta);
}, },
async remove(provider) { async remove(provider) {
const repo = getBaseRepository(); const repo = getBaseRepository();
return await repo.remove(provider); const uid = authService.getCurrentUserId();
return await repo.remove(uid, provider);
}, },
async removeAll() { async removeAllByUid() {
const repo = getBaseRepository(); const repo = getBaseRepository();
return await repo.removeAll(); const uid = authService.getCurrentUserId();
}, return await repo.removeAllByUid(uid);
async getRawApiKeys() {
// This function should always target the local sqlite DB,
// as it's part of the local-first boot sequence.
return await sqliteRepository.getRawApiKeys();
}, },
async getActiveProvider(type) { async getActiveProvider(type) {
const repo = getBaseRepository(); const repo = getBaseRepository();
return await repo.getActiveProvider(type); const uid = authService.getCurrentUserId();
return await repo.getActiveProvider(uid, type);
}, },
async setActiveProvider(provider, type) { async setActiveProvider(provider, type) {
const repo = getBaseRepository(); const repo = getBaseRepository();
return await repo.setActiveProvider(provider, type); const uid = authService.getCurrentUserId();
return await repo.setActiveProvider(uid, provider, type);
}, },
async getActiveSettings() { async getActiveSettings() {
const repo = getBaseRepository(); const repo = getBaseRepository();
return await repo.getActiveSettings(); const uid = authService.getCurrentUserId();
return await repo.getActiveSettings(uid);
} }
}; };
module.exports = { module.exports = {
...providerSettingsRepositoryAdapter ...providerSettingsRepositoryAdapter,
setAuthService
}; };

View File

@ -1,32 +1,34 @@
const sqliteClient = require('../../services/sqliteClient'); const sqliteClient = require('../../services/sqliteClient');
const encryptionService = require('../../services/encryptionService');
function getByProvider(provider) { function getByProvider(uid, provider) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT * FROM provider_settings WHERE provider = ?'); const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? AND provider = ?');
const result = stmt.get(provider) || null; const result = stmt.get(uid, provider) || null;
if (result && result.api_key && encryptionService.looksEncrypted(result.api_key)) { // if (result && result.api_key) {
result.api_key = encryptionService.decrypt(result.api_key); // // Decrypt API key if it exists
} // result.api_key = result.api_key;
// }
return result; return result;
} }
function getAll() { function getAllByUid(uid) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT * FROM provider_settings ORDER BY provider'); const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? ORDER BY provider');
const results = stmt.all(); const results = stmt.all(uid);
// Decrypt API keys for all results
return results.map(result => { return results.map(result => {
if (result.api_key && encryptionService.looksEncrypted(result.api_key)) { if (result.api_key) {
result.api_key = encryptionService.decrypt(result.api_key); result.api_key = result.api_key;
} }
return result; return result;
}); });
} }
function upsert(provider, settings) { function upsert(uid, provider, settings) {
// Validate: prevent direct setting of active status // Validate: prevent direct setting of active status
if (settings.is_active_llm || settings.is_active_stt) { if (settings.is_active_llm || settings.is_active_stt) {
console.warn('[ProviderSettings] Warning: is_active_llm/is_active_stt should not be set directly. Use setActiveProvider() instead.'); console.warn('[ProviderSettings] Warning: is_active_llm/is_active_stt should not be set directly. Use setActiveProvider() instead.');
@ -34,11 +36,17 @@ function upsert(provider, settings) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
// Encrypt API key if it exists
// const encryptedSettings = { ...settings };
// if (encryptedSettings.api_key) {
// encryptedSettings.api_key = encryptedSettings.api_key;
// }
// Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE) // Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE)
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO provider_settings (provider, api_key, selected_llm_model, selected_stt_model, is_active_llm, is_active_stt, created_at, updated_at) INSERT INTO provider_settings (uid, provider, api_key, selected_llm_model, selected_stt_model, is_active_llm, is_active_stt, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(provider) DO UPDATE SET ON CONFLICT(uid, provider) DO UPDATE SET
api_key = excluded.api_key, api_key = excluded.api_key,
selected_llm_model = excluded.selected_llm_model, selected_llm_model = excluded.selected_llm_model,
selected_stt_model = excluded.selected_stt_model, selected_stt_model = excluded.selected_stt_model,
@ -48,6 +56,7 @@ function upsert(provider, settings) {
`); `);
const result = stmt.run( const result = stmt.run(
uid,
provider, provider,
settings.api_key || null, settings.api_key || null,
settings.selected_llm_model || null, settings.selected_llm_model || null,
@ -61,55 +70,49 @@ function upsert(provider, settings) {
return { changes: result.changes }; return { changes: result.changes };
} }
function remove(provider) { function remove(uid, provider) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const stmt = db.prepare('DELETE FROM provider_settings WHERE provider = ?'); const stmt = db.prepare('DELETE FROM provider_settings WHERE uid = ? AND provider = ?');
const result = stmt.run(provider); const result = stmt.run(uid, provider);
return { changes: result.changes }; return { changes: result.changes };
} }
function removeAll() { function removeAllByUid(uid) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const stmt = db.prepare('DELETE FROM provider_settings'); const stmt = db.prepare('DELETE FROM provider_settings WHERE uid = ?');
const result = stmt.run(); const result = stmt.run(uid);
return { changes: result.changes }; return { changes: result.changes };
} }
function getRawApiKeys() {
const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT api_key FROM provider_settings');
return stmt.all();
}
// Get active provider for a specific type (llm or stt) // Get active provider for a specific type (llm or stt)
function getActiveProvider(type) { function getActiveProvider(uid, type) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt'; const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt';
const stmt = db.prepare(`SELECT * FROM provider_settings WHERE ${column} = 1`); const stmt = db.prepare(`SELECT * FROM provider_settings WHERE uid = ? AND ${column} = 1`);
const result = stmt.get() || null; const result = stmt.get(uid) || null;
if (result && result.api_key && encryptionService.looksEncrypted(result.api_key)) { if (result && result.api_key) {
result.api_key = encryptionService.decrypt(result.api_key); result.api_key = result.api_key;
} }
return result; return result;
} }
// Set active provider for a specific type // Set active provider for a specific type
function setActiveProvider(provider, type) { function setActiveProvider(uid, provider, type) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt'; const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt';
// Start transaction to ensure only one provider is active // Start transaction to ensure only one provider is active
db.transaction(() => { db.transaction(() => {
// First, deactivate all providers for this type // First, deactivate all providers for this type
const deactivateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 0`); const deactivateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 0 WHERE uid = ?`);
deactivateStmt.run(); deactivateStmt.run(uid);
// Then activate the specified provider // Then activate the specified provider
if (provider) { if (provider) {
const activateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 1 WHERE provider = ?`); const activateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 1 WHERE uid = ? AND provider = ?`);
activateStmt.run(provider); activateStmt.run(uid, provider);
} }
})(); })();
@ -117,14 +120,14 @@ function setActiveProvider(provider, type) {
} }
// Get all active settings (both llm and stt) // Get all active settings (both llm and stt)
function getActiveSettings() { function getActiveSettings(uid) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const stmt = db.prepare(` const stmt = db.prepare(`
SELECT * FROM provider_settings SELECT * FROM provider_settings
WHERE (is_active_llm = 1 OR is_active_stt = 1) WHERE uid = ? AND (is_active_llm = 1 OR is_active_stt = 1)
ORDER BY provider ORDER BY provider
`); `);
const results = stmt.all(); const results = stmt.all(uid);
// Decrypt API keys and organize by type // Decrypt API keys and organize by type
const activeSettings = { const activeSettings = {
@ -133,8 +136,8 @@ function getActiveSettings() {
}; };
results.forEach(result => { results.forEach(result => {
if (result.api_key && encryptionService.looksEncrypted(result.api_key)) { if (result.api_key) {
result.api_key = encryptionService.decrypt(result.api_key); result.api_key = result.api_key;
} }
if (result.is_active_llm) { if (result.is_active_llm) {
activeSettings.llm = result; activeSettings.llm = result;
@ -149,11 +152,10 @@ function getActiveSettings() {
module.exports = { module.exports = {
getByProvider, getByProvider,
getAll, getAllByUid,
upsert, upsert,
remove, remove,
removeAll, removeAllByUid,
getRawApiKeys,
getActiveProvider, getActiveProvider,
setActiveProvider, setActiveProvider,
getActiveSettings getActiveSettings

View File

@ -6,7 +6,6 @@ const encryptionService = require('./encryptionService');
const migrationService = require('./migrationService'); const migrationService = require('./migrationService');
const sessionRepository = require('../repositories/session'); const sessionRepository = require('../repositories/session');
const providerSettingsRepository = require('../repositories/providerSettings'); const providerSettingsRepository = require('../repositories/providerSettings');
const permissionService = require('./permissionService');
async function getVirtualKeyByEmail(email, idToken) { async function getVirtualKeyByEmail(email, idToken) {
if (!idToken) { if (!idToken) {
@ -43,9 +42,11 @@ class AuthService {
this.isInitialized = false; this.isInitialized = false;
// This ensures the key is ready before any login/logout state change. // This ensures the key is ready before any login/logout state change.
encryptionService.initializeKey(this.currentUserId);
this.initializationPromise = null; this.initializationPromise = null;
sessionRepository.setAuthService(this); sessionRepository.setAuthService(this);
providerSettingsRepository.setAuthService(this);
} }
initialize() { initialize() {
@ -66,32 +67,29 @@ class AuthService {
// Clean up any zombie sessions from a previous run for this user. // Clean up any zombie sessions from a previous run for this user.
await sessionRepository.endAllActiveSessions(); await sessionRepository.endAllActiveSessions();
// ** Initialize encryption key for the logged-in user if permissions are already granted ** // ** Initialize encryption key for the logged-in user **
if (process.platform === 'darwin' && !(await permissionService.checkKeychainCompleted(this.currentUserId))) { await encryptionService.initializeKey(user.uid);
console.warn('[AuthService] Keychain permission not yet completed for this user. Deferring key initialization.');
} else {
await encryptionService.initializeKey(user.uid);
}
// ** Check for and run data migration for the user ** // ** Check for and run data migration for the user **
// No 'await' here, so it runs in the background without blocking startup. // No 'await' here, so it runs in the background without blocking startup.
migrationService.checkAndRunMigration(user); migrationService.checkAndRunMigration(user);
// ***** CRITICAL: Wait for the virtual key and model state update to complete *****
try {
const idToken = await user.getIdToken(true);
const virtualKey = await getVirtualKeyByEmail(user.email, idToken);
if (global.modelStateService) { // Start background task to fetch and save virtual key
// The model state service now writes directly to the DB, no in-memory state. (async () => {
await global.modelStateService.setFirebaseVirtualKey(virtualKey); try {
const idToken = await user.getIdToken(true);
const virtualKey = await getVirtualKeyByEmail(user.email, idToken);
if (global.modelStateService) {
global.modelStateService.setFirebaseVirtualKey(virtualKey);
}
console.log(`[AuthService] BG: Virtual key for ${user.email} has been processed.`);
} catch (error) {
console.error('[AuthService] BG: Failed to fetch or save virtual key:', error);
} }
console.log(`[AuthService] Virtual key for ${user.email} has been processed and state updated.`); })();
} catch (error) {
console.error('[AuthService] Failed to fetch or save virtual key:', error);
// This is not critical enough to halt the login, but we should log it.
}
} else { } else {
// User signed OUT // User signed OUT
@ -99,8 +97,7 @@ class AuthService {
if (previousUser) { if (previousUser) {
console.log(`[AuthService] Clearing API key for logged-out user: ${previousUser.uid}`); console.log(`[AuthService] Clearing API key for logged-out user: ${previousUser.uid}`);
if (global.modelStateService) { if (global.modelStateService) {
// The model state service now writes directly to the DB. global.modelStateService.setFirebaseVirtualKey(null);
await global.modelStateService.setFirebaseVirtualKey(null);
} }
} }
this.currentUser = null; this.currentUser = null;
@ -110,7 +107,8 @@ class AuthService {
// End active sessions for the local/default user as well. // End active sessions for the local/default user as well.
await sessionRepository.endAllActiveSessions(); await sessionRepository.endAllActiveSessions();
encryptionService.resetSessionKey(); // ** Initialize encryption key for the default/local user **
await encryptionService.initializeKey(this.currentUserId);
} }
this.broadcastUserState(); this.broadcastUserState();
@ -175,6 +173,7 @@ class AuthService {
}); });
} }
getCurrentUserId() { getCurrentUserId() {
return this.currentUserId; return this.currentUserId;
} }

View File

@ -10,13 +10,10 @@ class DatabaseInitializer {
// 최종적으로 사용될 DB 경로 (쓰기 가능한 위치) // 최종적으로 사용될 DB 경로 (쓰기 가능한 위치)
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath('userData');
// In both development and production mode, the database is stored in the userData directory:
// macOS: ~/Library/Application Support/Glass/pickleglass.db
// Windows: %APPDATA%\Glass\pickleglass.db
this.dbPath = path.join(userDataPath, 'pickleglass.db'); this.dbPath = path.join(userDataPath, 'pickleglass.db');
this.dataDir = userDataPath; this.dataDir = userDataPath;
// The original DB path (read-only location in the package) // 원본 DB 경로 (패키지 내 읽기 전용 위치)
this.sourceDbPath = app.isPackaged this.sourceDbPath = app.isPackaged
? path.join(process.resourcesPath, 'data', 'pickleglass.db') ? path.join(process.resourcesPath, 'data', 'pickleglass.db')
: path.join(app.getAppPath(), 'data', 'pickleglass.db'); : path.join(app.getAppPath(), 'data', 'pickleglass.db');
@ -55,7 +52,7 @@ class DatabaseInitializer {
try { try {
this.ensureDatabaseExists(); this.ensureDatabaseExists();
sqliteClient.connect(this.dbPath); // DB 경로를 인자로 전달 await sqliteClient.connect(this.dbPath); // DB 경로를 인자로 전달
// This single call will now synchronize the schema and then init default data. // This single call will now synchronize the schema and then init default data.
await sqliteClient.initTables(); await sqliteClient.initTables();

View File

@ -9,8 +9,6 @@ try {
keytar = null; keytar = null;
} }
const permissionService = require('./permissionService');
const SERVICE_NAME = 'com.pickle.glass'; // A unique identifier for the app in the keychain const SERVICE_NAME = 'com.pickle.glass'; // A unique identifier for the app in the keychain
let sessionKey = null; // In-memory fallback key let sessionKey = null; // In-memory fallback key
@ -33,8 +31,6 @@ async function initializeKey(userId) {
throw new Error('A user ID must be provided to initialize the encryption key.'); throw new Error('A user ID must be provided to initialize the encryption key.');
} }
let keyRetrieved = false;
if (keytar) { if (keytar) {
try { try {
let key = await keytar.getPassword(SERVICE_NAME, userId); let key = await keytar.getPassword(SERVICE_NAME, userId);
@ -45,7 +41,6 @@ async function initializeKey(userId) {
console.log(`[EncryptionService] New key securely stored in keychain for ${userId}.`); console.log(`[EncryptionService] New key securely stored in keychain for ${userId}.`);
} else { } else {
console.log(`[EncryptionService] Encryption key successfully retrieved from keychain for ${userId}.`); console.log(`[EncryptionService] Encryption key successfully retrieved from keychain for ${userId}.`);
keyRetrieved = true;
} }
sessionKey = key; sessionKey = key;
} catch (error) { } catch (error) {
@ -60,26 +55,12 @@ async function initializeKey(userId) {
sessionKey = crypto.randomBytes(32).toString('hex'); sessionKey = crypto.randomBytes(32).toString('hex');
} }
} }
// Mark keychain completed in permissions DB if this is the first successful retrieval or storage
try {
await permissionService.markKeychainCompleted(userId);
if (keyRetrieved) {
console.log(`[EncryptionService] Keychain completion marked in DB for ${userId}.`);
}
} catch (permErr) {
console.error('[EncryptionService] Failed to mark keychain completion:', permErr);
}
if (!sessionKey) { if (!sessionKey) {
throw new Error('Failed to initialize encryption key.'); throw new Error('Failed to initialize encryption key.');
} }
} }
function resetSessionKey() {
sessionKey = null;
}
/** /**
* Encrypts a given text using AES-256-GCM. * Encrypts a given text using AES-256-GCM.
* @param {string} text The text to encrypt. * @param {string} text The text to encrypt.
@ -148,28 +129,12 @@ function decrypt(encryptedText) {
} catch (error) { } catch (error) {
// It's common for this to fail if the data is not encrypted (e.g., legacy data). // It's common for this to fail if the data is not encrypted (e.g., legacy data).
// In that case, we return the original value. // In that case, we return the original value.
console.error('[EncryptionService] Decryption failed:', error);
return encryptedText; return encryptedText;
} }
} }
function looksEncrypted(str) {
if (!str || typeof str !== 'string') return false;
// Base64 chars + optional '=' padding
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(str)) return false;
try {
const buf = Buffer.from(str, 'base64');
// Our AES-GCM cipher text must be at least 32 bytes (IV 16 + TAG 16)
return buf.length >= 32;
} catch {
return false;
}
}
module.exports = { module.exports = {
initializeKey, initializeKey,
resetSessionKey,
encrypt, encrypt,
decrypt, decrypt,
looksEncrypted,
}; };

File diff suppressed because it is too large Load Diff

View File

@ -1066,7 +1066,7 @@ class OllamaService extends EventEmitter {
return false; return false;
} }
const selectedModels = await modelStateService.getSelectedModels(); const selectedModels = modelStateService.getSelectedModels();
const llmModelId = selectedModels.llm; const llmModelId = selectedModels.llm;
// Check if it's an Ollama model // Check if it's an Ollama model

View File

@ -2,28 +2,27 @@ const { systemPreferences, shell, desktopCapturer } = require('electron');
const permissionRepository = require('../repositories/permission'); const permissionRepository = require('../repositories/permission');
class PermissionService { class PermissionService {
_getAuthService() {
return require('./authService');
}
async checkSystemPermissions() { async checkSystemPermissions() {
const permissions = { const permissions = {
microphone: 'unknown', microphone: 'unknown',
screen: 'unknown', screen: 'unknown',
keychain: 'unknown',
needsSetup: true needsSetup: true
}; };
try { try {
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
permissions.microphone = systemPreferences.getMediaAccessStatus('microphone'); const micStatus = systemPreferences.getMediaAccessStatus('microphone');
permissions.screen = systemPreferences.getMediaAccessStatus('screen'); console.log('[Permissions] Microphone status:', micStatus);
permissions.keychain = await this.checkKeychainCompleted(this._getAuthService().getCurrentUserId()) ? 'granted' : 'unknown'; permissions.microphone = micStatus;
permissions.needsSetup = permissions.microphone !== 'granted' || permissions.screen !== 'granted' || permissions.keychain !== 'granted';
const screenStatus = systemPreferences.getMediaAccessStatus('screen');
console.log('[Permissions] Screen status:', screenStatus);
permissions.screen = screenStatus;
permissions.needsSetup = micStatus !== 'granted' || screenStatus !== 'granted';
} else { } else {
permissions.microphone = 'granted'; permissions.microphone = 'granted';
permissions.screen = 'granted'; permissions.screen = 'granted';
permissions.keychain = 'granted';
permissions.needsSetup = false; permissions.needsSetup = false;
} }
@ -34,7 +33,6 @@ class PermissionService {
return { return {
microphone: 'unknown', microphone: 'unknown',
screen: 'unknown', screen: 'unknown',
keychain: 'unknown',
needsSetup: true, needsSetup: true,
error: error.message error: error.message
}; };
@ -94,27 +92,24 @@ class PermissionService {
} }
} }
async markKeychainCompleted() { async markPermissionsAsCompleted() {
try { try {
await permissionRepository.markKeychainCompleted(this._getAuthService().getCurrentUserId()); await permissionRepository.markPermissionsAsCompleted();
console.log('[Permissions] Marked keychain as completed'); console.log('[Permissions] Marked permissions as completed');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('[Permissions] Error marking keychain as completed:', error); console.error('[Permissions] Error marking permissions as completed:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }
async checkKeychainCompleted(uid) { async checkPermissionsCompleted() {
if (uid === "default_user") {
return true;
}
try { try {
const completed = permissionRepository.checkKeychainCompleted(uid); const completed = await permissionRepository.checkPermissionsCompleted();
console.log('[Permissions] Keychain completed status:', completed); console.log('[Permissions] Permissions completed status:', completed);
return completed; return completed;
} catch (error) { } catch (error) {
console.error('[Permissions] Error checking keychain completed status:', error); console.error('[Permissions] Error checking permissions completed status:', error);
return false; return false;
} }
} }

View File

@ -40,82 +40,8 @@ class SQLiteClient {
return `"${identifier}"`; return `"${identifier}"`;
} }
_migrateProviderSettings() { synchronizeSchema() {
const tablesInDb = this.getTablesFromDb();
if (!tablesInDb.includes('provider_settings')) {
return; // Table doesn't exist, no migration needed.
}
const providerSettingsInfo = this.db.prepare(`PRAGMA table_info(provider_settings)`).all();
const hasUidColumn = providerSettingsInfo.some(col => col.name === 'uid');
if (hasUidColumn) {
console.log('[DB Migration] Old provider_settings schema detected. Starting robust migration...');
try {
this.db.transaction(() => {
this.db.exec('ALTER TABLE provider_settings RENAME TO provider_settings_old');
console.log('[DB Migration] Renamed provider_settings to provider_settings_old');
this.createTable('provider_settings', LATEST_SCHEMA.provider_settings);
console.log('[DB Migration] Created new provider_settings table');
// Dynamically build the migration query for robustness
const oldColumnNames = this.db.prepare(`PRAGMA table_info(provider_settings_old)`).all().map(c => c.name);
const newColumnNames = LATEST_SCHEMA.provider_settings.columns.map(c => c.name);
const commonColumns = newColumnNames.filter(name => oldColumnNames.includes(name));
if (!commonColumns.includes('provider')) {
console.warn('[DB Migration] Old table is missing the "provider" column. Aborting migration for this table.');
this.db.exec('DROP TABLE provider_settings_old');
return;
}
const orderParts = [];
if (oldColumnNames.includes('updated_at')) orderParts.push('updated_at DESC');
if (oldColumnNames.includes('created_at')) orderParts.push('created_at DESC');
const orderByClause = orderParts.length > 0 ? `ORDER BY ${orderParts.join(', ')}` : '';
const columnsForInsert = commonColumns.map(c => this._validateAndQuoteIdentifier(c)).join(', ');
const migrationQuery = `
INSERT INTO provider_settings (${columnsForInsert})
SELECT ${columnsForInsert}
FROM (
SELECT *, ROW_NUMBER() OVER(PARTITION BY provider ${orderByClause}) as rn
FROM provider_settings_old
)
WHERE rn = 1
`;
console.log(`[DB Migration] Executing robust migration query for columns: ${commonColumns.join(', ')}`);
const result = this.db.prepare(migrationQuery).run();
console.log(`[DB Migration] Migrated ${result.changes} rows to the new provider_settings table.`);
this.db.exec('DROP TABLE provider_settings_old');
console.log('[DB Migration] Dropped provider_settings_old table.');
})();
console.log('[DB Migration] provider_settings migration completed successfully.');
} catch (error) {
console.error('[DB Migration] Failed to migrate provider_settings table.', error);
// Try to recover by dropping the temp table if it exists
const oldTableExists = this.getTablesFromDb().includes('provider_settings_old');
if (oldTableExists) {
this.db.exec('DROP TABLE provider_settings_old');
console.warn('[DB Migration] Cleaned up temporary old table after failure.');
}
throw error;
}
}
}
async synchronizeSchema() {
console.log('[DB Sync] Starting schema synchronization...'); console.log('[DB Sync] Starting schema synchronization...');
// Run special migration for provider_settings before the generic sync logic
this._migrateProviderSettings();
const tablesInDb = this.getTablesFromDb(); const tablesInDb = this.getTablesFromDb();
for (const tableName of Object.keys(LATEST_SCHEMA)) { for (const tableName of Object.keys(LATEST_SCHEMA)) {
@ -206,8 +132,8 @@ class SQLiteClient {
console.log(`[DB Cleanup] Successfully deleted ${result.changes} empty sessions.`); console.log(`[DB Cleanup] Successfully deleted ${result.changes} empty sessions.`);
} }
async initTables() { initTables() {
await this.synchronizeSchema(); this.synchronizeSchema();
this.initDefaultData(); this.initDefaultData();
} }
@ -240,6 +166,21 @@ class SQLiteClient {
console.log('Default data initialized.'); console.log('Default data initialized.');
} }
markPermissionsAsCompleted() {
return this.query(
'INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, ?)',
['permissions_completed', 'true']
);
}
checkPermissionsCompleted() {
const result = this.query(
'SELECT value FROM system_settings WHERE key = ?',
['permissions_completed']
);
return result.length > 0 && result[0].value === 'true';
}
close() { close() {
if (this.db) { if (this.db) {
try { try {

View File

@ -54,7 +54,53 @@ class ListenService {
} }
async handleListenRequest(listenButtonText) { async handleListenRequest(listenButtonText) {
const { windowPool } = require('../../window/windowManager'); 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;
}
}
initialize() {
this.setupIpcHandlers();
console.log('[ListenService] Initialized and ready.');
}
async handleListenRequest(listenButtonText) {
const { windowPool, updateLayout } = require('../../window/windowManager');
const listenWindow = windowPool.get('listen'); const listenWindow = windowPool.get('listen');
const header = windowPool.get('header'); const header = windowPool.get('header');

View File

@ -2,6 +2,7 @@ const { BrowserWindow } = require('electron');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const { createSTT } = require('../../common/ai/factory'); const { createSTT } = require('../../common/ai/factory');
const modelStateService = require('../../common/services/modelStateService'); const modelStateService = require('../../common/services/modelStateService');
// const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager');
const COMPLETION_DEBOUNCE_MS = 2000; const COMPLETION_DEBOUNCE_MS = 2000;
@ -133,7 +134,7 @@ class SttService {
async initializeSttSessions(language = 'en') { async initializeSttSessions(language = 'en') {
const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en'; const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en';
const modelInfo = await modelStateService.getCurrentModelInfo('stt'); const modelInfo = modelStateService.getCurrentModelInfo('stt');
if (!modelInfo || !modelInfo.apiKey) { if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key is not configured.'); throw new Error('AI model or API key is not configured.');
} }
@ -466,7 +467,7 @@ class SttService {
let modelInfo = this.modelInfo; let modelInfo = this.modelInfo;
if (!modelInfo) { if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await modelStateService.getCurrentModelInfo('stt'); modelInfo = modelStateService.getCurrentModelInfo('stt');
} }
if (!modelInfo) { if (!modelInfo) {
throw new Error('STT model info could not be retrieved.'); throw new Error('STT model info could not be retrieved.');
@ -491,7 +492,7 @@ class SttService {
let modelInfo = this.modelInfo; let modelInfo = this.modelInfo;
if (!modelInfo) { if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await modelStateService.getCurrentModelInfo('stt'); modelInfo = modelStateService.getCurrentModelInfo('stt');
} }
if (!modelInfo) { if (!modelInfo) {
throw new Error('STT model info could not be retrieved.'); throw new Error('STT model info could not be retrieved.');
@ -577,7 +578,7 @@ class SttService {
let modelInfo = this.modelInfo; let modelInfo = this.modelInfo;
if (!modelInfo) { if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await modelStateService.getCurrentModelInfo('stt'); modelInfo = modelStateService.getCurrentModelInfo('stt');
} }
if (!modelInfo) { if (!modelInfo) {
throw new Error('STT model info could not be retrieved.'); throw new Error('STT model info could not be retrieved.');

View File

@ -4,6 +4,7 @@ const { createLLM } = require('../../common/ai/factory');
const sessionRepository = require('../../common/repositories/session'); const sessionRepository = require('../../common/repositories/session');
const summaryRepository = require('./repositories'); const summaryRepository = require('./repositories');
const modelStateService = require('../../common/services/modelStateService'); const modelStateService = require('../../common/services/modelStateService');
// const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager.js');
class SummaryService { class SummaryService {
constructor() { constructor() {
@ -98,7 +99,7 @@ Please build upon this context while analyzing the new conversation segments.
await sessionRepository.touch(this.currentSessionId); await sessionRepository.touch(this.currentSessionId);
} }
const modelInfo = await modelStateService.getCurrentModelInfo('llm'); const modelInfo = modelStateService.getCurrentModelInfo('llm');
if (!modelInfo || !modelInfo.apiKey) { if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key is not configured.'); throw new Error('AI model or API key is not configured.');
} }

View File

@ -26,14 +26,16 @@ const NOTIFICATION_CONFIG = {
// New facade functions for model state management // New facade functions for model state management
async function getModelSettings() { async function getModelSettings() {
try { try {
const [config, storedKeys, selectedModels, availableLlm, availableStt] = await Promise.all([ const [config, storedKeys, selectedModels] = await Promise.all([
modelStateService.getProviderConfig(), modelStateService.getProviderConfig(),
modelStateService.getAllApiKeys(), modelStateService.getAllApiKeys(),
modelStateService.getSelectedModels(), modelStateService.getSelectedModels(),
modelStateService.getAvailableModels('llm'),
modelStateService.getAvailableModels('stt')
]); ]);
// 동기 함수들은 별도로 호출
const availableLlm = modelStateService.getAvailableModels('llm');
const availableStt = modelStateService.getAvailableModels('stt');
return { success: true, data: { config, storedKeys, availableLlm, availableStt, selectedModels } }; return { success: true, data: { config, storedKeys, availableLlm, availableStt, selectedModels } };
} catch (error) { } catch (error) {
console.error('[SettingsService] Error getting model settings:', error); console.error('[SettingsService] Error getting model settings:', error);
@ -41,6 +43,10 @@ async function getModelSettings() {
} }
} }
async function validateAndSaveKey(provider, key) {
return modelStateService.handleValidateKey(provider, key);
}
async function clearApiKey(provider) { async function clearApiKey(provider) {
const success = await modelStateService.handleRemoveApiKey(provider); const success = await modelStateService.handleRemoveApiKey(provider);
return { success }; return { success };
@ -458,6 +464,7 @@ module.exports = {
setAutoUpdateSetting, setAutoUpdateSetting,
// Model settings facade // Model settings facade
getModelSettings, getModelSettings,
validateAndSaveKey,
clearApiKey, clearApiKey,
setSelectedModel, setSelectedModel,
// Ollama facade // Ollama facade

View File

@ -8,11 +8,13 @@ class ShortcutsService {
constructor() { constructor() {
this.lastVisibleWindows = new Set(['header']); this.lastVisibleWindows = new Set(['header']);
this.mouseEventsIgnored = false; this.mouseEventsIgnored = false;
this.movementManager = null;
this.windowPool = null; this.windowPool = null;
this.allWindowVisibility = true; this.allWindowVisibility = true;
} }
initialize(windowPool) { initialize(movementManager, windowPool) {
this.movementManager = movementManager;
this.windowPool = windowPool; this.windowPool = windowPool;
internalBridge.on('reregister-shortcuts', () => { internalBridge.on('reregister-shortcuts', () => {
console.log('[ShortcutsService] Reregistering shortcuts due to header state change.'); console.log('[ShortcutsService] Reregistering shortcuts due to header state change.');
@ -136,7 +138,7 @@ class ShortcutsService {
} }
async registerShortcuts(registerOnlyToggleVisibility = false) { async registerShortcuts(registerOnlyToggleVisibility = false) {
if (!this.windowPool) { if (!this.movementManager || !this.windowPool) {
console.error('[Shortcuts] Service not initialized. Cannot register shortcuts.'); console.error('[Shortcuts] Service not initialized. Cannot register shortcuts.');
return; return;
} }
@ -177,7 +179,7 @@ class ShortcutsService {
if (displays.length > 1) { if (displays.length > 1) {
displays.forEach((display, index) => { displays.forEach((display, index) => {
const key = `${modifier}+Shift+${index + 1}`; const key = `${modifier}+Shift+${index + 1}`;
globalShortcut.register(key, () => internalBridge.emit('window:moveToDisplay', { displayId: display.id })); globalShortcut.register(key, () => this.movementManager.moveToDisplay(display.id));
}); });
} }
@ -188,7 +190,7 @@ class ShortcutsService {
]; ];
edgeDirections.forEach(({ key, direction }) => { edgeDirections.forEach(({ key, direction }) => {
globalShortcut.register(key, () => { globalShortcut.register(key, () => {
if (header && header.isVisible()) internalBridge.emit('window:moveToEdge', { direction }); if (header && header.isVisible()) this.movementManager.moveToEdge(direction);
}); });
}); });
@ -230,16 +232,16 @@ class ShortcutsService {
}; };
break; break;
case 'moveUp': case 'moveUp':
callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'up' }); }; callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('up'); };
break; break;
case 'moveDown': case 'moveDown':
callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'down' }); }; callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('down'); };
break; break;
case 'moveLeft': case 'moveLeft':
callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'left' }); }; callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('left'); };
break; break;
case 'moveRight': case 'moveRight':
callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'right' }); }; callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('right'); };
break; break;
case 'toggleClickThrough': case 'toggleClickThrough':
callback = () => { callback = () => {

View File

@ -686,43 +686,75 @@ async function startWebStack() {
console.log(`✅ API server started on http://localhost:${apiPort}`); console.log(`✅ API server started on http://localhost:${apiPort}`);
console.log(`🚀 All services ready: console.log(`🚀 All services ready:`);
Frontend: http://localhost:${frontendPort} console.log(` Frontend: http://localhost:${frontendPort}`);
API: http://localhost:${apiPort}`); console.log(` API: http://localhost:${apiPort}`);
return frontendPort; return frontendPort;
} }
// Auto-update initialization // Auto-update initialization
async function initAutoUpdater() { async function initAutoUpdater() {
if (process.env.NODE_ENV === 'development') {
console.log('Development environment, skipping auto-updater.');
return;
}
try { try {
await autoUpdater.checkForUpdates(); const autoUpdateEnabled = await settingsService.getAutoUpdateSetting();
autoUpdater.on('update-available', () => { if (!autoUpdateEnabled) {
console.log('Update available!'); console.log('[AutoUpdater] Skipped because auto-updates are disabled in settings');
autoUpdater.downloadUpdate(); return;
}
// Skip auto-updater in development mode
if (!app.isPackaged) {
console.log('[AutoUpdater] Skipped in development (app is not packaged)');
return;
}
autoUpdater.setFeedURL({
provider: 'github',
owner: 'pickle-com',
repo: 'glass',
}); });
autoUpdater.on('update-downloaded', (event, releaseNotes, releaseName, date, url) => {
console.log('Update downloaded:', releaseNotes, releaseName, date, url); // Immediately check for updates & notify
dialog.showMessageBox({ autoUpdater.checkForUpdatesAndNotify()
.catch(err => {
console.error('[AutoUpdater] Error checking for updates:', err);
});
autoUpdater.on('checking-for-update', () => {
console.log('[AutoUpdater] Checking for updates…');
});
autoUpdater.on('update-available', (info) => {
console.log('[AutoUpdater] Update available:', info.version);
});
autoUpdater.on('update-not-available', () => {
console.log('[AutoUpdater] Application is up-to-date');
});
autoUpdater.on('error', (err) => {
console.error('[AutoUpdater] Error while updating:', err);
});
autoUpdater.on('update-downloaded', (info) => {
console.log(`[AutoUpdater] Update downloaded: ${info.version}`);
const dialogOpts = {
type: 'info', type: 'info',
title: 'Application Update', buttons: ['Install now', 'Install on next launch'],
message: `A new version of PickleGlass (${releaseName}) has been downloaded. It will be installed the next time you launch the application.`, title: 'Update Available',
buttons: ['Restart', 'Later'] message: 'A new version of Glass is ready to be installed.',
}).then(response => { defaultId: 0,
if (response.response === 0) { cancelId: 1
};
dialog.showMessageBox(dialogOpts).then((returnValue) => {
// returnValue.response 0 is for 'Install Now'
if (returnValue.response === 0) {
autoUpdater.quitAndInstall(); autoUpdater.quitAndInstall();
} }
}); });
}); });
autoUpdater.on('error', (err) => { } catch (e) {
console.error('Error in auto-updater:', err); console.error('[AutoUpdater] Failed to initialise:', e);
});
} catch (err) {
console.error('Error initializing auto-updater:', err);
} }
} }

View File

@ -82,7 +82,6 @@ contextBridge.exposeInMainWorld('api', {
headerController: { headerController: {
// State Management // State Management
sendHeaderStateChanged: (state) => ipcRenderer.send('header-state-changed', state), sendHeaderStateChanged: (state) => ipcRenderer.send('header-state-changed', state),
reInitializeModelState: () => ipcRenderer.invoke('model:re-initialize-state'),
// Window Management // Window Management
resizeHeaderWindow: (dimensions) => ipcRenderer.invoke('resize-header-window', dimensions), resizeHeaderWindow: (dimensions) => ipcRenderer.invoke('resize-header-window', dimensions),
@ -97,7 +96,7 @@ contextBridge.exposeInMainWorld('api', {
onAuthFailed: (callback) => ipcRenderer.on('auth-failed', callback), onAuthFailed: (callback) => ipcRenderer.on('auth-failed', callback),
removeOnAuthFailed: (callback) => ipcRenderer.removeListener('auth-failed', callback), removeOnAuthFailed: (callback) => ipcRenderer.removeListener('auth-failed', callback),
onForceShowApiKeyHeader: (callback) => ipcRenderer.on('force-show-apikey-header', callback), onForceShowApiKeyHeader: (callback) => ipcRenderer.on('force-show-apikey-header', callback),
removeOnForceShowApiKeyHeader: (callback) => ipcRenderer.removeListener('force-show-apikey-header', callback), removeOnForceShowApiKeyHeader: (callback) => ipcRenderer.removeListener('force-show-apikey-header', callback)
}, },
// src/ui/app/MainHeader.js // src/ui/app/MainHeader.js
@ -131,9 +130,7 @@ contextBridge.exposeInMainWorld('api', {
checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'), checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'),
requestMicrophonePermission: () => ipcRenderer.invoke('request-microphone-permission'), requestMicrophonePermission: () => ipcRenderer.invoke('request-microphone-permission'),
openSystemPreferences: (preference) => ipcRenderer.invoke('open-system-preferences', preference), openSystemPreferences: (preference) => ipcRenderer.invoke('open-system-preferences', preference),
markKeychainCompleted: () => ipcRenderer.invoke('mark-keychain-completed'), markPermissionsCompleted: () => ipcRenderer.invoke('mark-permissions-completed')
checkKeychainCompleted: (uid) => ipcRenderer.invoke('check-keychain-completed', uid),
initializeEncryptionKey: () => ipcRenderer.invoke('initialize-encryption-key') // New for keychain
}, },
// src/ui/app/PickleGlassApp.js // src/ui/app/PickleGlassApp.js
@ -148,7 +145,7 @@ contextBridge.exposeInMainWorld('api', {
askView: { askView: {
// Window Management // Window Management
closeAskWindow: () => ipcRenderer.invoke('ask:closeAskWindow'), closeAskWindow: () => ipcRenderer.invoke('ask:closeAskWindow'),
adjustWindowHeight: (winName, height) => ipcRenderer.invoke('adjust-window-height', { winName, height }), adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height),
// Message Handling // Message Handling
sendMessage: (text) => ipcRenderer.invoke('ask:sendQuestionFromAsk', text), sendMessage: (text) => ipcRenderer.invoke('ask:sendQuestionFromAsk', text),
@ -173,7 +170,7 @@ contextBridge.exposeInMainWorld('api', {
// src/ui/listen/ListenView.js // src/ui/listen/ListenView.js
listenView: { listenView: {
// Window Management // Window Management
adjustWindowHeight: (winName, height) => ipcRenderer.invoke('adjust-window-height', { winName, height }), adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height),
// Listeners // Listeners
onSessionStateChanged: (callback) => ipcRenderer.on('session-state-changed', callback), onSessionStateChanged: (callback) => ipcRenderer.on('session-state-changed', callback),
@ -290,7 +287,7 @@ contextBridge.exposeInMainWorld('api', {
stopMacosSystemAudio: () => ipcRenderer.invoke('listen:stopMacosSystemAudio'), stopMacosSystemAudio: () => ipcRenderer.invoke('listen:stopMacosSystemAudio'),
// Session Management // Session Management
isSessionActive: () => ipcRenderer.invoke('listen:isSessionActive'), isSessionActive: () => ipcRenderer.invoke('is-session-active'),
// Listeners // Listeners
onSystemAudioData: (callback) => ipcRenderer.on('system-audio-data', callback), onSystemAudioData: (callback) => ipcRenderer.on('system-audio-data', callback),

View File

@ -48,16 +48,7 @@ class HeaderTransitionManager {
console.log('[HeaderController] ensureHeader: Header of type:', type, 'created.'); console.log('[HeaderController] ensureHeader: Header of type:', type, 'created.');
} else if (type === 'permission') { } else if (type === 'permission') {
this.permissionHeader = document.createElement('permission-setup'); this.permissionHeader = document.createElement('permission-setup');
this.permissionHeader.addEventListener('request-resize', e => { this.permissionHeader.continueCallback = () => this.transitionToMainHeader();
this._resizeForPermissionHeader(e.detail.height);
});
this.permissionHeader.continueCallback = async () => {
if (window.api && window.api.headerController) {
console.log('[HeaderController] Re-initializing model state after permission grant...');
await window.api.headerController.reInitializeModelState();
}
this.transitionToMainHeader();
};
this.headerContainer.appendChild(this.permissionHeader); this.headerContainer.appendChild(this.permissionHeader);
} else { } else {
this.mainHeader = document.createElement('main-header'); this.mainHeader = document.createElement('main-header');
@ -130,15 +121,19 @@ class HeaderTransitionManager {
const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured(); const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured();
if (isConfigured) { if (isConfigured) {
// If providers are configured, always check permissions regardless of login state. const { isLoggedIn } = userState;
const permissionResult = await this.checkPermissions(); if (isLoggedIn) {
if (permissionResult.success) { const permissionResult = await this.checkPermissions();
this.transitionToMainHeader(); if (permissionResult.success) {
this.transitionToMainHeader();
} else {
this.transitionToPermissionHeader();
}
} else { } else {
this.transitionToPermissionHeader(); this.transitionToMainHeader();
} }
} else { } else {
// If no providers are configured, show the welcome header to prompt for setup. // 프로바이더가 설정되지 않았으면 WelcomeHeader 먼저 표시
await this._resizeForWelcome(); await this._resizeForWelcome();
this.ensureHeader('welcome'); this.ensureHeader('welcome');
} }
@ -201,19 +196,7 @@ class HeaderTransitionManager {
} }
} }
let initialHeight = 220; await this._resizeForPermissionHeader();
if (window.api) {
try {
const userState = await window.api.common.getCurrentUser();
if (userState.mode === 'firebase') {
initialHeight = 280;
}
} catch (e) {
console.error('Could not get user state for resize', e);
}
}
await this._resizeForPermissionHeader(initialHeight);
this.ensureHeader('permission'); this.ensureHeader('permission');
} }
@ -238,10 +221,9 @@ class HeaderTransitionManager {
return window.api.headerController.resizeHeaderWindow({ width: 456, height: height }).catch(() => {}); return window.api.headerController.resizeHeaderWindow({ width: 456, height: height }).catch(() => {});
} }
async _resizeForPermissionHeader(height) { async _resizeForPermissionHeader() {
if (!window.api) return; if (!window.api) return;
const finalHeight = height || 220; return window.api.headerController.resizeHeaderWindow({ width: 285, height: 220 })
return window.api.headerController.resizeHeaderWindow({ width: 285, height: finalHeight })
.catch(() => {}); .catch(() => {});
} }

View File

@ -28,7 +28,7 @@ export class PermissionHeader extends LitElement {
.container { .container {
-webkit-app-region: drag; -webkit-app-region: drag;
width: 285px; width: 285px;
/* height is now set dynamically */ height: 220px;
padding: 18px 20px; padding: 18px 20px;
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
border-radius: 16px; border-radius: 16px;
@ -103,12 +103,6 @@ export class PermissionHeader extends LitElement {
margin-top: auto; margin-top: auto;
} }
.form-content.all-granted {
flex-grow: 1;
justify-content: center;
margin-top: 0;
}
.subtitle { .subtitle {
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
font-size: 11px; font-size: 11px;
@ -264,60 +258,24 @@ export class PermissionHeader extends LitElement {
static properties = { static properties = {
microphoneGranted: { type: String }, microphoneGranted: { type: String },
screenGranted: { type: String }, screenGranted: { type: String },
keychainGranted: { type: String },
isChecking: { type: String }, isChecking: { type: String },
continueCallback: { type: Function }, continueCallback: { type: Function }
userMode: { type: String }, // 'local' or 'firebase'
}; };
constructor() { constructor() {
super(); super();
this.microphoneGranted = 'unknown'; this.microphoneGranted = 'unknown';
this.screenGranted = 'unknown'; this.screenGranted = 'unknown';
this.keychainGranted = 'unknown';
this.isChecking = false; this.isChecking = false;
this.continueCallback = null; this.continueCallback = null;
this.userMode = 'local'; // Default to local
}
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('userMode')) {
const newHeight = this.userMode === 'firebase' ? 280 : 220;
console.log(`[PermissionHeader] User mode changed to ${this.userMode}, requesting resize to ${newHeight}px`);
this.dispatchEvent(new CustomEvent('request-resize', {
detail: { height: newHeight },
bubbles: true,
composed: true
}));
}
} }
async connectedCallback() { async connectedCallback() {
super.connectedCallback(); super.connectedCallback();
if (window.api) {
try {
const userState = await window.api.common.getCurrentUser();
this.userMode = userState.mode;
} catch (e) {
console.error('[PermissionHeader] Failed to get user state', e);
this.userMode = 'local'; // Fallback to local
}
}
await this.checkPermissions(); await this.checkPermissions();
// Set up periodic permission check // Set up periodic permission check
this.permissionCheckInterval = setInterval(async () => { this.permissionCheckInterval = setInterval(() => {
if (window.api) {
try {
const userState = await window.api.common.getCurrentUser();
this.userMode = userState.mode;
} catch (e) {
this.userMode = 'local';
}
}
this.checkPermissions(); this.checkPermissions();
}, 1000); }, 1000);
} }
@ -340,25 +298,19 @@ export class PermissionHeader extends LitElement {
const prevMic = this.microphoneGranted; const prevMic = this.microphoneGranted;
const prevScreen = this.screenGranted; const prevScreen = this.screenGranted;
const prevKeychain = this.keychainGranted;
this.microphoneGranted = permissions.microphone; this.microphoneGranted = permissions.microphone;
this.screenGranted = permissions.screen; this.screenGranted = permissions.screen;
this.keychainGranted = permissions.keychain;
// if permissions changed == UI update // if permissions changed == UI update
if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted || prevKeychain !== this.keychainGranted) { if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted) {
console.log('[PermissionHeader] Permission status changed, updating UI'); console.log('[PermissionHeader] Permission status changed, updating UI');
this.requestUpdate(); this.requestUpdate();
} }
const isKeychainRequired = this.userMode === 'firebase';
const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
// if all permissions granted == automatically continue // if all permissions granted == automatically continue
if (this.microphoneGranted === 'granted' && if (this.microphoneGranted === 'granted' &&
this.screenGranted === 'granted' && this.screenGranted === 'granted' &&
keychainOk &&
this.continueCallback) { this.continueCallback) {
console.log('[PermissionHeader] All permissions granted, proceeding automatically'); console.log('[PermissionHeader] All permissions granted, proceeding automatically');
setTimeout(() => this.handleContinue(), 500); setTimeout(() => this.handleContinue(), 500);
@ -429,39 +381,17 @@ export class PermissionHeader extends LitElement {
} }
} }
async handleKeychainClick() {
if (!window.api || this.keychainGranted === 'granted') return;
console.log('[PermissionHeader] Requesting keychain permission...');
try {
// Trigger initializeKey to prompt for keychain access
// Assuming encryptionService is accessible or via API
await window.api.permissionHeader.initializeEncryptionKey(); // New IPC handler needed
// After success, update status
this.keychainGranted = 'granted';
this.requestUpdate();
} catch (error) {
console.error('[PermissionHeader] Error requesting keychain permission:', error);
}
}
async handleContinue() { async handleContinue() {
const isKeychainRequired = this.userMode === 'firebase';
const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
if (this.continueCallback && if (this.continueCallback &&
this.microphoneGranted === 'granted' && this.microphoneGranted === 'granted' &&
this.screenGranted === 'granted' && this.screenGranted === 'granted') {
keychainOk) {
// Mark permissions as completed // Mark permissions as completed
if (window.api && isKeychainRequired) { if (window.api) {
try { try {
await window.api.permissionHeader.markKeychainCompleted(); await window.api.permissionHeader.markPermissionsCompleted();
console.log('[PermissionHeader] Marked keychain as completed'); console.log('[PermissionHeader] Marked permissions as completed');
} catch (error) { } catch (error) {
console.error('[PermissionHeader] Error marking keychain as completed:', error); console.error('[PermissionHeader] Error marking permissions as completed:', error);
} }
} }
@ -477,13 +407,10 @@ export class PermissionHeader extends LitElement {
} }
render() { render() {
const isKeychainRequired = this.userMode === 'firebase'; const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted';
const containerHeight = isKeychainRequired ? 280 : 220;
const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted' && keychainOk;
return html` return html`
<div class="container" style="height: ${containerHeight}px"> <div class="container">
<button class="close-button" @click=${this.handleClose} title="Close application"> <button class="close-button" @click=${this.handleClose} title="Close application">
<svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor"> <svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor">
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.2" /> <path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.2" />
@ -491,92 +418,65 @@ export class PermissionHeader extends LitElement {
</button> </button>
<h1 class="title">Permission Setup Required</h1> <h1 class="title">Permission Setup Required</h1>
<div class="form-content ${allGranted ? 'all-granted' : ''}"> <div class="form-content">
${!allGranted ? html` <div class="subtitle">Grant access to microphone and screen recording to continue</div>
<div class="subtitle">Grant access to microphone, screen recording${isKeychainRequired ? ' and keychain' : ''} to continue</div>
<div class="permission-status">
<div class="permission-status"> <div class="permission-item ${this.microphoneGranted === 'granted' ? 'granted' : ''}">
<div class="permission-item ${this.microphoneGranted === 'granted' ? 'granted' : ''}"> ${this.microphoneGranted === 'granted' ? html`
${this.microphoneGranted === 'granted' ? html` <svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor"> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" /> </svg>
</svg> <span>Microphone </span>
<span>Microphone </span> ` : html`
` : html` <svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor"> <path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd" />
<path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd" /> </svg>
</svg> <span>Microphone</span>
<span>Microphone</span> `}
`}
</div>
<div class="permission-item ${this.screenGranted === 'granted' ? 'granted' : ''}">
${this.screenGranted === 'granted' ? html`
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span>Screen </span>
` : html`
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
</svg>
<span>Screen Recording</span>
`}
</div>
${isKeychainRequired ? html`
<div class="permission-item ${this.keychainGranted === 'granted' ? 'granted' : ''}">
${this.keychainGranted === 'granted' ? html`
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span>Data Encryption </span>
` : html`
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 8a6 6 0 01-7.744 5.668l-1.649 1.652c-.63.63-1.706.19-1.706-.742V12.18a.75.75 0 00-1.5 0v2.696c0 .932-1.075 1.372-1.706.742l-1.649-1.652A6 6 0 112 8zm-4 0a.75.75 0 00.75-.75A3.75 3.75 0 018.25 4a.75.75 0 000 1.5 2.25 2.25 0 012.25 2.25.75.75 0 00.75.75z" clip-rule="evenodd" />
</svg>
<span>Data Encryption</span>
`}
</div>
` : ''}
</div> </div>
<div class="permission-item ${this.screenGranted === 'granted' ? 'granted' : ''}">
${this.screenGranted === 'granted' ? html`
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span>Screen </span>
` : html`
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
</svg>
<span>Screen Recording</span>
`}
</div>
</div>
${this.microphoneGranted !== 'granted' ? html`
<button <button
class="action-button" class="action-button"
@click=${this.handleMicrophoneClick} @click=${this.handleMicrophoneClick}
?disabled=${this.microphoneGranted === 'granted'}
> >
${this.microphoneGranted === 'granted' ? 'Microphone Access Granted' : 'Grant Microphone Access'} Grant Microphone Access
</button> </button>
` : ''}
${this.screenGranted !== 'granted' ? html`
<button <button
class="action-button" class="action-button"
@click=${this.handleScreenClick} @click=${this.handleScreenClick}
?disabled=${this.screenGranted === 'granted'}
> >
${this.screenGranted === 'granted' ? 'Screen Recording Granted' : 'Grant Screen Recording Access'} Grant Screen Recording Access
</button> </button>
` : ''}
${isKeychainRequired ? html` ${allGranted ? html`
<button
class="action-button"
@click=${this.handleKeychainClick}
?disabled=${this.keychainGranted === 'granted'}
>
${this.keychainGranted === 'granted' ? 'Encryption Enabled' : 'Enable Encryption'}
</button>
<div class="subtitle" style="visibility: ${this.keychainGranted === 'granted' ? 'hidden' : 'visible'}">
Stores the key to encrypt your data. Press "<b>Always Allow</b>" to continue.
</div>
` : ''}
` : html`
<button <button
class="continue-button" class="continue-button"
@click=${this.handleContinue} @click=${this.handleContinue}
> >
Continue to Pickle Glass Continue to Pickle Glass
</button> </button>
`} ` : ''}
</div> </div>
</div> </div>
`; `;

View File

@ -503,7 +503,6 @@ export class AskView extends LitElement {
padding: 0; padding: 0;
height: 0; height: 0;
overflow: hidden; overflow: hidden;
border-top: none;
} }
.text-input-container.no-response { .text-input-container.no-response {
@ -1422,7 +1421,7 @@ export class AskView extends LitElement {
const targetHeight = Math.min(700, idealHeight); const targetHeight = Math.min(700, idealHeight);
window.api.askView.adjustWindowHeight("ask", targetHeight); window.api.askView.adjustWindowHeight(targetHeight);
}).catch(err => console.error('AskView adjustWindowHeight error:', err)); }).catch(err => console.error('AskView adjustWindowHeight error:', err));
} }

View File

@ -536,7 +536,7 @@ export class ListenView extends LitElement {
`[Height Adjusted] Mode: ${this.viewMode}, TopBar: ${topBarHeight}px, Content: ${contentHeight}px, Ideal: ${idealHeight}px, Target: ${targetHeight}px` `[Height Adjusted] Mode: ${this.viewMode}, TopBar: ${topBarHeight}px, Content: ${contentHeight}px, Ideal: ${idealHeight}px, Target: ${targetHeight}px`
); );
window.api.listenView.adjustWindowHeight('listen', targetHeight); window.api.listenView.adjustWindowHeight(targetHeight);
}) })
.catch(error => { .catch(error => {
console.error('Error in adjustWindowHeight:', error); console.error('Error in adjustWindowHeight:', error);

View File

@ -422,12 +422,6 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
try { try {
if (isMacOS) { if (isMacOS) {
const sessionActive = await window.api.listenCapture.isSessionActive();
if (!sessionActive) {
throw new Error('STT sessions not initialized - please wait for initialization to complete');
}
// On macOS, use SystemAudioDump for audio and getDisplayMedia for screen // On macOS, use SystemAudioDump for audio and getDisplayMedia for screen
console.log('Starting macOS capture with SystemAudioDump...'); console.log('Starting macOS capture with SystemAudioDump...');
@ -472,12 +466,6 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
console.log('macOS screen capture started - audio handled by SystemAudioDump'); console.log('macOS screen capture started - audio handled by SystemAudioDump');
} else if (isLinux) { } else if (isLinux) {
const sessionActive = await window.api.listenCapture.isSessionActive();
if (!sessionActive) {
throw new Error('STT sessions not initialized - please wait for initialization to complete');
}
// Linux - use display media for screen capture and getUserMedia for microphone // Linux - use display media for screen capture and getUserMedia for microphone
mediaStream = await navigator.mediaDevices.getDisplayMedia({ mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: { video: {

View File

@ -918,8 +918,6 @@ export class SettingsView extends LitElement {
this.setupIpcListeners(); this.setupIpcListeners();
this.setupWindowResize(); this.setupWindowResize();
this.loadAutoUpdateSetting(); this.loadAutoUpdateSetting();
// Force one height calculation immediately (innerHeight may be 0 at first)
setTimeout(() => this.updateScrollHeight(), 0);
} }
disconnectedCallback() { disconnectedCallback() {
@ -1032,13 +1030,11 @@ export class SettingsView extends LitElement {
} }
updateScrollHeight() { updateScrollHeight() {
// Electron 일부 시점에서 window.innerHeight 가 0 으로 보고되는 버그 보호 const windowHeight = window.innerHeight;
const rawHeight = window.innerHeight || (window.screen ? window.screen.height : 0); const maxHeight = windowHeight;
const MIN_HEIGHT = 300; // 최소 보장 높이
const maxHeight = Math.max(MIN_HEIGHT, rawHeight);
this.style.maxHeight = `${maxHeight}px`; this.style.maxHeight = `${maxHeight}px`;
const container = this.shadowRoot?.querySelector('.settings-container'); const container = this.shadowRoot?.querySelector('.settings-container');
if (container) { if (container) {
container.style.maxHeight = `${maxHeight}px`; container.style.maxHeight = `${maxHeight}px`;
@ -1047,8 +1043,6 @@ export class SettingsView extends LitElement {
handleMouseEnter = () => { handleMouseEnter = () => {
window.api.settingsView.cancelHideSettingsWindow(); window.api.settingsView.cancelHideSettingsWindow();
// Recalculate height in case it was set to 0 before
this.updateScrollHeight();
} }
handleMouseLeave = () => { handleMouseLeave = () => {

View File

@ -171,7 +171,6 @@ export class ShortcutSettingsView extends LitElement {
async handleSave() { async handleSave() {
if (!window.api) return; if (!window.api) return;
this.feedback = {};
const result = await window.api.shortcutSettingsView.saveShortcuts(this.shortcuts); const result = await window.api.shortcutSettingsView.saveShortcuts(this.shortcuts);
if (!result.success) { if (!result.success) {
alert('Failed to save shortcuts: ' + result.error); alert('Failed to save shortcuts: ' + result.error);
@ -180,7 +179,6 @@ export class ShortcutSettingsView extends LitElement {
handleClose() { handleClose() {
if (!window.api) return; if (!window.api) return;
this.feedback = {};
window.api.shortcutSettingsView.closeShortcutSettingsWindow(); window.api.shortcutSettingsView.closeShortcutSettingsWindow();
} }

View File

@ -1,8 +1,11 @@
const { screen } = require('electron'); const { screen } = require('electron');
class SmoothMovementManager { class SmoothMovementManager {
constructor(windowPool) { constructor(windowPool, getDisplayById, getCurrentDisplay, updateLayout) {
this.windowPool = windowPool; this.windowPool = windowPool;
this.getDisplayById = getDisplayById;
this.getCurrentDisplay = getCurrentDisplay;
this.updateLayout = updateLayout;
this.stepSize = 80; this.stepSize = 80;
this.animationDuration = 300; this.animationDuration = 300;
this.headerPosition = { x: 0, y: 0 }; this.headerPosition = { x: 0, y: 0 };
@ -11,8 +14,6 @@ class SmoothMovementManager {
this.lastVisiblePosition = null; this.lastVisiblePosition = null;
this.currentDisplayId = null; this.currentDisplayId = null;
this.animationFrameId = null; this.animationFrameId = null;
this.animationTimers = new Map();
} }
/** /**
@ -21,25 +22,164 @@ class SmoothMovementManager {
*/ */
_isWindowValid(win) { _isWindowValid(win) {
if (!win || win.isDestroyed()) { if (!win || win.isDestroyed()) {
// 해당 창의 타이머가 있으면 정리 if (this.isAnimating) {
if (this.animationTimers.has(win)) { console.warn('[MovementManager] Window destroyed mid-animation. Halting.');
clearTimeout(this.animationTimers.get(win)); this.isAnimating = false;
this.animationTimers.delete(win); if (this.animationFrameId) {
clearTimeout(this.animationFrameId);
this.animationFrameId = null;
}
} }
return false; return false;
} }
return true; return true;
} }
moveToDisplay(displayId) {
const header = this.windowPool.get('header');
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
const targetDisplay = this.getDisplayById(displayId);
if (!targetDisplay) return;
const currentBounds = header.getBounds();
const currentDisplay = this.getCurrentDisplay(header);
if (currentDisplay.id === targetDisplay.id) return;
const relativeX = (currentBounds.x - currentDisplay.workArea.x) / currentDisplay.workAreaSize.width;
const relativeY = (currentBounds.y - currentDisplay.workArea.y) / currentDisplay.workAreaSize.height;
const targetX = targetDisplay.workArea.x + targetDisplay.workAreaSize.width * relativeX;
const targetY = targetDisplay.workArea.y + targetDisplay.workAreaSize.height * relativeY;
const finalX = Math.max(targetDisplay.workArea.x, Math.min(targetDisplay.workArea.x + targetDisplay.workAreaSize.width - currentBounds.width, targetX));
const finalY = Math.max(targetDisplay.workArea.y, Math.min(targetDisplay.workArea.y + targetDisplay.workAreaSize.height - currentBounds.height, targetY));
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
this.animateToPosition(header, finalX, finalY);
this.currentDisplayId = targetDisplay.id;
}
hideToEdge(edge, callback, { instant = false } = {}) {
const header = this.windowPool.get('header');
if (!header || header.isDestroyed()) {
if (typeof callback === 'function') callback();
return;
}
const { x, y } = header.getBounds();
this.lastVisiblePosition = { x, y };
this.hiddenPosition = { edge };
if (instant) {
header.hide();
if (typeof callback === 'function') callback();
return;
}
header.webContents.send('window-hide-animation');
setTimeout(() => {
if (!header.isDestroyed()) header.hide();
if (typeof callback === 'function') callback();
}, 5);
}
showFromEdge(callback) {
const header = this.windowPool.get('header');
if (!header || header.isDestroyed()) {
if (typeof callback === 'function') callback();
return;
}
// 숨기기 전에 기억해둔 위치 복구
if (this.lastVisiblePosition) {
header.setPosition(
this.lastVisiblePosition.x,
this.lastVisiblePosition.y,
false // animate: false
);
}
header.show();
header.webContents.send('window-show-animation');
// 내부 상태 초기화
this.hiddenPosition = null;
this.lastVisiblePosition = null;
if (typeof callback === 'function') callback();
}
moveStep(direction) {
const header = this.windowPool.get('header');
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
const currentBounds = header.getBounds();
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
let targetX = this.headerPosition.x;
let targetY = this.headerPosition.y;
console.log(`[MovementManager] Moving ${direction} from (${targetX}, ${targetY})`);
const windowSize = {
width: currentBounds.width,
height: currentBounds.height
};
switch (direction) {
case 'left': targetX -= this.stepSize; break;
case 'right': targetX += this.stepSize; break;
case 'up': targetY -= this.stepSize; break;
case 'down': targetY += this.stepSize; break;
default: return;
}
// Find the display that contains or is nearest to the target position
const nearestDisplay = screen.getDisplayNearestPoint({ x: targetX, y: targetY });
const { x: workAreaX, y: workAreaY, width: workAreaWidth, height: workAreaHeight } = nearestDisplay.workArea;
// Only clamp if the target position would actually go out of bounds
let clampedX = targetX;
let clampedY = targetY;
// Check horizontal bounds
if (targetX < workAreaX) {
clampedX = workAreaX;
} else if (targetX + currentBounds.width > workAreaX + workAreaWidth) {
clampedX = workAreaX + workAreaWidth - currentBounds.width;
}
// Check vertical bounds
if (targetY < workAreaY) {
clampedY = workAreaY;
console.log(`[MovementManager] Clamped Y to top edge: ${clampedY}`);
} else if (targetY + currentBounds.height > workAreaY + workAreaHeight) {
clampedY = workAreaY + workAreaHeight - currentBounds.height;
console.log(`[MovementManager] Clamped Y to bottom edge: ${clampedY}`);
}
console.log(`[MovementManager] Final position: (${clampedX}, ${clampedY}), Work area: ${workAreaX},${workAreaY} ${workAreaWidth}x${workAreaHeight}`);
// Only move if there's an actual change in position
if (clampedX === this.headerPosition.x && clampedY === this.headerPosition.y) {
console.log(`[MovementManager] No position change, skipping animation`);
return;
}
this.animateToPosition(header, clampedX, clampedY, windowSize);
}
/** /**
* * [수정됨] 창을 목표 지점으로 부드럽게 애니메이션합니다.
* @param {BrowserWindow} win * 완료 콜백 기타 옵션을 지원합니다.
* @param {number} targetX * @param {BrowserWindow} win - 애니메이션할
* @param {number} targetY * @param {number} targetX - 목표 X 좌표
* @param {object} [options] * @param {number} targetY - 목표 Y 좌표
* @param {object} [options.sizeOverride] * @param {object} [options] - 추가 옵션
* @param {function} [options.onComplete] * @param {object} [options.sizeOverride] - 애니메이션 사용할 크기
* @param {number} [options.duration] * @param {function} [options.onComplete] - 애니메이션 완료 실행할 콜백
* @param {number} [options.duration] - 애니메이션 지속 시간 (ms)
*/ */
animateWindow(win, targetX, targetY, options = {}) { animateWindow(win, targetX, targetY, options = {}) {
if (!this._isWindowValid(win)) { if (!this._isWindowValid(win)) {
@ -54,6 +194,7 @@ class SmoothMovementManager {
const { width, height } = sizeOverride || start; const { width, height } = sizeOverride || start;
const step = () => { const step = () => {
// 애니메이션 중간에 창이 파괴될 경우 콜백을 실행하고 중단
if (!this._isWindowValid(win)) { if (!this._isWindowValid(win)) {
if (onComplete) onComplete(); if (onComplete) onComplete();
return; return;
@ -67,116 +208,112 @@ class SmoothMovementManager {
win.setBounds({ x: Math.round(x), y: Math.round(y), width, height }); win.setBounds({ x: Math.round(x), y: Math.round(y), width, height });
if (p < 1) { if (p < 1) {
setTimeout(step, 8); setTimeout(step, 8); // requestAnimationFrame 대신 setTimeout으로 간결하게 처리
} else { } else {
this.layoutManager.updateLayout(); // 애니메이션 종료
this.updateLayout(); // 레이아웃 재정렬
if (onComplete) { if (onComplete) {
onComplete(); onComplete(); // 완료 콜백 실행
} }
} }
}; };
step(); step();
} }
fade(win, { from, to, duration = 250, onComplete }) { animateToPosition(header, targetX, targetY, windowSize) {
if (!this._isWindowValid(win)) { if (!this._isWindowValid(header)) return;
if (onComplete) onComplete();
return;
}
const startOpacity = from ?? win.getOpacity();
const startTime = Date.now();
const step = () => { this.isAnimating = true;
if (!this._isWindowValid(win)) { const startX = this.headerPosition.x;
if (onComplete) onComplete(); return; const startY = this.headerPosition.y;
} const startTime = Date.now();
const progress = Math.min(1, (Date.now() - startTime) / duration);
const eased = 1 - Math.pow(1 - progress, 3);
win.setOpacity(startOpacity + (to - startOpacity) * eased);
if (progress < 1) {
setTimeout(step, 8);
} else {
win.setOpacity(to);
if (onComplete) onComplete();
}
};
step();
}
animateWindowBounds(win, targetBounds, options = {}) {
if (this.animationTimers.has(win)) {
clearTimeout(this.animationTimers.get(win));
}
if (!this._isWindowValid(win)) { if (!Number.isFinite(targetX) || !Number.isFinite(targetY) || !Number.isFinite(startX) || !Number.isFinite(startY)) {
if (options.onComplete) options.onComplete(); this.isAnimating = false;
return; return;
} }
this.isAnimating = true; const animate = () => {
if (!this._isWindowValid(header)) return;
const startBounds = win.getBounds(); const elapsed = Date.now() - startTime;
const startTime = Date.now(); const progress = Math.min(elapsed / this.animationDuration, 1);
const duration = options.duration || this.animationDuration; const eased = 1 - Math.pow(1 - progress, 3);
const currentX = startX + (targetX - startX) * eased;
const step = () => { const currentY = startY + (targetY - startY) * eased;
if (!this._isWindowValid(win)) {
if (options.onComplete) options.onComplete(); if (!Number.isFinite(currentX) || !Number.isFinite(currentY)) {
this.isAnimating = false;
return; return;
} }
const progress = Math.min(1, (Date.now() - startTime) / duration); if (!this._isWindowValid(header)) return;
const eased = 1 - Math.pow(1 - progress, 3); const { width, height } = windowSize || header.getBounds();
header.setBounds({
const newBounds = { x: Math.round(currentX),
x: Math.round(startBounds.x + (targetBounds.x - startBounds.x) * eased), y: Math.round(currentY),
y: Math.round(startBounds.y + (targetBounds.y - startBounds.y) * eased), width,
width: Math.round(startBounds.width + ((targetBounds.width ?? startBounds.width) - startBounds.width) * eased), height
height: Math.round(startBounds.height + ((targetBounds.height ?? startBounds.height) - startBounds.height) * eased), });
};
win.setBounds(newBounds);
if (progress < 1) { if (progress < 1) {
const timerId = setTimeout(step, 8); this.animationFrameId = setTimeout(animate, 8);
this.animationTimers.set(win, timerId);
} else { } else {
win.setBounds(targetBounds); this.animationFrameId = null;
this.animationTimers.delete(win); this.isAnimating = false;
if (Number.isFinite(targetX) && Number.isFinite(targetY)) {
if (this.animationTimers.size === 0) { if (!this._isWindowValid(header)) return;
this.isAnimating = false; header.setPosition(Math.round(targetX), Math.round(targetY));
// Update header position to the actual final position
this.headerPosition = { x: Math.round(targetX), y: Math.round(targetY) };
} }
this.updateLayout();
if (options.onComplete) options.onComplete();
} }
}; };
step(); animate();
} }
animateWindowPosition(win, targetPosition, options = {}) { moveToEdge(direction) {
if (!this._isWindowValid(win)) { const header = this.windowPool.get('header');
if (options.onComplete) options.onComplete(); if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
return;
} const display = this.getCurrentDisplay(header);
const currentBounds = win.getBounds(); const { width, height } = display.workAreaSize;
const targetBounds = { ...currentBounds, ...targetPosition }; const { x: workAreaX, y: workAreaY } = display.workArea;
this.animateWindowBounds(win, targetBounds, options); const currentBounds = header.getBounds();
}
const windowSize = {
animateLayout(layout, animated = true) { width: currentBounds.width,
if (!layout) return; height: currentBounds.height
for (const winName in layout) { };
const win = this.windowPool.get(winName);
const targetBounds = layout[winName]; let targetX = currentBounds.x;
if (win && !win.isDestroyed() && targetBounds) { let targetY = currentBounds.y;
if (animated) {
this.animateWindowBounds(win, targetBounds); switch (direction) {
} else { case 'left':
win.setBounds(targetBounds); targetX = workAreaX;
} break;
} case 'right':
targetX = workAreaX + width - windowSize.width;
break;
case 'up':
targetY = workAreaY;
break;
case 'down':
targetY = workAreaY + height - windowSize.height;
break;
} }
header.setBounds({
x: Math.round(targetX),
y: Math.round(targetY),
width: windowSize.width,
height: windowSize.height
});
this.headerPosition = { x: targetX, y: targetY };
this.updateLayout();
} }
destroy() { destroy() {

View File

@ -27,15 +27,130 @@ class WindowLayoutManager {
this.PADDING = 80; this.PADDING = 80;
} }
getHeaderPosition = () => { updateLayout() {
const header = this.windowPool.get('header'); if (this.isUpdating) return;
if (header) { this.isUpdating = true;
const [x, y] = header.getPosition();
return { x, y };
}
return { x: 0, y: 0 };
};
setImmediate(() => {
this.positionWindows();
this.isUpdating = false;
});
}
/**
*
* @param {object} [visibilityOverride] - { listen: true, ask: true }
* @returns {{listen: {x:number, y:number}|null, ask: {x:number, y:number}|null}}
*/
getTargetBoundsForFeatureWindows(visibilityOverride = {}) {
const header = this.windowPool.get('header');
if (!header?.getBounds) return {};
const headerBounds = header.getBounds();
const display = getCurrentDisplay(header);
const { width: screenWidth, height: screenHeight } = display.workAreaSize;
const { x: workAreaX, y: workAreaY } = display.workArea;
const ask = this.windowPool.get('ask');
const listen = this.windowPool.get('listen');
const askVis = visibilityOverride.ask !== undefined ?
visibilityOverride.ask :
(ask && ask.isVisible() && !ask.isDestroyed());
const listenVis = visibilityOverride.listen !== undefined ?
visibilityOverride.listen :
(listen && listen.isVisible() && !listen.isDestroyed());
if (!askVis && !listenVis) return {};
const PAD = 8;
const headerTopRel = headerBounds.y - workAreaY;
const headerBottomRel = headerTopRel + headerBounds.height;
const headerCenterXRel = headerBounds.x - workAreaX + headerBounds.width / 2;
const relativeX = headerCenterXRel / screenWidth;
const relativeY = (headerBounds.y - workAreaY) / screenHeight;
const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY);
const askB = ask ? ask.getBounds() : null;
const listenB = listen ? listen.getBounds() : null;
const result = { listen: null, ask: null };
if (askVis && listenVis) {
let askXRel = headerCenterXRel - (askB.width / 2);
let listenXRel = askXRel - listenB.width - PAD;
if (listenXRel < PAD) {
listenXRel = PAD;
askXRel = listenXRel + listenB.width + PAD;
}
if (askXRel + askB.width > screenWidth - PAD) {
askXRel = screenWidth - PAD - askB.width;
listenXRel = askXRel - listenB.width - PAD;
}
// [수정] 'above'일 경우 하단 정렬, 'below'일 경우 상단 정렬
if (strategy.primary === 'above') {
const windowBottomAbs = headerBounds.y - PAD;
const askY = windowBottomAbs - askB.height;
const listenY = windowBottomAbs - listenB.height;
result.ask = { x: Math.round(askXRel + workAreaX), y: Math.round(askY) };
result.listen = { x: Math.round(listenXRel + workAreaX), y: Math.round(listenY) };
} else { // 'below'
const yPos = headerBottomRel + PAD;
const yAbs = yPos + workAreaY;
result.ask = { x: Math.round(askXRel + workAreaX), y: Math.round(yAbs) };
result.listen = { x: Math.round(listenXRel + workAreaX), y: Math.round(yAbs) };
}
} else { // 한 창만 보일 때는 기존 로직 유지 (정상 동작 확인)
const winB = askVis ? askB : listenB;
let xRel = headerCenterXRel - winB.width / 2;
xRel = Math.max(PAD, Math.min(screenWidth - winB.width - PAD, xRel));
let yPos;
if (strategy.primary === 'above') {
const windowBottomRel = headerTopRel - PAD;
yPos = windowBottomRel - winB.height;
} else { // 'below'
yPos = headerBottomRel + PAD;
}
const abs = { x: Math.round(xRel + workAreaX), y: Math.round(yPos + workAreaY) };
if (askVis) result.ask = abs;
if (listenVis) result.listen = abs;
}
return result;
}
positionWindows() {
const header = this.windowPool.get('header');
if (!header?.getBounds) return;
const headerBounds = header.getBounds();
const display = getCurrentDisplay(header);
const { width: screenWidth, height: screenHeight } = display.workAreaSize;
const { x: workAreaX, y: workAreaY } = display.workArea;
const headerCenterX = headerBounds.x - workAreaX + headerBounds.width / 2;
const headerCenterY = headerBounds.y - workAreaY + headerBounds.height / 2;
const relativeX = headerCenterX / screenWidth;
const relativeY = headerCenterY / screenHeight;
const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY);
this.positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY);
const settings = this.windowPool.get('settings');
if (settings && !settings.isDestroyed() && settings.isVisible()) {
const settingPos = this.calculateSettingsWindowPosition();
if (settingPos) {
const { width, height } = settings.getBounds();
settings.setBounds({ x: settingPos.x, y: settingPos.y, width, height });
}
}
}
/** /**
* *
@ -64,6 +179,67 @@ class WindowLayoutManager {
} }
positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) {
const ask = this.windowPool.get('ask');
const listen = this.windowPool.get('listen');
const askVisible = ask && ask.isVisible() && !ask.isDestroyed();
const listenVisible = listen && listen.isVisible() && !listen.isDestroyed();
if (!askVisible && !listenVisible) return;
const PAD = 8;
const headerTopRel = headerBounds.y - workAreaY;
const headerBottomRel = headerTopRel + headerBounds.height;
const headerCenterXRel = headerBounds.x - workAreaX + headerBounds.width / 2;
let askBounds = askVisible ? ask.getBounds() : null;
let listenBounds = listenVisible ? listen.getBounds() : null;
if (askVisible && listenVisible) {
let askXRel = headerCenterXRel - (askBounds.width / 2);
let listenXRel = askXRel - listenBounds.width - PAD;
if (listenXRel < PAD) {
listenXRel = PAD;
askXRel = listenXRel + listenBounds.width + PAD;
}
if (askXRel + askBounds.width > screenWidth - PAD) {
askXRel = screenWidth - PAD - askBounds.width;
listenXRel = askXRel - listenBounds.width - PAD;
}
// [수정] 'above'일 경우 하단 정렬, 'below'일 경우 상단 정렬
if (strategy.primary === 'above') {
const windowBottomAbs = headerBounds.y - PAD;
const askY = windowBottomAbs - askBounds.height;
const listenY = windowBottomAbs - listenBounds.height;
ask.setBounds({ x: Math.round(askXRel + workAreaX), y: Math.round(askY), width: askBounds.width, height: askBounds.height });
listen.setBounds({ x: Math.round(listenXRel + workAreaX), y: Math.round(listenY), width: listenBounds.width, height: listenBounds.height });
} else { // 'below'
const yPos = headerBottomRel + PAD;
const yAbs = yPos + workAreaY;
ask.setBounds({ x: Math.round(askXRel + workAreaX), y: Math.round(yAbs), width: askBounds.width, height: askBounds.height });
listen.setBounds({ x: Math.round(listenXRel + workAreaX), y: Math.round(yAbs), width: listenBounds.width, height: listenBounds.height });
}
} else { // 한 창만 보일 때는 기존 로직 유지 (정상 동작 확인)
const win = askVisible ? ask : listen;
const winBounds = askVisible ? askBounds : listenBounds;
let xRel = headerCenterXRel - winBounds.width / 2;
xRel = Math.max(PAD, Math.min(screenWidth - winBounds.width - PAD, xRel));
let yPos;
if (strategy.primary === 'above') {
const windowBottomRel = headerTopRel - PAD;
yPos = windowBottomRel - winBounds.height;
} else { // 'below'
yPos = headerBottomRel + PAD;
}
const yAbs = yPos + workAreaY;
win.setBounds({ x: Math.round(xRel + workAreaX), y: Math.round(yAbs), width: winBounds.width, height: winBounds.height });
}
}
/** /**
* @returns {{x: number, y: number} | null} * @returns {{x: number, y: number} | null}
@ -93,206 +269,26 @@ class WindowLayoutManager {
return { x: Math.round(clampedX), y: Math.round(clampedY) }; return { x: Math.round(clampedX), y: Math.round(clampedY) };
} }
positionShortcutSettingsWindow() {
calculateHeaderResize(header, { width, height }) {
if (!header) return null;
const currentBounds = header.getBounds();
const centerX = currentBounds.x + currentBounds.width / 2;
const newX = Math.round(centerX - width / 2);
const display = getCurrentDisplay(header);
const { x: workAreaX, width: workAreaWidth } = display.workArea;
const clampedX = Math.max(workAreaX, Math.min(workAreaX + workAreaWidth - width, newX));
return { x: clampedX, y: currentBounds.y, width, height };
}
calculateClampedPosition(header, { x: newX, y: newY }) {
if (!header) return null;
const targetDisplay = screen.getDisplayNearestPoint({ x: newX, y: newY });
const { x: workAreaX, y: workAreaY, width, height } = targetDisplay.workArea;
const headerBounds = header.getBounds();
const clampedX = Math.max(workAreaX, Math.min(newX, workAreaX + width - headerBounds.width));
const clampedY = Math.max(workAreaY, Math.min(newY, workAreaY + height - headerBounds.height));
return { x: clampedX, y: clampedY };
}
calculateWindowHeightAdjustment(senderWindow, targetHeight) {
if (!senderWindow) return null;
const currentBounds = senderWindow.getBounds();
const minHeight = senderWindow.getMinimumSize()[1];
const maxHeight = senderWindow.getMaximumSize()[1];
let adjustedHeight = Math.max(minHeight, targetHeight);
if (maxHeight > 0) {
adjustedHeight = Math.min(maxHeight, adjustedHeight);
}
console.log(`[Layout Debug] calculateWindowHeightAdjustment: targetHeight=${targetHeight}`);
return { ...currentBounds, height: adjustedHeight };
}
// 기존 getTargetBoundsForFeatureWindows를 이 함수로 대체합니다.
calculateFeatureWindowLayout(visibility, headerBoundsOverride = null) {
const header = this.windowPool.get('header');
const headerBounds = headerBoundsOverride || (header ? header.getBounds() : null);
if (!headerBounds) return {};
let display;
if (headerBoundsOverride) {
const boundsCenter = {
x: headerBounds.x + headerBounds.width / 2,
y: headerBounds.y + headerBounds.height / 2,
};
display = screen.getDisplayNearestPoint(boundsCenter);
} else {
display = getCurrentDisplay(header);
}
const { width: screenWidth, height: screenHeight, x: workAreaX, y: workAreaY } = display.workArea;
const ask = this.windowPool.get('ask');
const listen = this.windowPool.get('listen');
const askVis = visibility.ask && ask && !ask.isDestroyed();
const listenVis = visibility.listen && listen && !listen.isDestroyed();
if (!askVis && !listenVis) return {};
const PAD = 8;
const headerTopRel = headerBounds.y - workAreaY;
const headerBottomRel = headerTopRel + headerBounds.height;
const headerCenterXRel = headerBounds.x - workAreaX + headerBounds.width / 2;
const relativeX = headerCenterXRel / screenWidth;
const relativeY = (headerBounds.y - workAreaY) / screenHeight;
const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY);
const askB = askVis ? ask.getBounds() : null;
const listenB = listenVis ? listen.getBounds() : null;
if (askVis) {
console.log(`[Layout Debug] Ask Window Bounds: height=${askB.height}, width=${askB.width}`);
}
if (listenVis) {
console.log(`[Layout Debug] Listen Window Bounds: height=${listenB.height}, width=${listenB.width}`);
}
const layout = {};
if (askVis && listenVis) {
let askXRel = headerCenterXRel - (askB.width / 2);
let listenXRel = askXRel - listenB.width - PAD;
if (listenXRel < PAD) {
listenXRel = PAD;
askXRel = listenXRel + listenB.width + PAD;
}
if (askXRel + askB.width > screenWidth - PAD) {
askXRel = screenWidth - PAD - askB.width;
listenXRel = askXRel - listenB.width - PAD;
}
if (strategy.primary === 'above') {
const windowBottomAbs = headerBounds.y - PAD;
layout.ask = { x: Math.round(askXRel + workAreaX), y: Math.round(windowBottomAbs - askB.height), width: askB.width, height: askB.height };
layout.listen = { x: Math.round(listenXRel + workAreaX), y: Math.round(windowBottomAbs - listenB.height), width: listenB.width, height: listenB.height };
} else { // 'below'
const yAbs = headerBounds.y + headerBounds.height + PAD;
layout.ask = { x: Math.round(askXRel + workAreaX), y: Math.round(yAbs), width: askB.width, height: askB.height };
layout.listen = { x: Math.round(listenXRel + workAreaX), y: Math.round(yAbs), width: listenB.width, height: listenB.height };
}
} else { // Single window
const winName = askVis ? 'ask' : 'listen';
const winB = askVis ? askB : listenB;
if (!winB) return {};
let xRel = headerCenterXRel - winB.width / 2;
xRel = Math.max(PAD, Math.min(screenWidth - winB.width - PAD, xRel));
let yPos;
if (strategy.primary === 'above') {
yPos = (headerBounds.y - workAreaY) - PAD - winB.height;
} else { // 'below'
yPos = (headerBounds.y - workAreaY) + headerBounds.height + PAD;
}
layout[winName] = { x: Math.round(xRel + workAreaX), y: Math.round(yPos + workAreaY), width: winB.width, height: winB.height };
}
return layout;
}
calculateShortcutSettingsWindowPosition() {
const header = this.windowPool.get('header'); const header = this.windowPool.get('header');
const shortcutSettings = this.windowPool.get('shortcut-settings'); const shortcutSettings = this.windowPool.get('shortcut-settings');
if (!header || !shortcutSettings) return null;
if (!header || header.isDestroyed() || !shortcutSettings || shortcutSettings.isDestroyed()) {
return;
}
const headerBounds = header.getBounds(); const headerBounds = header.getBounds();
const shortcutBounds = shortcutSettings.getBounds(); const shortcutBounds = shortcutSettings.getBounds();
const { workArea } = getCurrentDisplay(header);
let newX = Math.round(headerBounds.x + (headerBounds.width / 2) - (shortcutBounds.width / 2));
let newY = Math.round(headerBounds.y);
newX = Math.max(workArea.x, Math.min(newX, workArea.x + workArea.width - shortcutBounds.width));
newY = Math.max(workArea.y, Math.min(newY, workArea.y + workArea.height - shortcutBounds.height));
return { x: newX, y: newY, width: shortcutBounds.width, height: shortcutBounds.height };
}
calculateStepMovePosition(header, direction) {
if (!header) return null;
const currentBounds = header.getBounds();
const stepSize = 80; // 이동 간격
let targetX = currentBounds.x;
let targetY = currentBounds.y;
switch (direction) {
case 'left': targetX -= stepSize; break;
case 'right': targetX += stepSize; break;
case 'up': targetY -= stepSize; break;
case 'down': targetY += stepSize; break;
}
return this.calculateClampedPosition(header, { x: targetX, y: targetY });
}
calculateEdgePosition(header, direction) {
if (!header) return null;
const display = getCurrentDisplay(header); const display = getCurrentDisplay(header);
const { workArea } = display; const { workArea } = display;
const currentBounds = header.getBounds();
let newX = Math.round(headerBounds.x + (headerBounds.width / 2) - (shortcutBounds.width / 2));
let targetX = currentBounds.x; let newY = Math.round(headerBounds.y);
let targetY = currentBounds.y;
newX = Math.max(workArea.x, Math.min(newX, workArea.x + workArea.width - shortcutBounds.width));
switch (direction) { newY = Math.max(workArea.y, Math.min(newY, workArea.y + workArea.height - shortcutBounds.height));
case 'left': targetX = workArea.x; break;
case 'right': targetX = workArea.x + workArea.width - currentBounds.width; break; shortcutSettings.setBounds({ x: newX, y: newY, width: shortcutBounds.width, height: shortcutBounds.height });
case 'up': targetY = workArea.y; break;
case 'down': targetY = workArea.y + workArea.height - currentBounds.height; break;
}
return { x: targetX, y: targetY };
}
calculateNewPositionForDisplay(window, targetDisplayId) {
if (!window) return null;
const targetDisplay = screen.getAllDisplays().find(d => d.id === targetDisplayId);
if (!targetDisplay) return null;
const currentBounds = window.getBounds();
const currentDisplay = getCurrentDisplay(window);
if (currentDisplay.id === targetDisplay.id) return { x: currentBounds.x, y: currentBounds.y };
const relativeX = (currentBounds.x - currentDisplay.workArea.x) / currentDisplay.workArea.width;
const relativeY = (currentBounds.y - currentDisplay.workArea.y) / currentDisplay.workArea.height;
const targetX = targetDisplay.workArea.x + targetDisplay.workArea.width * relativeX;
const targetY = targetDisplay.workArea.y + targetDisplay.workArea.height * relativeY;
const clampedX = Math.max(targetDisplay.workArea.x, Math.min(targetX, targetDisplay.workArea.x + targetDisplay.workArea.width - currentBounds.width));
const clampedY = Math.max(targetDisplay.workArea.y, Math.min(targetY, targetDisplay.workArea.y + targetDisplay.workArea.height - currentBounds.height));
return { x: Math.round(clampedX), y: Math.round(clampedY) };
} }
/** /**

View File

@ -30,6 +30,8 @@ if (shouldUseLiquidGlass) {
let isContentProtectionOn = true; let isContentProtectionOn = true;
let lastVisibleWindows = new Set(['header']); let lastVisibleWindows = new Set(['header']);
const HEADER_HEIGHT = 47;
const DEFAULT_WINDOW_WIDTH = 353;
let currentHeaderState = 'apikey'; let currentHeaderState = 'apikey';
const windowPool = new Map(); const windowPool = new Map();
@ -38,26 +40,50 @@ let settingsHideTimer = null;
let layoutManager = null; let layoutManager = null;
function updateLayout() {
if (layoutManager) {
layoutManager.updateLayout();
}
}
let movementManager = null; let movementManager = null;
function updateChildWindowLayouts(animated = true) { const FADE_DURATION = 250;
// if (movementManager.isAnimating) return; const FADE_FPS = 60;
const visibleWindows = {}; /**
const listenWin = windowPool.get('listen'); * 윈도우 투명도를 서서히 변경한다.
const askWin = windowPool.get('ask'); * @param {BrowserWindow} win
if (listenWin && !listenWin.isDestroyed() && listenWin.isVisible()) { * @param {number} from
visibleWindows.listen = true; * @param {number} to
* @param {number} duration
* @param {Function=} onComplete
*/
function fadeWindow(win, from, to, duration = FADE_DURATION, onComplete) {
if (!win || win.isDestroyed()) return;
const steps = Math.max(1, Math.round(duration / (1000 / FADE_FPS)));
let currentStep = 0;
win.setOpacity(from);
const timer = setInterval(() => {
if (win.isDestroyed()) { clearInterval(timer); return; }
currentStep += 1;
const progress = currentStep / steps;
const eased = progress < 1
? 1 - Math.pow(1 - progress, 3)
: 1;
win.setOpacity(from + (to - from) * eased);
if (currentStep >= steps) {
clearInterval(timer);
win.setOpacity(to);
onComplete && onComplete();
} }
if (askWin && !askWin.isDestroyed() && askWin.isVisible()) { }, 1000 / FADE_FPS);
visibleWindows.ask = true;
}
if (Object.keys(visibleWindows).length === 0) return;
const newLayout = layoutManager.calculateFeatureWindowLayout(visibleWindows);
movementManager.animateLayout(newLayout, animated);
} }
const showSettingsWindow = () => { const showSettingsWindow = () => {
@ -72,34 +98,6 @@ const cancelHideSettingsWindow = () => {
internalBridge.emit('window:requestVisibility', { name: 'settings', visible: true }); internalBridge.emit('window:requestVisibility', { name: 'settings', visible: true });
}; };
const moveWindowStep = (direction) => {
internalBridge.emit('window:moveStep', { direction });
};
const resizeHeaderWindow = ({ width, height }) => {
internalBridge.emit('window:resizeHeaderWindow', { width, height });
};
const handleHeaderAnimationFinished = (state) => {
internalBridge.emit('window:headerAnimationFinished', state);
};
const getHeaderPosition = () => {
return new Promise((resolve) => {
internalBridge.emit('window:getHeaderPosition', (position) => {
resolve(position);
});
});
};
const moveHeaderTo = (newX, newY) => {
internalBridge.emit('window:moveHeaderTo', { newX, newY });
};
const adjustWindowHeight = (winName, targetHeight) => {
internalBridge.emit('window:adjustWindowHeight', { winName, targetHeight });
};
function setupWindowController(windowPool, layoutManager, movementManager) { function setupWindowController(windowPool, layoutManager, movementManager) {
internalBridge.on('window:requestVisibility', ({ name, visible }) => { internalBridge.on('window:requestVisibility', ({ name, visible }) => {
@ -108,110 +106,6 @@ function setupWindowController(windowPool, layoutManager, movementManager) {
internalBridge.on('window:requestToggleAllWindowsVisibility', ({ targetVisibility }) => { internalBridge.on('window:requestToggleAllWindowsVisibility', ({ targetVisibility }) => {
changeAllWindowsVisibility(windowPool, targetVisibility); changeAllWindowsVisibility(windowPool, targetVisibility);
}); });
internalBridge.on('window:moveToDisplay', ({ displayId }) => {
// movementManager.moveToDisplay(displayId);
const header = windowPool.get('header');
if (header) {
const newPosition = layoutManager.calculateNewPositionForDisplay(header, displayId);
if (newPosition) {
movementManager.animateWindowPosition(header, newPosition, {
onComplete: () => updateChildWindowLayouts(true)
});
}
}
});
internalBridge.on('window:moveToEdge', ({ direction }) => {
const header = windowPool.get('header');
if (header) {
const newPosition = layoutManager.calculateEdgePosition(header, direction);
movementManager.animateWindowPosition(header, newPosition, {
onComplete: () => updateChildWindowLayouts(true)
});
}
});
internalBridge.on('window:moveStep', ({ direction }) => {
const header = windowPool.get('header');
if (header) {
const newHeaderPosition = layoutManager.calculateStepMovePosition(header, direction);
if (!newHeaderPosition) return;
const futureHeaderBounds = { ...header.getBounds(), ...newHeaderPosition };
const visibleWindows = {};
const listenWin = windowPool.get('listen');
const askWin = windowPool.get('ask');
if (listenWin && !listenWin.isDestroyed() && listenWin.isVisible()) {
visibleWindows.listen = true;
}
if (askWin && !askWin.isDestroyed() && askWin.isVisible()) {
visibleWindows.ask = true;
}
const newChildLayout = layoutManager.calculateFeatureWindowLayout(visibleWindows, futureHeaderBounds);
movementManager.animateWindowPosition(header, newHeaderPosition);
movementManager.animateLayout(newChildLayout);
}
});
internalBridge.on('window:resizeHeaderWindow', ({ width, height }) => {
const header = windowPool.get('header');
if (!header || movementManager.isAnimating) return;
const newHeaderBounds = layoutManager.calculateHeaderResize(header, { width, height });
const wasResizable = header.isResizable();
if (!wasResizable) header.setResizable(true);
movementManager.animateWindowBounds(header, newHeaderBounds, {
onComplete: () => {
if (!wasResizable) header.setResizable(false);
updateChildWindowLayouts(true);
}
});
});
internalBridge.on('window:headerAnimationFinished', (state) => {
const header = windowPool.get('header');
if (!header || header.isDestroyed()) return;
if (state === 'hidden') {
header.hide();
} else if (state === 'visible') {
updateChildWindowLayouts(false);
}
});
internalBridge.on('window:getHeaderPosition', (reply) => {
const header = windowPool.get('header');
if (header && !header.isDestroyed()) {
reply(header.getBounds());
} else {
reply({ x: 0, y: 0, width: 0, height: 0 });
}
});
internalBridge.on('window:moveHeaderTo', ({ newX, newY }) => {
const header = windowPool.get('header');
if (header) {
const newPosition = layoutManager.calculateClampedPosition(header, { x: newX, y: newY });
header.setPosition(newPosition.x, newPosition.y);
}
});
internalBridge.on('window:adjustWindowHeight', ({ winName, targetHeight }) => {
console.log(`[Layout Debug] adjustWindowHeight: targetHeight=${targetHeight}`);
const senderWindow = windowPool.get(winName);
if (senderWindow) {
const newBounds = layoutManager.calculateWindowHeightAdjustment(senderWindow, targetHeight);
const wasResizable = senderWindow.isResizable();
if (!wasResizable) senderWindow.setResizable(true);
movementManager.animateWindowBounds(senderWindow, newBounds, {
onComplete: () => {
if (!wasResizable) senderWindow.setResizable(false);
updateChildWindowLayouts(true);
}
});
}
});
} }
function changeAllWindowsVisibility(windowPool, targetVisibility) { function changeAllWindowsVisibility(windowPool, targetVisibility) {
@ -326,10 +220,7 @@ async function handleWindowVisibilityRequest(windowPool, layoutManager, movement
if (name === 'shortcut-settings') { if (name === 'shortcut-settings') {
if (shouldBeVisible) { if (shouldBeVisible) {
// layoutManager.positionShortcutSettingsWindow(); layoutManager.positionShortcutSettingsWindow();
const newBounds = layoutManager.calculateShortcutSettingsWindowPosition();
if (newBounds) win.setBounds(newBounds);
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
win.setAlwaysOnTop(true, 'screen-saver'); win.setAlwaysOnTop(true, 'screen-saver');
} else { } else {
@ -351,55 +242,91 @@ async function handleWindowVisibilityRequest(windowPool, layoutManager, movement
} }
if (name === 'listen' || name === 'ask') { if (name === 'listen' || name === 'ask') {
const win = windowPool.get(name);
const otherName = name === 'listen' ? 'ask' : 'listen'; const otherName = name === 'listen' ? 'ask' : 'listen';
const otherWin = windowPool.get(otherName); const otherWin = windowPool.get(otherName);
const isOtherWinVisible = otherWin && !otherWin.isDestroyed() && otherWin.isVisible(); const isOtherWinVisible = otherWin && !otherWin.isDestroyed() && otherWin.isVisible();
const ANIM_OFFSET_X = 50;
const ANIM_OFFSET_Y = 20;
const finalVisibility = { const ANIM_OFFSET_X = 100;
listen: (name === 'listen' && shouldBeVisible) || (otherName === 'listen' && isOtherWinVisible), const ANIM_OFFSET_Y = 20;
ask: (name === 'ask' && shouldBeVisible) || (otherName === 'ask' && isOtherWinVisible),
};
if (!shouldBeVisible) {
finalVisibility[name] = false;
}
const targetLayout = layoutManager.calculateFeatureWindowLayout(finalVisibility);
if (shouldBeVisible) { if (shouldBeVisible) {
if (!win) return;
const targetBounds = targetLayout[name];
if (!targetBounds) return;
const startPos = { ...targetBounds };
if (name === 'listen') startPos.x -= ANIM_OFFSET_X;
else if (name === 'ask') startPos.y -= ANIM_OFFSET_Y;
win.setOpacity(0); win.setOpacity(0);
win.setBounds(startPos);
win.show();
movementManager.fade(win, { to: 1 }); if (name === 'listen') {
movementManager.animateLayout(targetLayout); if (!isOtherWinVisible) {
const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: false });
if (!targets.listen) return;
const startPos = { x: targets.listen.x - ANIM_OFFSET_X, y: targets.listen.y };
win.setBounds(startPos);
win.show();
fadeWindow(win, 0, 1);
movementManager.animateWindow(win, targets.listen.x, targets.listen.y);
} else {
const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: true });
if (!targets.listen || !targets.ask) return;
const startListenPos = { x: targets.listen.x - ANIM_OFFSET_X, y: targets.listen.y };
win.setBounds(startListenPos);
win.show();
fadeWindow(win, 0, 1);
movementManager.animateWindow(otherWin, targets.ask.x, targets.ask.y);
movementManager.animateWindow(win, targets.listen.x, targets.listen.y);
}
} else if (name === 'ask') {
if (!isOtherWinVisible) {
const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: false, ask: true });
if (!targets.ask) return;
const startPos = { x: targets.ask.x, y: targets.ask.y - ANIM_OFFSET_Y };
win.setBounds(startPos);
win.show();
fadeWindow(win, 0, 1);
movementManager.animateWindow(win, targets.ask.x, targets.ask.y);
} else {
const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: true });
if (!targets.listen || !targets.ask) return;
const startAskPos = { x: targets.ask.x, y: targets.ask.y - ANIM_OFFSET_Y };
win.setBounds(startAskPos);
win.show();
fadeWindow(win, 0, 1);
movementManager.animateWindow(otherWin, targets.listen.x, targets.listen.y);
movementManager.animateWindow(win, targets.ask.x, targets.ask.y);
}
}
} else { } else {
if (!win || !win.isVisible()) return;
const currentBounds = win.getBounds(); const currentBounds = win.getBounds();
const targetPos = { ...currentBounds }; fadeWindow(
if (name === 'listen') targetPos.x -= ANIM_OFFSET_X; win, 1, 0, FADE_DURATION,
else if (name === 'ask') targetPos.y -= ANIM_OFFSET_Y; () => win.hide()
);
if (name === 'listen') {
if (!isOtherWinVisible) {
const targetX = currentBounds.x - ANIM_OFFSET_X;
movementManager.animateWindow(win, targetX, currentBounds.y);
} else {
const targetX = currentBounds.x - currentBounds.width;
movementManager.animateWindow(win, targetX, currentBounds.y);
}
} else if (name === 'ask') {
if (!isOtherWinVisible) {
const targetY = currentBounds.y - ANIM_OFFSET_Y;
movementManager.animateWindow(win, currentBounds.x, targetY);
} else {
const targetAskY = currentBounds.y - ANIM_OFFSET_Y;
movementManager.animateWindow(win, currentBounds.x, targetAskY);
movementManager.fade(win, { to: 0, onComplete: () => win.hide() }); const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: false });
movementManager.animateWindowPosition(win, targetPos); if (targets.listen) {
movementManager.animateWindow(otherWin, targets.listen.x, targets.listen.y);
// 다른 창들도 새 레이아웃으로 애니메이션 }
const otherWindowsLayout = { ...targetLayout }; }
delete otherWindowsLayout[name]; }
movementManager.animateLayout(otherWindowsLayout);
} }
} }
} }
@ -423,6 +350,52 @@ const toggleContentProtection = () => {
return newStatus; return newStatus;
}; };
const resizeHeaderWindow = ({ width, height }) => {
const header = windowPool.get('header');
if (header) {
console.log(`[WindowManager] Resize request: ${width}x${height}`);
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})`);
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);
}
const centerX = currentBounds.x + currentBounds.width / 2;
const newX = Math.round(centerX - width / 2);
const display = getCurrentDisplay(header);
const { x: workAreaX, width: workAreaWidth } = display.workArea;
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);
}
if (updateLayout) {
updateLayout();
}
return { success: true };
}
return { success: false, error: 'Header window not found' };
};
const openLoginPage = () => { const openLoginPage = () => {
const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000'; const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000';
@ -431,6 +404,12 @@ const openLoginPage = () => {
console.log('Opening personalization page:', personalizeUrl); console.log('Opening personalization page:', personalizeUrl);
}; };
const moveWindowStep = (direction) => {
if (movementManager) {
movementManager.moveStep(direction);
}
};
function createFeatureWindows(header, namesToCreate) { function createFeatureWindows(header, namesToCreate) {
// if (windowPool.has('listen')) return; // if (windowPool.has('listen')) return;
@ -444,7 +423,7 @@ function createFeatureWindows(header, namesToCreate) {
hasShadow: false, hasShadow: false,
skipTaskbar: true, skipTaskbar: true,
hiddenInMissionControl: true, hiddenInMissionControl: true,
resizable: false, resizable: true,
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
@ -635,18 +614,24 @@ function getCurrentDisplay(window) {
return screen.getDisplayNearestPoint(windowCenter); return screen.getDisplayNearestPoint(windowCenter);
} }
function getDisplayById(displayId) {
const displays = screen.getAllDisplays();
return displays.find(d => d.id === displayId) || screen.getPrimaryDisplay();
}
function createWindows() { function createWindows() {
const HEADER_HEIGHT = 47;
const DEFAULT_WINDOW_WIDTH = 353;
const primaryDisplay = screen.getPrimaryDisplay(); const primaryDisplay = screen.getPrimaryDisplay();
const { y: workAreaY, width: screenWidth } = primaryDisplay.workArea; const { y: workAreaY, width: screenWidth } = primaryDisplay.workArea;
const initialX = Math.round((screenWidth - DEFAULT_WINDOW_WIDTH) / 2); const initialX = Math.round((screenWidth - DEFAULT_WINDOW_WIDTH) / 2);
const initialY = workAreaY + 21; const initialY = workAreaY + 21;
movementManager = new SmoothMovementManager(windowPool, getDisplayById, getCurrentDisplay, updateLayout);
const header = new BrowserWindow({ const header = new BrowserWindow({
width: DEFAULT_WINDOW_WIDTH, width: DEFAULT_WINDOW_WIDTH,
height: HEADER_HEIGHT, height: HEADER_HEIGHT,
@ -696,23 +681,15 @@ function createWindows() {
}); });
} }
windowPool.set('header', header); windowPool.set('header', header);
header.on('moved', updateLayout);
layoutManager = new WindowLayoutManager(windowPool); layoutManager = new WindowLayoutManager(windowPool);
movementManager = new SmoothMovementManager(windowPool);
header.on('moved', () => {
if (movementManager.isAnimating) {
return;
}
updateChildWindowLayouts(false);
});
header.webContents.once('dom-ready', () => { header.webContents.once('dom-ready', () => {
shortcutsService.initialize(windowPool); shortcutsService.initialize(movementManager, windowPool);
shortcutsService.registerShortcuts(); shortcutsService.registerShortcuts();
}); });
setupIpcHandlers(windowPool, layoutManager); setupIpcHandlers(movementManager);
setupWindowController(windowPool, layoutManager, movementManager); setupWindowController(windowPool, layoutManager, movementManager);
if (currentHeaderState === 'main') { if (currentHeaderState === 'main') {
@ -744,13 +721,16 @@ function createWindows() {
} }
}); });
header.on('resize', () => updateChildWindowLayouts(false)); header.on('resize', () => {
console.log('[WindowManager] Header resize event triggered');
updateLayout();
});
return windowPool; return windowPool;
} }
function setupIpcHandlers(movementManager) {
function setupIpcHandlers(windowPool, layoutManager) { // quit-application handler moved to windowBridge.js to avoid duplication
screen.on('display-added', (event, newDisplay) => { screen.on('display-added', (event, newDisplay) => {
console.log('[Display] New display added:', newDisplay.id); console.log('[Display] New display added:', newDisplay.id);
}); });
@ -758,25 +738,18 @@ function setupIpcHandlers(windowPool, layoutManager) {
screen.on('display-removed', (event, oldDisplay) => { screen.on('display-removed', (event, oldDisplay) => {
console.log('[Display] Display removed:', oldDisplay.id); console.log('[Display] Display removed:', oldDisplay.id);
const header = windowPool.get('header'); const header = windowPool.get('header');
if (header && getCurrentDisplay(header).id === oldDisplay.id) { if (header && getCurrentDisplay(header).id === oldDisplay.id) {
const primaryDisplay = screen.getPrimaryDisplay(); const primaryDisplay = screen.getPrimaryDisplay();
const newPosition = layoutManager.calculateNewPositionForDisplay(header, primaryDisplay.id); movementManager.moveToDisplay(primaryDisplay.id);
if (newPosition) {
// 복구 상황이므로 애니메이션 없이 즉시 이동
header.setPosition(newPosition.x, newPosition.y, false);
updateChildWindowLayouts(false);
}
} }
}); });
screen.on('display-metrics-changed', (event, display, changedMetrics) => { screen.on('display-metrics-changed', (event, display, changedMetrics) => {
// 레이아웃 업데이트 함수를 새 버전으로 호출 // console.log('[Display] Display metrics changed:', display.id, changedMetrics);
updateChildWindowLayouts(false); updateLayout();
}); });
} }
const handleHeaderStateChanged = (state) => { const handleHeaderStateChanged = (state) => {
console.log(`[WindowManager] Header state changed to: ${state}`); console.log(`[WindowManager] Header state changed to: ${state}`);
currentHeaderState = state; currentHeaderState = state;
@ -789,8 +762,96 @@ const handleHeaderStateChanged = (state) => {
internalBridge.emit('reregister-shortcuts'); internalBridge.emit('reregister-shortcuts');
}; };
const handleHeaderAnimationFinished = (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();
}
};
const getHeaderPosition = () => {
const header = windowPool.get('header');
if (header) {
const [x, y] = header.getPosition();
return { x, y };
}
return { x: 0, y: 0 };
};
const moveHeader = (newX, newY) => {
const header = windowPool.get('header');
if (header) {
const currentY = newY !== undefined ? newY : header.getBounds().y;
header.setPosition(newX, currentY, false);
updateLayout();
}
};
const moveHeaderTo = (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();
let clampedX = newX;
let clampedY = newY;
if (newX < workAreaX) {
clampedX = workAreaX;
} else if (newX + headerBounds.width > workAreaX + width) {
clampedX = workAreaX + width - headerBounds.width;
}
if (newY < workAreaY) {
clampedY = workAreaY;
} else if (newY + headerBounds.height > workAreaY + height) {
clampedY = workAreaY + height - headerBounds.height;
}
header.setPosition(clampedX, clampedY, false);
updateLayout();
}
};
const adjustWindowHeight = (sender, targetHeight) => {
const senderWindow = BrowserWindow.fromWebContents(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();
}
};
module.exports = { module.exports = {
updateLayout,
createWindows, createWindows,
windowPool, windowPool,
toggleContentProtection, toggleContentProtection,
@ -804,6 +865,7 @@ module.exports = {
handleHeaderStateChanged, handleHeaderStateChanged,
handleHeaderAnimationFinished, handleHeaderAnimationFinished,
getHeaderPosition, getHeaderPosition,
moveHeader,
moveHeaderTo, moveHeaderTo,
adjustWindowHeight, adjustWindowHeight,
}; };