feat(automation): add macOS permission check before automation start

This commit is contained in:
2025-11-22 14:52:48 +01:00
parent 98baa1c3bc
commit c0e0e00c4c
6 changed files with 510 additions and 9 deletions

View File

@@ -3,6 +3,7 @@ import { MockBrowserAutomationAdapter } from '@/packages/infrastructure/adapters
import { BrowserDevToolsAdapter } from '@/packages/infrastructure/adapters/automation/BrowserDevToolsAdapter'; import { BrowserDevToolsAdapter } from '@/packages/infrastructure/adapters/automation/BrowserDevToolsAdapter';
import { NutJsAutomationAdapter } from '@/packages/infrastructure/adapters/automation/NutJsAutomationAdapter'; import { NutJsAutomationAdapter } from '@/packages/infrastructure/adapters/automation/NutJsAutomationAdapter';
import { MockAutomationEngineAdapter } from '@/packages/infrastructure/adapters/automation/MockAutomationEngineAdapter'; 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 { StartAutomationSessionUseCase } from '@/packages/application/use-cases/StartAutomationSessionUseCase';
import { loadAutomationConfig, AutomationMode } from '@/packages/infrastructure/config'; import { loadAutomationConfig, AutomationMode } from '@/packages/infrastructure/config';
import { PinoLogAdapter } from '@/packages/infrastructure/adapters/logging/PinoLogAdapter'; import { PinoLogAdapter } from '@/packages/infrastructure/adapters/logging/PinoLogAdapter';
@@ -74,6 +75,7 @@ export class DIContainer {
private automationEngine: IAutomationEngine; private automationEngine: IAutomationEngine;
private startAutomationUseCase: StartAutomationSessionUseCase; private startAutomationUseCase: StartAutomationSessionUseCase;
private automationMode: AutomationMode; private automationMode: AutomationMode;
private permissionService: PermissionService;
private constructor() { private constructor() {
// Initialize logger first - it's needed by other components // Initialize logger first - it's needed by other components
@@ -94,6 +96,9 @@ export class DIContainer {
this.browserAutomation, this.browserAutomation,
this.sessionRepository this.sessionRepository
); );
this.permissionService = new PermissionService(
this.logger.child({ service: 'PermissionService' })
);
this.logger.info('DIContainer initialized', { this.logger.info('DIContainer initialized', {
automationMode: config.mode, automationMode: config.mode,
@@ -141,6 +146,10 @@ export class DIContainer {
return this.logger; return this.logger;
} }
public getPermissionService(): PermissionService {
return this.permissionService;
}
/** /**
* Initialize browser connection for dev mode. * Initialize browser connection for dev mode.
* In dev mode, connects to the browser via Chrome DevTools Protocol. * In dev mode, connects to the browser via Chrome DevTools Protocol.

View File

@@ -11,8 +11,62 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
const startAutomationUseCase = container.getStartAutomationUseCase(); const startAutomationUseCase = container.getStartAutomationUseCase();
const sessionRepository = container.getSessionRepository(); const sessionRepository = container.getSessionRepository();
const automationEngine = container.getAutomationEngine(); const automationEngine = container.getAutomationEngine();
const permissionService = container.getPermissionService();
const logger = container.getLogger(); 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) => { ipcMain.handle('start-automation', async (_event: IpcMainInvokeEvent, config: HostedSessionConfig) => {
try { try {
logger.info('Starting automation', { sessionName: config.sessionName }); logger.info('Starting automation', { sessionName: config.sessionName });
@@ -22,6 +76,20 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
clearInterval(progressMonitorInterval); clearInterval(progressMonitorInterval);
progressMonitorInterval = null; 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) // Connect to browser first (required for dev mode)
const connectionResult = await container.initializeBrowserConnection(); const connectionResult = await container.initializeBrowserConnection();

View File

@@ -1,13 +1,37 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig'; 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 { 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 }>; stopAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
getSessionStatus: (sessionId: string) => Promise<any>; getSessionStatus: (sessionId: string) => Promise<any>;
pauseAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>; pauseAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
resumeAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>; resumeAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
onSessionProgress: (callback: (progress: any) => void) => void; onSessionProgress: (callback: (progress: any) => void) => void;
// Permission APIs
checkPermissions: () => Promise<PermissionCheckResponse>;
requestAccessibility: () => Promise<{ success: boolean; granted: boolean; error?: string }>;
openPermissionSettings: (pane?: 'accessibility' | 'screenRecording') => Promise<{ success: boolean; error?: string }>;
} }
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
@@ -18,5 +42,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
resumeAutomation: (sessionId: string) => ipcRenderer.invoke('resume-automation', sessionId), resumeAutomation: (sessionId: string) => ipcRenderer.invoke('resume-automation', sessionId),
onSessionProgress: (callback: (progress: any) => void) => { onSessionProgress: (callback: (progress: any) => void) => {
ipcRenderer.on('session-progress', (_event, progress) => callback(progress)); 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); } as ElectronAPI);

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { SessionCreationForm } from './components/SessionCreationForm'; import { SessionCreationForm } from './components/SessionCreationForm';
import { SessionProgressMonitor } from './components/SessionProgressMonitor'; import { SessionProgressMonitor } from './components/SessionProgressMonitor';
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig'; import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
@@ -12,25 +12,72 @@ interface SessionProgress {
errorMessage: string | null; errorMessage: string | null;
} }
interface PermissionStatus {
accessibility: boolean;
screenRecording: boolean;
platform: string;
}
export function App() { export function App() {
const [sessionId, setSessionId] = useState<string | null>(null); const [sessionId, setSessionId] = useState<string | null>(null);
const [progress, setProgress] = useState<SessionProgress | null>(null); const [progress, setProgress] = useState<SessionProgress | null>(null);
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const [permissionStatus, setPermissionStatus] = useState<PermissionStatus | null>(null);
const [permissionChecking, setPermissionChecking] = useState(true);
const [missingPermissions, setMissingPermissions] = useState<string[]>([]);
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(() => { useEffect(() => {
// Check permissions on app start
checkPermissions();
if (window.electronAPI) { if (window.electronAPI) {
window.electronAPI.onSessionProgress((newProgress: SessionProgress) => { window.electronAPI.onSessionProgress((newProgress: SessionProgress) => {
setProgress(newProgress); setProgress(newProgress);
if (newProgress.state === 'COMPLETED' || if (newProgress.state === 'COMPLETED' ||
newProgress.state === 'FAILED' || newProgress.state === 'FAILED' ||
newProgress.state === 'STOPPED_AT_STEP_18') { newProgress.state === 'STOPPED_AT_STEP_18') {
setIsRunning(false); 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) => { 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); setIsRunning(true);
const result = await window.electronAPI.startAutomation(config); const result = await window.electronAPI.startAutomation(config);
@@ -38,7 +85,13 @@ export function App() {
setSessionId(result.sessionId); setSessionId(result.sessionId);
} else { } else {
setIsRunning(false); 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 ( return (
<div style={{ <div style={{
display: 'flex', display: 'flex',
@@ -71,9 +127,127 @@ export function App() {
<p style={{ marginBottom: '2rem', color: '#aaa' }}> <p style={{ marginBottom: '2rem', color: '#aaa' }}>
Hosted Session Automation POC Hosted Session Automation POC
</p> </p>
{/* Permission Banner */}
{isMacOS && !permissionChecking && !hasAllPermissions && (
<div style={{
marginBottom: '1.5rem',
padding: '1rem',
backgroundColor: '#3d2020',
border: '1px solid #dc3545',
borderRadius: '8px',
}}>
<h3 style={{ color: '#ff6b6b', margin: '0 0 0.5rem 0', fontSize: '1rem' }}>
Missing Permissions
</h3>
<p style={{ color: '#ffaaaa', margin: '0 0 1rem 0', fontSize: '0.9rem' }}>
GridPilot requires macOS permissions to control your computer for automation.
Please grant the following permissions:
</p>
<ul style={{ color: '#ffaaaa', margin: '0 0 1rem 0', paddingLeft: '1.5rem', fontSize: '0.9rem' }}>
{!permissionStatus?.accessibility && (
<li style={{ marginBottom: '0.5rem' }}>
<strong>Accessibility:</strong> Required for keyboard and mouse control
</li>
)}
{!permissionStatus?.screenRecording && (
<li style={{ marginBottom: '0.5rem' }}>
<strong>Screen Recording:</strong> Required for screen capture and window detection
</li>
)}
</ul>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{!permissionStatus?.accessibility && (
<button
onClick={handleRequestAccessibility}
style={{
padding: '0.5rem 1rem',
backgroundColor: '#007bff',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
Request Accessibility
</button>
)}
{!permissionStatus?.accessibility && (
<button
onClick={() => handleOpenPermissionSettings('accessibility')}
style={{
padding: '0.5rem 1rem',
backgroundColor: '#6c757d',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
Open Accessibility Settings
</button>
)}
{!permissionStatus?.screenRecording && (
<button
onClick={() => handleOpenPermissionSettings('screenRecording')}
style={{
padding: '0.5rem 1rem',
backgroundColor: '#6c757d',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
Open Screen Recording Settings
</button>
)}
<button
onClick={checkPermissions}
style={{
padding: '0.5rem 1rem',
backgroundColor: '#28a745',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
Recheck Permissions
</button>
</div>
<p style={{ color: '#888', margin: '1rem 0 0 0', fontSize: '0.8rem' }}>
After granting permissions in System Preferences, click "Recheck Permissions" or restart the app.
</p>
</div>
)}
{/* Permission Status Indicator */}
{isMacOS && !permissionChecking && hasAllPermissions && (
<div style={{
marginBottom: '1.5rem',
padding: '0.75rem 1rem',
backgroundColor: '#1e3d1e',
border: '1px solid #28a745',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}}>
<span style={{ color: '#28a745', fontSize: '1.2rem' }}></span>
<span style={{ color: '#8eff8e', fontSize: '0.9rem' }}>
All permissions granted - Ready for automation
</span>
</div>
)}
<SessionCreationForm <SessionCreationForm
onSubmit={handleStartAutomation} onSubmit={handleStartAutomation}
disabled={isRunning} disabled={isRunning || (isMacOS && !hasAllPermissions)}
/> />
{isRunning && ( {isRunning && (
<button <button

View File

@@ -0,0 +1,216 @@
import { systemPreferences, shell } from 'electron';
import type { ILogger } from '../../../application/ports/ILogger';
import { NoOpLogAdapter } from '../logging/NoOpLogAdapter';
/**
* Permission status for macOS automation permissions.
*/
export interface PermissionStatus {
accessibility: boolean;
screenRecording: boolean;
platform: NodeJS.Platform;
}
/**
* Result of a permission check operation.
*/
export interface PermissionCheckResult {
granted: boolean;
status: PermissionStatus;
missingPermissions: string[];
}
/**
* Service for checking and managing macOS permissions required for automation.
*
* On macOS, the following permissions are required:
* - Accessibility: Required for keyboard/mouse control (nut.js)
* - Screen Recording: Required for screen capture and window detection
*
* On other platforms, permissions are assumed to be granted.
*/
export class PermissionService {
private logger: ILogger;
private cachedStatus: PermissionStatus | null = null;
constructor(logger?: ILogger) {
this.logger = logger ?? new NoOpLogAdapter();
}
/**
* Check if all required permissions are granted.
*
* @returns PermissionCheckResult with status of each permission
*/
async checkPermissions(): Promise<PermissionCheckResult> {
const status = await this.getPermissionStatus();
const missingPermissions: string[] = [];
if (!status.accessibility) {
missingPermissions.push('Accessibility');
}
if (!status.screenRecording) {
missingPermissions.push('Screen Recording');
}
const granted = missingPermissions.length === 0;
this.logger.info('Permission check completed', {
granted,
accessibility: status.accessibility,
screenRecording: status.screenRecording,
platform: status.platform,
});
return {
granted,
status,
missingPermissions,
};
}
/**
* Get the current permission status for each required permission.
*/
async getPermissionStatus(): Promise<PermissionStatus> {
const platform = process.platform;
// On non-macOS platforms, assume permissions are granted
if (platform !== 'darwin') {
this.logger.debug('Non-macOS platform, assuming permissions granted', { platform });
return {
accessibility: true,
screenRecording: true,
platform,
};
}
const accessibility = this.checkAccessibilityPermission();
const screenRecording = this.checkScreenRecordingPermission();
this.cachedStatus = {
accessibility,
screenRecording,
platform,
};
this.logger.debug('Permission status retrieved', this.cachedStatus);
return this.cachedStatus;
}
/**
* Check if Accessibility permission is granted.
* Uses systemPreferences.isTrustedAccessibilityClient on macOS.
*/
private checkAccessibilityPermission(): boolean {
try {
// isTrustedAccessibilityClient checks if the app has Accessibility permission
// Pass false to just check without prompting the user
const isTrusted = systemPreferences.isTrustedAccessibilityClient(false);
this.logger.debug('Accessibility permission check', { isTrusted });
return isTrusted;
} catch (error) {
this.logger.warn('Failed to check Accessibility permission', {
error: error instanceof Error ? error.message : String(error),
});
return false;
}
}
/**
* Check if Screen Recording permission is granted.
* Uses systemPreferences.getMediaAccessStatus on macOS.
*/
private checkScreenRecordingPermission(): boolean {
try {
// getMediaAccessStatus with 'screen' checks Screen Recording permission
const status = systemPreferences.getMediaAccessStatus('screen');
const isGranted = status === 'granted';
this.logger.debug('Screen Recording permission check', { status, isGranted });
return isGranted;
} catch (error) {
this.logger.warn('Failed to check Screen Recording permission', {
error: error instanceof Error ? error.message : String(error),
});
return false;
}
}
/**
* Request Accessibility permission by prompting the user.
* This will show a system dialog asking for permission.
*
* @returns true if permission was granted after prompt
*/
requestAccessibilityPermission(): boolean {
if (process.platform !== 'darwin') {
return true;
}
try {
// Pass true to prompt the user if not already trusted
const isTrusted = systemPreferences.isTrustedAccessibilityClient(true);
this.logger.info('Accessibility permission requested', { isTrusted });
return isTrusted;
} catch (error) {
this.logger.error('Failed to request Accessibility permission',
error instanceof Error ? error : new Error(String(error)));
return false;
}
}
/**
* Open System Preferences to the Security & Privacy pane.
*
* @param pane - Which pane to open: 'accessibility' or 'screenRecording'
*/
async openSystemPreferences(pane: 'accessibility' | 'screenRecording'): Promise<void> {
if (process.platform !== 'darwin') {
this.logger.debug('Not on macOS, cannot open System Preferences');
return;
}
// macOS System Preferences URLs
const urls: Record<string, string> = {
accessibility: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility',
screenRecording: 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
};
const url = urls[pane];
if (url) {
this.logger.info('Opening System Preferences', { pane, url });
await shell.openExternal(url);
}
}
/**
* Open System Preferences to show all missing permissions.
* Opens Accessibility pane first if that permission is missing.
*/
async openPermissionsSettings(): Promise<void> {
const status = this.cachedStatus ?? await this.getPermissionStatus();
if (!status.accessibility) {
await this.openSystemPreferences('accessibility');
} else if (!status.screenRecording) {
await this.openSystemPreferences('screenRecording');
}
}
/**
* Get cached permission status without re-checking.
* Returns null if permissions haven't been checked yet.
*/
getCachedStatus(): PermissionStatus | null {
return this.cachedStatus;
}
/**
* Clear the cached permission status.
* Next call to getPermissionStatus will re-check permissions.
*/
clearCache(): void {
this.cachedStatus = null;
}
}

View File

@@ -1,9 +1,11 @@
/** /**
* Automation adapters for browser automation. * Automation adapters for browser automation.
* *
* Exports: * Exports:
* - MockBrowserAutomationAdapter: Mock adapter for testing * - MockBrowserAutomationAdapter: Mock adapter for testing
* - BrowserDevToolsAdapter: Real browser automation via Chrome DevTools Protocol * - BrowserDevToolsAdapter: Real browser automation via Chrome DevTools Protocol
* - NutJsAutomationAdapter: OS-level automation via nut.js
* - PermissionService: macOS permission checking for automation
* - IRacingSelectorMap: CSS selectors for iRacing UI elements * - IRacingSelectorMap: CSS selectors for iRacing UI elements
*/ */
@@ -12,6 +14,9 @@ export { MockBrowserAutomationAdapter } from './MockBrowserAutomationAdapter';
export { BrowserDevToolsAdapter, DevToolsConfig } from './BrowserDevToolsAdapter'; export { BrowserDevToolsAdapter, DevToolsConfig } from './BrowserDevToolsAdapter';
export { NutJsAutomationAdapter, NutJsConfig } from './NutJsAutomationAdapter'; export { NutJsAutomationAdapter, NutJsConfig } from './NutJsAutomationAdapter';
// Permission service
export { PermissionService, PermissionStatus, PermissionCheckResult } from './PermissionService';
// Selector map and utilities // Selector map and utilities
export { export {
IRacingSelectorMap, IRacingSelectorMap,