WIP encryption cleanup + providerSetting refactor

This commit is contained in:
samtiz 2025-07-15 15:49:34 +09:00
parent 94ae002d83
commit ab23c10006
16 changed files with 219 additions and 109 deletions

View File

@ -11,6 +11,7 @@ const presetRepository = require('../features/common/repositories/preset');
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로부터의 요청을 수신하고 서비스로 전달
@ -20,7 +21,6 @@ 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));
@ -37,8 +37,13 @@ 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-permissions-completed', async () => await permissionService.markPermissionsAsCompleted()); ipcMain.handle('mark-keychain-completed', async () => await permissionService.markKeychainCompleted());
ipcMain.handle('check-permissions-completed', async () => await permissionService.checkPermissionsCompleted()); ipcMain.handle('check-keychain-completed', async () => await permissionService.checkKeychainCompleted());
ipcMain.handle('initialize-encryption-key', async () => {
const userId = authService.getCurrentUserId();
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());
@ -109,6 +114,7 @@ module.exports = {
ipcMain.handle('model:get-available-models', (e, { type }) => modelStateService.getAvailableModels(type)); ipcMain.handle('model:get-available-models', (e, { type }) => modelStateService.getAvailableModels(type));
ipcMain.handle('model:are-providers-configured', () => 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', () => modelStateService.initialize());
console.log('[FeatureBridge] Initialized with all feature handlers.'); console.log('[FeatureBridge] Initialized with all feature handlers.');
}, },

View File

@ -117,6 +117,12 @@ 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

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

View File

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

View File

@ -56,6 +56,13 @@ const providerSettingsRepositoryAdapter = {
const repo = getBaseRepository(); const repo = getBaseRepository();
const uid = authService.getCurrentUserId(); const uid = authService.getCurrentUserId();
return await repo.removeAllByUid(uid); return await repo.removeAllByUid(uid);
},
async getRawApiKeysByUid() {
// This function should always target the local sqlite DB,
// as it's part of the local-first boot sequence.
const uid = authService.getCurrentUserId();
return await sqliteRepository.getRawApiKeysByUid(uid);
} }
}; };

View File

@ -7,7 +7,6 @@ function getByProvider(uid, provider) {
const result = stmt.get(uid, provider) || null; const result = stmt.get(uid, provider) || null;
if (result && result.api_key) { if (result && result.api_key) {
// Decrypt API key if it exists
result.api_key = encryptionService.decrypt(result.api_key); result.api_key = encryptionService.decrypt(result.api_key);
} }
@ -19,7 +18,6 @@ function getAllByUid(uid) {
const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? ORDER BY provider'); const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? ORDER BY provider');
const results = stmt.all(uid); const results = stmt.all(uid);
// Decrypt API keys for all results
return results.map(result => { return results.map(result => {
if (result.api_key) { if (result.api_key) {
result.api_key = encryptionService.decrypt(result.api_key); result.api_key = encryptionService.decrypt(result.api_key);
@ -31,12 +29,6 @@ function getAllByUid(uid) {
function upsert(uid, provider, settings) { function upsert(uid, 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 = encryptionService.encrypt(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 (uid, provider, api_key, selected_llm_model, selected_stt_model, created_at, updated_at) INSERT INTO provider_settings (uid, provider, api_key, selected_llm_model, selected_stt_model, created_at, updated_at)
@ -51,11 +43,11 @@ function upsert(uid, provider, settings) {
const result = stmt.run( const result = stmt.run(
uid, uid,
provider, provider,
encryptedSettings.api_key || null, settings.api_key || null,
encryptedSettings.selected_llm_model || null, settings.selected_llm_model || null,
encryptedSettings.selected_stt_model || null, settings.selected_stt_model || null,
encryptedSettings.created_at || Date.now(), settings.created_at || Date.now(),
encryptedSettings.updated_at settings.updated_at
); );
return { changes: result.changes }; return { changes: result.changes };
@ -75,10 +67,17 @@ function removeAllByUid(uid) {
return { changes: result.changes }; return { changes: result.changes };
} }
function getRawApiKeysByUid(uid) {
const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT api_key FROM provider_settings WHERE uid = ?');
return stmt.all(uid);
}
module.exports = { module.exports = {
getByProvider, getByProvider,
getAllByUid, getAllByUid,
upsert, upsert,
remove, remove,
removeAllByUid removeAllByUid,
getRawApiKeysByUid
}; };

View File

@ -7,6 +7,7 @@ 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 userModelSelectionsRepository = require('../repositories/userModelSelections'); const userModelSelectionsRepository = require('../repositories/userModelSelections');
const permissionService = require('./permissionService');
async function getVirtualKeyByEmail(email, idToken) { async function getVirtualKeyByEmail(email, idToken) {
if (!idToken) { if (!idToken) {
@ -43,7 +44,6 @@ 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);
@ -69,8 +69,12 @@ 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 ** // ** Initialize encryption key for the logged-in user if permissions are already granted **
await encryptionService.initializeKey(user.uid); if (process.platform === 'darwin' && !(await permissionService.checkKeychainCompleted(this.currentUserId))) {
console.warn('[AuthService] Keychain permission not yet completed for this user. Deferring key initialization.');
} else if (process.platform === 'darwin') {
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.
@ -109,8 +113,12 @@ 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();
// ** Initialize encryption key for the default/local user ** // ** Initialize encryption key for the default/local user if permissions are already granted **
await encryptionService.initializeKey(this.currentUserId); if (process.platform === 'darwin' && !(await permissionService.checkKeychainCompleted(this.currentUserId))) {
console.warn('[AuthService] Keychain permission not yet completed for default user. Deferring key initialization.');
} else if (process.platform === 'darwin') {
await encryptionService.initializeKey(this.currentUserId);
}
} }
this.broadcastUserState(); this.broadcastUserState();
@ -175,7 +183,6 @@ class AuthService {
}); });
} }
getCurrentUserId() { getCurrentUserId() {
return this.currentUserId; return this.currentUserId;
} }

View File

@ -10,10 +10,13 @@ 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;
// 원본 DB 경로 (패키지 내 읽기 전용 위치) // The original DB path (read-only location in the package)
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');
@ -52,7 +55,7 @@ class DatabaseInitializer {
try { try {
this.ensureDatabaseExists(); this.ensureDatabaseExists();
await sqliteClient.connect(this.dbPath); // DB 경로를 인자로 전달 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,6 +9,8 @@ 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
@ -31,6 +33,8 @@ 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);
@ -41,6 +45,7 @@ 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) {
@ -55,7 +60,17 @@ 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.');
} }
@ -129,6 +144,7 @@ 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;
} }
} }

View File

@ -9,13 +9,39 @@ const userModelSelectionsRepository = require('../repositories/userModelSelectio
// Import authService directly (singleton) // Import authService directly (singleton)
const authService = require('./authService'); const authService = require('./authService');
const permissionService = require('./permissionService');
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;
}
}
class ModelStateService extends EventEmitter { class ModelStateService extends EventEmitter {
constructor() { constructor() {
super(); super();
this.authService = authService; this.authService = authService;
this.store = new Store({ name: 'pickle-glass-model-state' }); this.store = new Store({ name: 'pickle-glass-model-state' });
this.state = {};
const initialApiKeys = Object.keys(PROVIDERS).reduce((acc, provider) => {
acc[provider] = null;
return acc;
}, {});
this.state = {
apiKeys: initialApiKeys,
selectedModels: {
llm: null,
stt: null
}
};
this.hasMigrated = false; this.hasMigrated = false;
} }
@ -193,9 +219,19 @@ class ModelStateService extends EventEmitter {
async _loadStateForCurrentUser() { async _loadStateForCurrentUser() {
const userId = this.authService.getCurrentUserId(); const userId = this.authService.getCurrentUserId();
// Initialize encryption service for current user // Conditionally initialize encryption if old encrypted keys are detected
await encryptionService.initializeKey(userId); try {
const rows = await providerSettingsRepository.getRawApiKeysByUid();
if (rows.some(r => looksEncrypted(r.api_key))) {
console.log('[ModelStateService] Encrypted keys detected, initializing encryption...');
await encryptionService.initializeKey(userId);
} else {
console.log('[ModelStateService] No encrypted keys detected, skipping encryption initialization.');
}
} catch (err) {
console.warn('[ModelStateService] Error while checking encrypted keys:', err.message);
}
// Try to load from database first // Try to load from database first
await this._loadStateFromDatabase(); await this._loadStateFromDatabase();
@ -263,17 +299,6 @@ class ModelStateService extends EventEmitter {
apiKeys: { ...this.state.apiKeys } apiKeys: { ...this.state.apiKeys }
}; };
for (const [provider, key] of Object.entries(stateToSave.apiKeys)) {
if (key) {
try {
stateToSave.apiKeys[provider] = encryptionService.encrypt(key);
} catch (error) {
console.error(`[ModelStateService] Failed to encrypt API key for ${provider}`);
stateToSave.apiKeys[provider] = null;
}
}
}
this.store.set(`users.${userId}`, stateToSave); this.store.set(`users.${userId}`, stateToSave);
console.log(`[ModelStateService] State saved to electron-store for user: ${userId}`); console.log(`[ModelStateService] State saved to electron-store for user: ${userId}`);
this._logCurrentSelection(); this._logCurrentSelection();

View File

@ -2,27 +2,28 @@ 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') {
const micStatus = systemPreferences.getMediaAccessStatus('microphone'); permissions.microphone = systemPreferences.getMediaAccessStatus('microphone');
console.log('[Permissions] Microphone status:', micStatus); permissions.screen = systemPreferences.getMediaAccessStatus('screen');
permissions.microphone = micStatus; permissions.keychain = await this.checkKeychainCompleted(this._getAuthService().getCurrentUserId()) ? 'granted' : 'unknown';
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;
} }
@ -33,6 +34,7 @@ class PermissionService {
return { return {
microphone: 'unknown', microphone: 'unknown',
screen: 'unknown', screen: 'unknown',
keychain: 'unknown',
needsSetup: true, needsSetup: true,
error: error.message error: error.message
}; };
@ -92,24 +94,24 @@ class PermissionService {
} }
} }
async markPermissionsAsCompleted() { async markKeychainCompleted() {
try { try {
await permissionRepository.markPermissionsAsCompleted(); await permissionRepository.markKeychainCompleted(this._getAuthService().getCurrentUserId());
console.log('[Permissions] Marked permissions as completed'); console.log('[Permissions] Marked keychain as completed');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('[Permissions] Error marking permissions as completed:', error); console.error('[Permissions] Error marking keychain as completed:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }
async checkPermissionsCompleted() { async checkKeychainCompleted(uid) {
try { try {
const completed = await permissionRepository.checkPermissionsCompleted(); const completed = permissionRepository.checkKeychainCompleted(uid);
console.log('[Permissions] Permissions completed status:', completed); console.log('[Permissions] Keychain completed status:', completed);
return completed; return completed;
} catch (error) { } catch (error) {
console.error('[Permissions] Error checking permissions completed status:', error); console.error('[Permissions] Error checking keychain completed status:', error);
return false; return false;
} }
} }

View File

@ -40,7 +40,7 @@ class SQLiteClient {
return `"${identifier}"`; return `"${identifier}"`;
} }
synchronizeSchema() { async synchronizeSchema() {
console.log('[DB Sync] Starting schema synchronization...'); console.log('[DB Sync] Starting schema synchronization...');
const tablesInDb = this.getTablesFromDb(); const tablesInDb = this.getTablesFromDb();
@ -132,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.`);
} }
initTables() { async initTables() {
this.synchronizeSchema(); await this.synchronizeSchema();
this.initDefaultData(); this.initDefaultData();
} }
@ -166,21 +166,6 @@ 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

@ -44,10 +44,6 @@ 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 };
@ -461,7 +457,6 @@ module.exports = {
setAutoUpdateSetting, setAutoUpdateSetting,
// Model settings facade // Model settings facade
getModelSettings, getModelSettings,
validateAndSaveKey,
clearApiKey, clearApiKey,
setSelectedModel, setSelectedModel,
// Ollama facade // Ollama facade

View File

@ -69,6 +69,7 @@ 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),
@ -83,7 +84,9 @@ 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),
onForceShowPermissionHeader: (callback) => ipcRenderer.on('force-show-permission-header', callback),
removeOnForceShowPermissionHeader: (callback) => ipcRenderer.removeListener('force-show-permission-header', callback)
}, },
// src/ui/app/MainHeader.js // src/ui/app/MainHeader.js
@ -114,7 +117,9 @@ 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),
markPermissionsCompleted: () => ipcRenderer.invoke('mark-permissions-completed') markKeychainCompleted: () => ipcRenderer.invoke('mark-keychain-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

View File

@ -48,7 +48,13 @@ 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.continueCallback = () => this.transitionToMainHeader(); 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');
@ -121,19 +127,15 @@ class HeaderTransitionManager {
const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured(); const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured();
if (isConfigured) { if (isConfigured) {
const { isLoggedIn } = userState; // If providers are configured, always check permissions regardless of login state.
if (isLoggedIn) { const permissionResult = await this.checkPermissions();
const permissionResult = await this.checkPermissions(); if (permissionResult.success) {
if (permissionResult.success) {
this.transitionToMainHeader();
} else {
this.transitionToPermissionHeader();
}
} else {
this.transitionToMainHeader(); this.transitionToMainHeader();
} else {
this.transitionToPermissionHeader();
} }
} else { } else {
// 프로바이더가 설정되지 않았으면 WelcomeHeader 먼저 표시 // If no providers are configured, show the welcome header to prompt for setup.
await this._resizeForWelcome(); await this._resizeForWelcome();
this.ensureHeader('welcome'); this.ensureHeader('welcome');
} }

View File

@ -258,6 +258,7 @@ 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 }
}; };
@ -266,6 +267,7 @@ export class PermissionHeader extends LitElement {
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;
} }
@ -298,12 +300,14 @@ 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) { if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted || prevKeychain !== this.keychainGranted) {
console.log('[PermissionHeader] Permission status changed, updating UI'); console.log('[PermissionHeader] Permission status changed, updating UI');
this.requestUpdate(); this.requestUpdate();
} }
@ -311,6 +315,7 @@ export class PermissionHeader extends LitElement {
// 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' &&
this.keychainGranted === 'granted' &&
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);
@ -381,17 +386,36 @@ 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() {
if (this.continueCallback && if (this.continueCallback &&
this.microphoneGranted === 'granted' && this.microphoneGranted === 'granted' &&
this.screenGranted === 'granted') { this.screenGranted === 'granted' &&
this.keychainGranted === 'granted') {
// Mark permissions as completed // Mark permissions as completed
if (window.api) { if (window.api) {
try { try {
await window.api.permissionHeader.markPermissionsCompleted(); await window.api.permissionHeader.markKeychainCompleted();
console.log('[PermissionHeader] Marked permissions as completed'); console.log('[PermissionHeader] Marked keychain as completed');
} catch (error) { } catch (error) {
console.error('[PermissionHeader] Error marking permissions as completed:', error); console.error('[PermissionHeader] Error marking keychain as completed:', error);
} }
} }
@ -407,7 +431,7 @@ export class PermissionHeader extends LitElement {
} }
render() { render() {
const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted'; const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted' && this.keychainGranted === 'granted';
return html` return html`
<div class="container"> <div class="container">
@ -419,7 +443,7 @@ export class PermissionHeader extends LitElement {
<h1 class="title">Permission Setup Required</h1> <h1 class="title">Permission Setup Required</h1>
<div class="form-content"> <div class="form-content">
<div class="subtitle">Grant access to microphone and screen recording to continue</div> <div class="subtitle">Grant access to microphone, screen recording 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' : ''}">
@ -449,6 +473,20 @@ export class PermissionHeader extends LitElement {
<span>Screen Recording</span> <span>Screen Recording</span>
`} `}
</div> </div>
<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>Keychain </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>Keychain Access</span>
`}
</div>
</div> </div>
${this.microphoneGranted !== 'granted' ? html` ${this.microphoneGranted !== 'granted' ? html`
@ -469,6 +507,16 @@ export class PermissionHeader extends LitElement {
</button> </button>
` : ''} ` : ''}
${this.keychainGranted !== 'granted' ? html`
<button
class="action-button"
@click=${this.handleKeychainClick}
>
Grant Keychain Access
</button>
<div class="subtitle">System prompt will appear. Select 'Always Allow' for seamless access.</div>
` : ''}
${allGranted ? html` ${allGranted ? html`
<button <button
class="continue-button" class="continue-button"