feat(automation): add macOS permission check before automation start
This commit is contained in:
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.
|
||||
*
|
||||
*
|
||||
* Exports:
|
||||
* - MockBrowserAutomationAdapter: Mock adapter for testing
|
||||
* - 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
|
||||
*/
|
||||
|
||||
@@ -12,6 +14,9 @@ export { MockBrowserAutomationAdapter } from './MockBrowserAutomationAdapter';
|
||||
export { BrowserDevToolsAdapter, DevToolsConfig } from './BrowserDevToolsAdapter';
|
||||
export { NutJsAutomationAdapter, NutJsConfig } from './NutJsAutomationAdapter';
|
||||
|
||||
// Permission service
|
||||
export { PermissionService, PermissionStatus, PermissionCheckResult } from './PermissionService';
|
||||
|
||||
// Selector map and utilities
|
||||
export {
|
||||
IRacingSelectorMap,
|
||||
|
||||
Reference in New Issue
Block a user