From c0e0e00c4ccca489f20524cd26bdaba699e1bc67 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sat, 22 Nov 2025 14:52:48 +0100 Subject: [PATCH] feat(automation): add macOS permission check before automation start --- apps/companion/main/di-container.ts | 9 + apps/companion/main/ipc-handlers.ts | 68 ++++++ apps/companion/main/preload.ts | 33 ++- apps/companion/renderer/App.tsx | 186 ++++++++++++++- .../adapters/automation/PermissionService.ts | 216 ++++++++++++++++++ .../adapters/automation/index.ts | 7 +- 6 files changed, 510 insertions(+), 9 deletions(-) create mode 100644 packages/infrastructure/adapters/automation/PermissionService.ts diff --git a/apps/companion/main/di-container.ts b/apps/companion/main/di-container.ts index c3b061720..19b324462 100644 --- a/apps/companion/main/di-container.ts +++ b/apps/companion/main/di-container.ts @@ -3,6 +3,7 @@ import { MockBrowserAutomationAdapter } from '@/packages/infrastructure/adapters import { BrowserDevToolsAdapter } from '@/packages/infrastructure/adapters/automation/BrowserDevToolsAdapter'; import { NutJsAutomationAdapter } from '@/packages/infrastructure/adapters/automation/NutJsAutomationAdapter'; import { MockAutomationEngineAdapter } from '@/packages/infrastructure/adapters/automation/MockAutomationEngineAdapter'; +import { PermissionService } from '@/packages/infrastructure/adapters/automation/PermissionService'; import { StartAutomationSessionUseCase } from '@/packages/application/use-cases/StartAutomationSessionUseCase'; import { loadAutomationConfig, AutomationMode } from '@/packages/infrastructure/config'; import { PinoLogAdapter } from '@/packages/infrastructure/adapters/logging/PinoLogAdapter'; @@ -74,6 +75,7 @@ export class DIContainer { private automationEngine: IAutomationEngine; private startAutomationUseCase: StartAutomationSessionUseCase; private automationMode: AutomationMode; + private permissionService: PermissionService; private constructor() { // Initialize logger first - it's needed by other components @@ -94,6 +96,9 @@ export class DIContainer { this.browserAutomation, this.sessionRepository ); + this.permissionService = new PermissionService( + this.logger.child({ service: 'PermissionService' }) + ); this.logger.info('DIContainer initialized', { automationMode: config.mode, @@ -141,6 +146,10 @@ export class DIContainer { return this.logger; } + public getPermissionService(): PermissionService { + return this.permissionService; + } + /** * Initialize browser connection for dev mode. * In dev mode, connects to the browser via Chrome DevTools Protocol. diff --git a/apps/companion/main/ipc-handlers.ts b/apps/companion/main/ipc-handlers.ts index f53d685d5..f080f40d0 100644 --- a/apps/companion/main/ipc-handlers.ts +++ b/apps/companion/main/ipc-handlers.ts @@ -11,8 +11,62 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void { const startAutomationUseCase = container.getStartAutomationUseCase(); const sessionRepository = container.getSessionRepository(); const automationEngine = container.getAutomationEngine(); + const permissionService = container.getPermissionService(); const logger = container.getLogger(); + // Permission handlers + ipcMain.handle('automation:checkPermissions', async () => { + try { + const result = await permissionService.checkPermissions(); + return { + success: true, + granted: result.granted, + status: result.status, + missingPermissions: result.missingPermissions, + }; + } catch (error) { + const err = error instanceof Error ? error : new Error('Unknown error'); + logger.error('Permission check failed', err); + return { + success: false, + error: err.message, + granted: false, + status: { + accessibility: false, + screenRecording: false, + platform: process.platform, + }, + missingPermissions: ['Accessibility', 'Screen Recording'], + }; + } + }); + + ipcMain.handle('automation:requestAccessibility', async () => { + try { + const granted = permissionService.requestAccessibilityPermission(); + return { success: true, granted }; + } catch (error) { + const err = error instanceof Error ? error : new Error('Unknown error'); + logger.error('Accessibility permission request failed', err); + return { success: false, granted: false, error: err.message }; + } + }); + + ipcMain.handle('automation:openPermissionSettings', async (_event: IpcMainInvokeEvent, pane?: 'accessibility' | 'screenRecording') => { + try { + if (pane) { + await permissionService.openSystemPreferences(pane); + } else { + await permissionService.openPermissionsSettings(); + } + return { success: true }; + } catch (error) { + const err = error instanceof Error ? error : new Error('Unknown error'); + logger.error('Failed to open permission settings', err); + return { success: false, error: err.message }; + } + }); + ipcMain.handle('start-automation', async (_event: IpcMainInvokeEvent, config: HostedSessionConfig) => { try { logger.info('Starting automation', { sessionName: config.sessionName }); @@ -22,6 +76,20 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void { clearInterval(progressMonitorInterval); progressMonitorInterval = null; } + + // Check permissions before starting automation (macOS only) + const permissionResult = await permissionService.checkPermissions(); + if (!permissionResult.granted) { + logger.warn('Automation blocked due to missing permissions', { + missingPermissions: permissionResult.missingPermissions, + }); + return { + success: false, + error: `Missing required permissions: ${permissionResult.missingPermissions.join(', ')}. Please grant permissions in System Preferences and try again.`, + permissionError: true, + missingPermissions: permissionResult.missingPermissions, + }; + } // Connect to browser first (required for dev mode) const connectionResult = await container.initializeBrowserConnection(); diff --git a/apps/companion/main/preload.ts b/apps/companion/main/preload.ts index 949406ea5..efbf4dc21 100644 --- a/apps/companion/main/preload.ts +++ b/apps/companion/main/preload.ts @@ -1,13 +1,37 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig'; +export interface PermissionStatus { + accessibility: boolean; + screenRecording: boolean; + platform: NodeJS.Platform; +} + +export interface PermissionCheckResponse { + success: boolean; + granted: boolean; + status: PermissionStatus; + missingPermissions: string[]; + error?: string; +} + export interface ElectronAPI { - startAutomation: (config: HostedSessionConfig) => Promise<{ success: boolean; sessionId?: string; error?: string }>; + startAutomation: (config: HostedSessionConfig) => Promise<{ + success: boolean; + sessionId?: string; + error?: string; + permissionError?: boolean; + missingPermissions?: string[]; + }>; stopAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>; getSessionStatus: (sessionId: string) => Promise; pauseAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>; resumeAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>; onSessionProgress: (callback: (progress: any) => void) => void; + // Permission APIs + checkPermissions: () => Promise; + requestAccessibility: () => Promise<{ success: boolean; granted: boolean; error?: string }>; + openPermissionSettings: (pane?: 'accessibility' | 'screenRecording') => Promise<{ success: boolean; error?: string }>; } contextBridge.exposeInMainWorld('electronAPI', { @@ -18,5 +42,10 @@ contextBridge.exposeInMainWorld('electronAPI', { resumeAutomation: (sessionId: string) => ipcRenderer.invoke('resume-automation', sessionId), onSessionProgress: (callback: (progress: any) => void) => { ipcRenderer.on('session-progress', (_event, progress) => callback(progress)); - } + }, + // Permission APIs + checkPermissions: () => ipcRenderer.invoke('automation:checkPermissions'), + requestAccessibility: () => ipcRenderer.invoke('automation:requestAccessibility'), + openPermissionSettings: (pane?: 'accessibility' | 'screenRecording') => + ipcRenderer.invoke('automation:openPermissionSettings', pane), } as ElectronAPI); \ No newline at end of file diff --git a/apps/companion/renderer/App.tsx b/apps/companion/renderer/App.tsx index 90b66e4d9..e418fef92 100644 --- a/apps/companion/renderer/App.tsx +++ b/apps/companion/renderer/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { SessionCreationForm } from './components/SessionCreationForm'; import { SessionProgressMonitor } from './components/SessionProgressMonitor'; import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig'; @@ -12,25 +12,72 @@ interface SessionProgress { errorMessage: string | null; } +interface PermissionStatus { + accessibility: boolean; + screenRecording: boolean; + platform: string; +} + export function App() { const [sessionId, setSessionId] = useState(null); const [progress, setProgress] = useState(null); const [isRunning, setIsRunning] = useState(false); + const [permissionStatus, setPermissionStatus] = useState(null); + const [permissionChecking, setPermissionChecking] = useState(true); + const [missingPermissions, setMissingPermissions] = useState([]); + + const checkPermissions = useCallback(async () => { + if (!window.electronAPI) return; + + setPermissionChecking(true); + try { + const result = await window.electronAPI.checkPermissions(); + setPermissionStatus(result.status); + setMissingPermissions(result.missingPermissions); + } catch (error) { + console.error('Failed to check permissions:', error); + } finally { + setPermissionChecking(false); + } + }, []); useEffect(() => { + // Check permissions on app start + checkPermissions(); + if (window.electronAPI) { window.electronAPI.onSessionProgress((newProgress: SessionProgress) => { setProgress(newProgress); - if (newProgress.state === 'COMPLETED' || - newProgress.state === 'FAILED' || + if (newProgress.state === 'COMPLETED' || + newProgress.state === 'FAILED' || newProgress.state === 'STOPPED_AT_STEP_18') { setIsRunning(false); } }); } - }, []); + }, [checkPermissions]); + + const handleOpenPermissionSettings = async (pane?: 'accessibility' | 'screenRecording') => { + if (!window.electronAPI) return; + await window.electronAPI.openPermissionSettings(pane); + }; + + const handleRequestAccessibility = async () => { + if (!window.electronAPI) return; + await window.electronAPI.requestAccessibility(); + // Recheck permissions after request + setTimeout(checkPermissions, 500); + }; const handleStartAutomation = async (config: HostedSessionConfig) => { + // Recheck permissions before starting + await checkPermissions(); + + if (missingPermissions.length > 0) { + alert(`Cannot start automation: Missing permissions: ${missingPermissions.join(', ')}`); + return; + } + setIsRunning(true); const result = await window.electronAPI.startAutomation(config); @@ -38,7 +85,13 @@ export function App() { setSessionId(result.sessionId); } else { setIsRunning(false); - alert(`Failed to start automation: ${result.error}`); + if (result.permissionError) { + // Update permission status + await checkPermissions(); + alert(`Permission Error: ${result.error}`); + } else { + alert(`Failed to start automation: ${result.error}`); + } } }; @@ -54,6 +107,9 @@ export function App() { } }; + const isMacOS = permissionStatus?.platform === 'darwin'; + const hasAllPermissions = missingPermissions.length === 0; + return (
Hosted Session Automation POC

+ + {/* Permission Banner */} + {isMacOS && !permissionChecking && !hasAllPermissions && ( +
+

+ ⚠️ Missing Permissions +

+

+ GridPilot requires macOS permissions to control your computer for automation. + Please grant the following permissions: +

+
    + {!permissionStatus?.accessibility && ( +
  • + Accessibility: Required for keyboard and mouse control +
  • + )} + {!permissionStatus?.screenRecording && ( +
  • + Screen Recording: Required for screen capture and window detection +
  • + )} +
+
+ {!permissionStatus?.accessibility && ( + + )} + {!permissionStatus?.accessibility && ( + + )} + {!permissionStatus?.screenRecording && ( + + )} + +
+

+ After granting permissions in System Preferences, click "Recheck Permissions" or restart the app. +

+
+ )} + + {/* Permission Status Indicator */} + {isMacOS && !permissionChecking && hasAllPermissions && ( +
+ + + All permissions granted - Ready for automation + +
+ )} + {isRunning && (