feat(automation): add macOS permission check before automation start
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
|||||||
216
packages/infrastructure/adapters/automation/PermissionService.ts
Normal file
216
packages/infrastructure/adapters/automation/PermissionService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user