From d2ab6570ea5cfa432a3206a9a9519a606dbe45dc Mon Sep 17 00:00:00 2001 From: jhyang0 Date: Sat, 5 Jul 2025 23:34:15 +0900 Subject: [PATCH] Add permission setup flow --- src/app/ApiKeyHeader.js | 52 +-- src/app/HeaderController.js | 180 ++++----- src/app/PermissionSetup.js | 533 +++++++++++++++++++++++++++ src/common/services/sqliteClient.js | 28 ++ src/electron/windowManager.js | 93 +++-- src/features/ask/AskView.js | 69 ++++ src/features/listen/AssistantView.js | 69 ++++ 7 files changed, 861 insertions(+), 163 deletions(-) create mode 100644 src/app/PermissionSetup.js diff --git a/src/app/ApiKeyHeader.js b/src/app/ApiKeyHeader.js index 2e885f0..b884c13 100644 --- a/src/app/ApiKeyHeader.js +++ b/src/app/ApiKeyHeader.js @@ -268,7 +268,6 @@ export class ApiKeyHeader extends LitElement { this.handleAnimationEnd = this.handleAnimationEnd.bind(this); this.handleUsePicklesKey = this.handleUsePicklesKey.bind(this); this.handleProviderChange = this.handleProviderChange.bind(this); - this.checkAndRequestPermissions = this.checkAndRequestPermissions.bind(this); } reset() { @@ -407,18 +406,10 @@ export class ApiKeyHeader extends LitElement { const isValid = await this.validateApiKey(this.apiKey.trim(), this.selectedProvider); if (isValid) { - console.log('API key valid – checking system permissions…'); - const permissionResult = await this.checkAndRequestPermissions(); - - if (permissionResult.success) { - console.log('All permissions granted – starting slide-out animation'); + console.log('API key valid - starting slide out animation'); this.startSlideOutAnimation(); this.validatedApiKey = this.apiKey.trim(); - this.validatedProvider = this.selectedProvider; - } else { - this.errorMessage = permissionResult.error || 'Permission setup required'; - console.log('Permission setup incomplete:', permissionResult); - } + this.validatedProvider = this.selectedProvider; } else { this.errorMessage = 'Invalid API key - please check and try again'; console.log('API key validation failed'); @@ -497,45 +488,6 @@ export class ApiKeyHeader extends LitElement { return false; } - async checkAndRequestPermissions() { - if (!window.require) return { success: true }; - - const { ipcRenderer } = window.require('electron'); - - try { - const permissions = await ipcRenderer.invoke('check-system-permissions'); - console.log('[Permissions] Current status:', permissions); - - if (!permissions.needsSetup) return { success: true }; - - if (!permissions.microphone) { - console.log('[Permissions] Requesting microphone permission…'); - const micResult = await ipcRenderer.invoke('request-microphone-permission'); - if (!micResult.success) { - await ipcRenderer.invoke('open-system-preferences', 'microphone'); - return { - success: false, - error: 'Please grant microphone access in System Preferences', - }; - } - } - - if (!permissions.screen) { - console.log('[Permissions] Screen-recording permission needed'); - await ipcRenderer.invoke('open-system-preferences', 'screen-recording'); - return { - success: false, - error: 'Please grant screen recording access in System Preferences', - }; - } - - return { success: true }; - } catch (err) { - console.error('[Permissions] Error checking/requesting permissions:', err); - return { success: false, error: 'Failed to check permissions' }; - } - } - startSlideOutAnimation() { this.classList.add('sliding-out'); } diff --git a/src/app/HeaderController.js b/src/app/HeaderController.js index a20c863..313b59e 100644 --- a/src/app/HeaderController.js +++ b/src/app/HeaderController.js @@ -3,6 +3,7 @@ import { getAuth, onAuthStateChanged, GoogleAuthProvider, signInWithCredential, import './AppHeader.js'; import './ApiKeyHeader.js'; +import './PermissionSetup.js'; const firebaseConfig = { apiKey: 'AIzaSyAgtJrmsFWG1C7m9S55HyT1laICEzuUS2g', @@ -21,31 +22,40 @@ class HeaderTransitionManager { constructor() { this.headerContainer = document.getElementById('header-container'); - this.currentHeaderType = null; // 'apikey' | 'app' + this.currentHeaderType = null; // 'apikey' | 'app' | 'permission' this.apiKeyHeader = null; this.appHeader = null; + this.permissionSetup = null; /** * only one header window is allowed - * @param {'apikey'|'app'} type + * @param {'apikey'|'app'|'permission'} type */ this.ensureHeader = (type) => { if (this.currentHeaderType === type) return; - if (this.apiKeyHeader) { this.apiKeyHeader.remove(); this.apiKeyHeader = null; } - if (this.appHeader) { this.appHeader.remove(); this.appHeader = null; } + this.headerContainer.innerHTML = ''; + + this.apiKeyHeader = null; + this.appHeader = null; + this.permissionSetup = null; + // Create new header element if (type === 'apikey') { - this.apiKeyHeader = document.createElement('apikey-header'); + this.apiKeyHeader = document.createElement('apikey-header'); this.headerContainer.appendChild(this.apiKeyHeader); + } else if (type === 'permission') { + this.permissionSetup = document.createElement('permission-setup'); + this.permissionSetup.continueCallback = () => this.transitionToAppHeader(); + this.headerContainer.appendChild(this.permissionSetup); } else { - this.appHeader = document.createElement('app-header'); + this.appHeader = document.createElement('app-header'); this.headerContainer.appendChild(this.appHeader); this.appHeader.startSlideInAnimation?.(); } this.currentHeaderType = type; - this.notifyHeaderState(type); + this.notifyHeaderState(type === 'permission' ? 'apikey' : type); // Keep permission state as apikey for compatibility }; console.log('[HeaderController] Manager initialized'); @@ -80,32 +90,14 @@ class HeaderTransitionManager { } if (error) { - console.warn('[HeaderController] Login payload indicates verification failure. Proceeding to AppHeader UI only.'); - // Check permissions before transitioning - const permissionResult = await this.checkPermissions(); - if (permissionResult.success) { - this.transitionToAppHeader(); - } else { - console.log('[HeaderController] Permissions not granted after login error'); - if (this.apiKeyHeader) { - this.apiKeyHeader.errorMessage = permissionResult.error || 'Permission setup required'; - this.apiKeyHeader.requestUpdate(); - } - } + console.warn('[HeaderController] Login payload indicates verification failure. Showing permission setup.'); + // Show permission setup after login error + this.transitionToPermissionSetup(); } } catch (error) { console.error('[HeaderController] Sign-in failed', error); - // Check permissions before transitioning - const permissionResult = await this.checkPermissions(); - if (permissionResult.success) { - this.transitionToAppHeader(); - } else { - console.log('[HeaderController] Permissions not granted after sign-in failure'); - if (this.apiKeyHeader) { - this.apiKeyHeader.errorMessage = permissionResult.error || 'Permission setup required'; - this.apiKeyHeader.requestUpdate(); - } - } + // Show permission setup after sign-in failure + this.transitionToPermissionSetup(); } }); @@ -122,7 +114,10 @@ class HeaderTransitionManager { ipcRenderer.on('api-key-validated', () => { this.hasApiKey = true; - this.transitionToAppHeader(); + // Wait for animation to complete before transitioning + setTimeout(() => { + this.transitionToPermissionSetup(); + }, 350); // Give time for slide-out animation to complete }); ipcRenderer.on('api-key-removed', () => { @@ -133,7 +128,7 @@ class HeaderTransitionManager { ipcRenderer.on('api-key-updated', () => { this.hasApiKey = true; if (!auth.currentUser) { - this.transitionToAppHeader(); + this.transitionToPermissionSetup(); } }); @@ -145,22 +140,13 @@ class HeaderTransitionManager { await signInWithCredential(auth, credential); console.log('[HeaderController] Firebase sign-in successful via ID token'); } else { - console.warn('[HeaderController] No ID token received from deeplink, virtual key request may fail'); - // Check permissions before transitioning - const permissionResult = await this.checkPermissions(); - if (permissionResult.success) { - this.transitionToAppHeader(); - } else { - console.log('[HeaderController] Permissions not granted after Firebase auth'); - if (this.apiKeyHeader) { - this.apiKeyHeader.errorMessage = permissionResult.error || 'Permission setup required'; - this.apiKeyHeader.requestUpdate(); - } - } + console.warn('[HeaderController] No ID token received from deeplink, showing permission setup'); + // Show permission setup after Firebase auth + this.transitionToPermissionSetup(); } } catch (error) { console.error('[HeaderController] Firebase auth failed:', error); - this.transitionToAppHeader(); + this.transitionToPermissionSetup(); } }); } @@ -201,28 +187,18 @@ class HeaderTransitionManager { if (!this.isInitialized) { this.isInitialized = true; + return; // Skip on initial load - bootstrap handles it } + // Only handle state changes after initial load if (user) { - console.log('[HeaderController] User is logged in, checking permissions...'); - const permissionResult = await this.checkPermissions(); - if (permissionResult.success) { - this.transitionToAppHeader(!this.hasApiKey); - } else { - console.log('[HeaderController] Permissions not granted, staying on ApiKeyHeader'); - if (this.apiKeyHeader) { - this.apiKeyHeader.errorMessage = permissionResult.error || 'Permission setup required'; - this.apiKeyHeader.requestUpdate(); - } - } + console.log('[HeaderController] User logged in, updating hasApiKey and checking permissions...'); + this.hasApiKey = true; // User login should provide API key + // Delay permission check to ensure smooth login flow + setTimeout(() => this.transitionToPermissionSetup(), 500); } else if (this.hasApiKey) { - console.log('[HeaderController] No Firebase user but API key exists, checking permissions...'); - const permissionResult = await this.checkPermissions(); - if (permissionResult.success) { - this.transitionToAppHeader(false); - } else { - console.log('[HeaderController] Permissions not granted, staying on ApiKeyHeader'); - } + console.log('[HeaderController] No Firebase user but API key exists, checking if permission setup is needed...'); + setTimeout(() => this.transitionToPermissionSetup(), 500); } else { console.log('[HeaderController] No auth & no API key — showing ApiKeyHeader'); this.transitionToApiKeyHeader(); @@ -255,29 +231,60 @@ class HeaderTransitionManager { }); }); - if (user || this.hasApiKey) { + // check flow order: API key -> Permissions -> App + if (!user && !this.hasApiKey) { + // No auth and no API key -> show API key input + await this._resizeForApiKey(); + this.ensureHeader('apikey'); + } else { + // Has API key or user -> check permissions first const permissionResult = await this.checkPermissions(); - if (permissionResult.success) { + // All permissions granted -> go to app await this._resizeForApp(); this.ensureHeader('app'); } else { - await this._resizeForApiKey(); - this.ensureHeader('apikey'); - - setTimeout(() => { - if (this.apiKeyHeader) { - this.apiKeyHeader.errorMessage = permissionResult.error || 'Permission setup required'; - this.apiKeyHeader.requestUpdate(); - } - }, 100); + // Permissions needed -> show permission setup + await this._resizeForPermissionSetup(); + this.ensureHeader('permission'); } - } else { - await this._resizeForApiKey(); - this.ensureHeader('apikey'); } } + async transitionToPermissionSetup() { + // Prevent duplicate transitions + if (this.currentHeaderType === 'permission') { + console.log('[HeaderController] Already showing permission setup, skipping transition'); + return; + } + + // Check if permissions were previously completed + if (window.require) { + const { ipcRenderer } = window.require('electron'); + try { + const permissionsCompleted = await ipcRenderer.invoke('check-permissions-completed'); + if (permissionsCompleted) { + console.log('[HeaderController] Permissions were previously completed, checking current status...'); + + // Double check current permission status + const permissionResult = await this.checkPermissions(); + if (permissionResult.success) { + // Skip permission setup if already granted + this.transitionToAppHeader(); + return; + } + + console.log('[HeaderController] Permissions were revoked, showing setup again'); + } + } catch (error) { + console.error('[HeaderController] Error checking permissions completed status:', error); + } + } + + await this._resizeForPermissionSetup(); + this.ensureHeader('permission'); + } + async transitionToAppHeader(animate = true) { if (this.currentHeaderType === 'app') { return this._resizeForApp(); @@ -285,11 +292,10 @@ class HeaderTransitionManager { const canAnimate = animate && - this.apiKeyHeader && - !this.apiKeyHeader.classList.contains('hidden') && - typeof this.apiKeyHeader.startSlideOutAnimation === 'function'; + (this.apiKeyHeader || this.permissionSetup) && + this.currentHeaderType !== 'app'; - if (canAnimate) { + if (canAnimate && this.apiKeyHeader?.startSlideOutAnimation) { const old = this.apiKeyHeader; const onEnd = () => { clearTimeout(fallback); @@ -321,6 +327,14 @@ class HeaderTransitionManager { .catch(() => {}); } + async _resizeForPermissionSetup() { + if (!window.require) return; + return window + .require('electron') + .ipcRenderer.invoke('resize-header-window', { width: 285, height: 220 }) + .catch(() => {}); + } + async transitionToApiKeyHeader() { await this._resizeForApiKey(); @@ -351,10 +365,6 @@ class HeaderTransitionManager { let errorMessage = ''; if (!permissions.microphone && !permissions.screen) { errorMessage = 'Microphone and screen recording access required'; - } else if (!permissions.microphone) { - errorMessage = 'Microphone access required'; - } else if (!permissions.screen) { - errorMessage = 'Screen recording access required'; } return { diff --git a/src/app/PermissionSetup.js b/src/app/PermissionSetup.js new file mode 100644 index 0000000..f8a7914 --- /dev/null +++ b/src/app/PermissionSetup.js @@ -0,0 +1,533 @@ +import { LitElement, html, css } from '../assets/lit-core-2.7.4.min.js'; + +export class PermissionSetup extends LitElement { + static styles = css` + :host { + display: block; + transform: translate3d(0, 0, 0); + backface-visibility: hidden; + transition: opacity 0.25s ease-out; + } + + :host(.sliding-out) { + animation: slideOutUp 0.3s ease-in forwards; + will-change: opacity, transform; + } + + :host(.hidden) { + opacity: 0; + pointer-events: none; + } + + @keyframes slideOutUp { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-20px); + } + } + + * { + font-family: 'Helvetica Neue', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + cursor: default; + user-select: none; + box-sizing: border-box; + } + + .container { + width: 285px; + height: 220px; + padding: 18px 20px; + background: rgba(0, 0, 0, 0.3); + border-radius: 16px; + overflow: hidden; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + } + + .container::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 16px; + padding: 1px; + background: linear-gradient(169deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%, rgba(255, 255, 255, 0.5) 100%); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: destination-out; + mask-composite: exclude; + pointer-events: none; + } + + .close-button { + position: absolute; + top: 10px; + right: 10px; + width: 14px; + height: 14px; + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 3px; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + z-index: 10; + font-size: 14px; + line-height: 1; + padding: 0; + } + + .close-button:hover { + background: rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.9); + } + + .close-button:active { + transform: scale(0.95); + } + + .title { + color: white; + font-size: 16px; + font-weight: 500; + margin: 0; + text-align: center; + flex-shrink: 0; + } + + .form-content { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + margin-top: auto; + } + + .subtitle { + color: rgba(255, 255, 255, 0.7); + font-size: 11px; + font-weight: 400; + text-align: center; + margin-bottom: 12px; + line-height: 1.3; + } + + .permission-status { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: 12px; + min-height: 20px; + } + + .permission-item { + display: flex; + align-items: center; + gap: 6px; + color: rgba(255, 255, 255, 0.8); + font-size: 11px; + font-weight: 400; + } + + .permission-item.granted { + color: rgba(34, 197, 94, 0.9); + } + + .permission-icon { + width: 12px; + height: 12px; + opacity: 0.8; + } + + .check-icon { + width: 12px; + height: 12px; + color: rgba(34, 197, 94, 0.9); + } + + .action-button { + width: 100%; + height: 34px; + background: rgba(255, 255, 255, 0.2); + border: none; + border-radius: 10px; + color: white; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease; + position: relative; + overflow: hidden; + margin-bottom: 6px; + } + + .action-button::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 10px; + padding: 1px; + background: linear-gradient(169deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%, rgba(255, 255, 255, 0.5) 100%); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: destination-out; + mask-composite: exclude; + pointer-events: none; + } + + .action-button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.3); + } + + .action-button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .continue-button { + width: 100%; + height: 34px; + background: rgba(34, 197, 94, 0.8); + border: none; + border-radius: 10px; + color: white; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease; + position: relative; + overflow: hidden; + margin-top: 4px; + } + + .continue-button::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 10px; + padding: 1px; + background: linear-gradient(169deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%, rgba(255, 255, 255, 0.5) 100%); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: destination-out; + mask-composite: exclude; + pointer-events: none; + } + + .continue-button:hover:not(:disabled) { + background: rgba(34, 197, 94, 0.9); + } + + .continue-button:disabled { + background: rgba(255, 255, 255, 0.2); + cursor: not-allowed; + } + `; + + static properties = { + microphoneGranted: { type: String }, + screenGranted: { type: String }, + isChecking: { type: String }, + continueCallback: { type: Function } + }; + + constructor() { + super(); + this.microphoneGranted = 'unknown'; + this.screenGranted = 'unknown'; + this.isChecking = false; + this.continueCallback = null; + + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + } + + async connectedCallback() { + super.connectedCallback(); + await this.checkPermissions(); + + // Set up periodic permission check + this.permissionCheckInterval = setInterval(() => { + this.checkPermissions(); + }, 1000); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.permissionCheckInterval) { + clearInterval(this.permissionCheckInterval); + } + } + + async handleMouseDown(e) { + if (e.target.tagName === 'BUTTON') { + return; + } + + e.preventDefault(); + + const { ipcRenderer } = window.require('electron'); + const initialPosition = await ipcRenderer.invoke('get-header-position'); + + this.dragState = { + initialMouseX: e.screenX, + initialMouseY: e.screenY, + initialWindowX: initialPosition.x, + initialWindowY: initialPosition.y, + moved: false, + }; + + window.addEventListener('mousemove', this.handleMouseMove); + window.addEventListener('mouseup', this.handleMouseUp, { once: true }); + } + + handleMouseMove(e) { + if (!this.dragState) return; + + const deltaX = Math.abs(e.screenX - this.dragState.initialMouseX); + const deltaY = Math.abs(e.screenY - this.dragState.initialMouseY); + + if (deltaX > 3 || deltaY > 3) { + this.dragState.moved = true; + } + + const newWindowX = this.dragState.initialWindowX + (e.screenX - this.dragState.initialMouseX); + const newWindowY = this.dragState.initialWindowY + (e.screenY - this.dragState.initialMouseY); + + const { ipcRenderer } = window.require('electron'); + ipcRenderer.invoke('move-header-to', newWindowX, newWindowY); + } + + handleMouseUp(e) { + if (!this.dragState) return; + + const wasDragged = this.dragState.moved; + + window.removeEventListener('mousemove', this.handleMouseMove); + this.dragState = null; + + if (wasDragged) { + this.wasJustDragged = true; + setTimeout(() => { + this.wasJustDragged = false; + }, 200); + } + } + + async checkPermissions() { + if (!window.require || this.isChecking) return; + + this.isChecking = true; + const { ipcRenderer } = window.require('electron'); + + try { + const permissions = await ipcRenderer.invoke('check-system-permissions'); + console.log('[PermissionSetup] Permission check result:', permissions); + + const prevMic = this.microphoneGranted; + const prevScreen = this.screenGranted; + + this.microphoneGranted = permissions.microphone; + this.screenGranted = permissions.screen; + + // if permissions changed == UI update + if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted) { + console.log('[PermissionSetup] Permission status changed, updating UI'); + this.requestUpdate(); + } + + // if all permissions granted == automatically continue + if (this.microphoneGranted === 'granted' && + this.screenGranted === 'granted' && + this.continueCallback) { + console.log('[PermissionSetup] All permissions granted, proceeding automatically'); + setTimeout(() => this.handleContinue(), 500); + } + } catch (error) { + console.error('[PermissionSetup] Error checking permissions:', error); + } finally { + this.isChecking = false; + } + } + + async handleMicrophoneClick() { + if (!window.require || this.microphoneGranted === 'granted' || this.wasJustDragged) return; + + console.log('[PermissionSetup] Requesting microphone permission...'); + const { ipcRenderer } = window.require('electron'); + + try { + const result = await ipcRenderer.invoke('check-system-permissions'); + console.log('[PermissionSetup] Microphone permission result:', result); + + if (result.microphone === 'granted') { + this.microphoneGranted = 'granted'; + this.requestUpdate(); + return; + } + + if (result.microphone === 'not-determined' || result.microphone === 'denied' || result.microphone === 'unknown' || result.microphone === 'restricted') { + const res = await ipcRenderer.invoke('request-microphone-permission'); + if (res.status === 'granted' || res.success === true) { + this.microphoneGranted = 'granted'; + this.requestUpdate(); + return; + } + } + + + // Check permissions again after a delay + // setTimeout(() => this.checkPermissions(), 1000); + } catch (error) { + console.error('[PermissionSetup] Error requesting microphone permission:', error); + } + } + + async handleScreenClick() { + if (!window.require || this.screenGranted === 'granted' || this.wasJustDragged) return; + + console.log('[PermissionSetup] Checking screen recording permission...'); + const { ipcRenderer } = window.require('electron'); + + try { + const permissions = await ipcRenderer.invoke('check-system-permissions'); + console.log('[PermissionSetup] Screen permission check result:', permissions); + + if (permissions.screen === 'granted') { + this.screenGranted = 'granted'; + this.requestUpdate(); + return; + } + if (permissions.screen === 'not-determined' || permissions.screen === 'denied' || permissions.screen === 'unknown' || permissions.screen === 'restricted') { + console.log('[PermissionSetup] Opening screen recording preferences...'); + await ipcRenderer.invoke('open-system-preferences', 'screen-recording'); + } + + // Check permissions again after a delay + // (This may not execute if app restarts after permission grant) + // setTimeout(() => this.checkPermissions(), 2000); + } catch (error) { + console.error('[PermissionSetup] Error opening screen recording preferences:', error); + } + } + + async handleContinue() { + if (this.continueCallback && + this.microphoneGranted === 'granted' && + this.screenGranted === 'granted' && + !this.wasJustDragged) { + // Mark permissions as completed + if (window.require) { + const { ipcRenderer } = window.require('electron'); + try { + await ipcRenderer.invoke('mark-permissions-completed'); + console.log('[PermissionSetup] Marked permissions as completed'); + } catch (error) { + console.error('[PermissionSetup] Error marking permissions as completed:', error); + } + } + + this.continueCallback(); + } + } + + handleClose() { + console.log('Close button clicked'); + if (window.require) { + window.require('electron').ipcRenderer.invoke('quit-application'); + } + } + + render() { + const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted'; + + return html` +
+ +

Permission Setup Required

+ +
+
Grant access to microphone and screen recording to continue
+ +
+
+ ${this.microphoneGranted === 'granted' ? html` + + + + Microphone ✓ + ` : html` + + + + Microphone + `} +
+ +
+ ${this.screenGranted === 'granted' ? html` + + + + Screen ✓ + ` : html` + + + + Screen Recording + `} +
+
+ + ${this.microphoneGranted !== 'granted' ? html` + + ` : ''} + + ${this.screenGranted !== 'granted' ? html` + + ` : ''} + + ${allGranted ? html` + + ` : ''} +
+
+ `; + } +} + +customElements.define('permission-setup', PermissionSetup); \ No newline at end of file diff --git a/src/common/services/sqliteClient.js b/src/common/services/sqliteClient.js index 08ce9db..d91ccee 100644 --- a/src/common/services/sqliteClient.js +++ b/src/common/services/sqliteClient.js @@ -433,6 +433,34 @@ class SQLiteClient { this.db = null; } } + + async query(sql, params = []) { + return new Promise((resolve, reject) => { + if (!this.db) { + return reject(new Error('Database not connected')); + } + + if (sql.toUpperCase().startsWith('SELECT')) { + this.db.all(sql, params, (err, rows) => { + if (err) { + console.error('Query error:', err); + reject(err); + } else { + resolve(rows); + } + }); + } else { + this.db.run(sql, params, function(err) { + if (err) { + console.error('Query error:', err); + reject(err); + } else { + resolve({ changes: this.changes, lastID: this.lastID }); + } + }); + } + }); + } } const sqliteClient = new SQLiteClient(); diff --git a/src/electron/windowManager.js b/src/electron/windowManager.js index 9dc8b5f..9098d7c 100644 --- a/src/electron/windowManager.js +++ b/src/electron/windowManager.js @@ -1845,32 +1845,27 @@ function setupIpcHandlers(openaiSessionRef) { ipcMain.handle('check-system-permissions', async () => { const { systemPreferences } = require('electron'); const permissions = { - microphone: false, - screen: false, - needsSetup: false + microphone: 'unknown', + screen: 'unknown', + needsSetup: true }; try { if (process.platform === 'darwin') { // Check microphone permission on macOS const micStatus = systemPreferences.getMediaAccessStatus('microphone'); - permissions.microphone = micStatus === 'granted'; + console.log('[Permissions] Microphone status:', micStatus); + permissions.microphone = micStatus; - try { - const sources = await desktopCapturer.getSources({ - types: ['screen'], - thumbnailSize: { width: 1, height: 1 } - }); - permissions.screen = sources && sources.length > 0; - } catch (err) { - console.log('[Permissions] Screen capture test failed:', err); - permissions.screen = false; - } + // Check screen recording permission using the system API + const screenStatus = systemPreferences.getMediaAccessStatus('screen'); + console.log('[Permissions] Screen status:', screenStatus); + permissions.screen = screenStatus; - permissions.needsSetup = !permissions.microphone || !permissions.screen; + permissions.needsSetup = micStatus !== 'granted' || screenStatus !== 'granted'; } else { - permissions.microphone = true; - permissions.screen = true; + permissions.microphone = 'granted'; + permissions.screen = 'granted'; permissions.needsSetup = false; } @@ -1879,8 +1874,8 @@ function setupIpcHandlers(openaiSessionRef) { } catch (error) { console.error('[Permissions] Error checking permissions:', error); return { - microphone: false, - screen: false, + microphone: 'unknown', + screen: 'unknown', needsSetup: true, error: error.message }; @@ -1895,15 +1890,16 @@ function setupIpcHandlers(openaiSessionRef) { const { systemPreferences } = require('electron'); try { const status = systemPreferences.getMediaAccessStatus('microphone'); + console.log('[Permissions] Microphone status:', status); if (status === 'granted') { - return { success: true, status: 'already-granted' }; + return { success: true, status: 'granted' }; } // Req mic permission const granted = await systemPreferences.askForMediaAccess('microphone'); return { - success: granted, - status: granted ? 'granted' : 'denied' + success: granted, + status: granted ? 'granted' : 'denied' }; } catch (error) { console.error('[Permissions] Error requesting microphone permission:', error); @@ -1920,20 +1916,61 @@ function setupIpcHandlers(openaiSessionRef) { } try { - // Open System Preferences to Privacy & Security > Screen Recording if (section === 'screen-recording') { - await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'); - } else if (section === 'microphone') { - await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'); - } else { - await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy'); + // First trigger screen capture request to register the app in system preferences + try { + console.log('[Permissions] Triggering screen capture request to register app...'); + await desktopCapturer.getSources({ + types: ['screen'], + thumbnailSize: { width: 1, height: 1 } + }); + console.log('[Permissions] App registered for screen recording'); + } catch (captureError) { + console.log('[Permissions] Screen capture request triggered (expected to fail):', captureError.message); + } + + // Then open system preferences + // await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'); } + // if (section === 'microphone') { + // await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'); + // } return { success: true }; } catch (error) { console.error('[Permissions] Error opening system preferences:', error); return { success: false, error: error.message }; } }); + + ipcMain.handle('mark-permissions-completed', async () => { + try { + // Store in SQLite that permissions have been completed + await sqliteClient.query( + 'INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, ?)', + ['permissions_completed', 'true'] + ); + console.log('[Permissions] Marked permissions as completed'); + return { success: true }; + } catch (error) { + console.error('[Permissions] Error marking permissions as completed:', error); + return { success: false, error: error.message }; + } + }); + + ipcMain.handle('check-permissions-completed', async () => { + try { + const result = await sqliteClient.query( + 'SELECT value FROM system_settings WHERE key = ?', + ['permissions_completed'] + ); + const completed = result.length > 0 && result[0].value === 'true'; + console.log('[Permissions] Permissions completed status:', completed); + return completed; + } catch (error) { + console.error('[Permissions] Error checking permissions completed status:', error); + return false; + } + }); } diff --git a/src/features/ask/AskView.js b/src/features/ask/AskView.js index 991cf28..437aa91 100644 --- a/src/features/ask/AskView.js +++ b/src/features/ask/AskView.js @@ -21,6 +21,75 @@ export class AskView extends LitElement { width: 100%; height: 100%; color: white; + transform: translate3d(0, 0, 0); + backface-visibility: hidden; + transition: transform 0.2s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.2s ease-out; + will-change: transform, opacity; + } + + :host(.hiding) { + animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.6, 1) forwards; + } + + :host(.showing) { + animation: slideDown 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + } + + :host(.hidden) { + opacity: 0; + transform: translateY(-150%) scale(0.85); + pointer-events: none; + } + + @keyframes slideUp { + 0% { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0px); + } + 30% { + opacity: 0.7; + transform: translateY(-20%) scale(0.98); + filter: blur(0.5px); + } + 70% { + opacity: 0.3; + transform: translateY(-80%) scale(0.92); + filter: blur(1.5px); + } + 100% { + opacity: 0; + transform: translateY(-150%) scale(0.85); + filter: blur(2px); + } + } + + @keyframes slideDown { + 0% { + opacity: 0; + transform: translateY(-150%) scale(0.85); + filter: blur(2px); + } + 30% { + opacity: 0.5; + transform: translateY(-50%) scale(0.92); + filter: blur(1px); + } + 65% { + opacity: 0.9; + transform: translateY(-5%) scale(0.99); + filter: blur(0.2px); + } + 85% { + opacity: 0.98; + transform: translateY(2%) scale(1.005); + filter: blur(0px); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0px); + } } * { diff --git a/src/features/listen/AssistantView.js b/src/features/listen/AssistantView.js index 6c9e4a1..e1584ca 100644 --- a/src/features/listen/AssistantView.js +++ b/src/features/listen/AssistantView.js @@ -5,6 +5,75 @@ export class AssistantView extends LitElement { :host { display: block; width: 400px; + transform: translate3d(0, 0, 0); + backface-visibility: hidden; + transition: transform 0.2s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.2s ease-out; + will-change: transform, opacity; + } + + :host(.hiding) { + animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.6, 1) forwards; + } + + :host(.showing) { + animation: slideDown 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + } + + :host(.hidden) { + opacity: 0; + transform: translateY(-150%) scale(0.85); + pointer-events: none; + } + + @keyframes slideUp { + 0% { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0px); + } + 30% { + opacity: 0.7; + transform: translateY(-20%) scale(0.98); + filter: blur(0.5px); + } + 70% { + opacity: 0.3; + transform: translateY(-80%) scale(0.92); + filter: blur(1.5px); + } + 100% { + opacity: 0; + transform: translateY(-150%) scale(0.85); + filter: blur(2px); + } + } + + @keyframes slideDown { + 0% { + opacity: 0; + transform: translateY(-150%) scale(0.85); + filter: blur(2px); + } + 30% { + opacity: 0.5; + transform: translateY(-50%) scale(0.92); + filter: blur(1px); + } + 65% { + opacity: 0.9; + transform: translateY(-5%) scale(0.99); + filter: blur(0.2px); + } + 85% { + opacity: 0.98; + transform: translateY(2%) scale(1.005); + filter: blur(0px); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0px); + } } * {