Files
gridpilot.gg/packages/infrastructure/adapters/automation/PermissionService.ts

216 lines
6.4 KiB
TypeScript

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;
}
}