working companion prototype

This commit is contained in:
2025-11-24 23:32:36 +01:00
parent e7978024d7
commit e2bea9a126
175 changed files with 23227 additions and 3519 deletions

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { SessionCreationForm } from './components/SessionCreationForm';
import { SessionProgressMonitor } from './components/SessionProgressMonitor';
import { LoginPrompt } from './components/LoginPrompt';
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
interface SessionProgress {
@@ -12,72 +13,97 @@ interface SessionProgress {
errorMessage: string | null;
}
interface PermissionStatus {
accessibility: boolean;
screenRecording: boolean;
platform: string;
}
type AuthState = 'UNKNOWN' | 'AUTHENTICATED' | 'EXPIRED' | 'LOGGED_OUT' | 'CHECKING';
type LoginStatus = 'idle' | 'waiting' | 'success' | 'error';
export function App() {
const [authState, setAuthState] = useState<AuthState>('CHECKING');
const [authError, setAuthError] = useState<string | undefined>(undefined);
const [sessionId, setSessionId] = useState<string | null>(null);
const [progress, setProgress] = useState<SessionProgress | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [permissionStatus, setPermissionStatus] = useState<PermissionStatus | null>(null);
const [permissionChecking, setPermissionChecking] = useState(true);
const [missingPermissions, setMissingPermissions] = useState<string[]>([]);
const [loginStatus, setLoginStatus] = useState<LoginStatus>('idle');
const checkPermissions = useCallback(async () => {
const handleLogin = useCallback(async () => {
if (!window.electronAPI) return;
setPermissionChecking(true);
setAuthError(undefined);
setLoginStatus('waiting');
try {
const result = await window.electronAPI.checkPermissions();
setPermissionStatus(result.status);
setMissingPermissions(result.missingPermissions);
// This now waits for login to complete (auto-detects and closes browser)
const result = await window.electronAPI.initiateLogin();
if (result.success) {
// Login completed successfully - browser closed automatically
setLoginStatus('success');
// Show success message for 2 seconds before transitioning
setTimeout(() => {
setAuthState('AUTHENTICATED');
}, 2000);
} else {
setLoginStatus('error');
setAuthError(result.error);
}
} catch (error) {
console.error('Failed to check permissions:', error);
} finally {
setPermissionChecking(false);
console.error('Login failed:', error);
setLoginStatus('error');
setAuthError(error instanceof Error ? error.message : 'Login failed');
}
}, []);
const handleRetryAuth = useCallback(async () => {
if (!window.electronAPI) return;
setAuthState('CHECKING');
setAuthError(undefined);
setLoginStatus('idle');
try {
const result = await window.electronAPI.checkAuth();
if (result.success && result.state) {
setAuthState(result.state as AuthState);
} else {
setAuthError(result.error);
setAuthState('UNKNOWN');
}
} catch (error) {
console.error('Auth check failed:', error);
setAuthError(error instanceof Error ? error.message : 'Connection failed');
setAuthState('UNKNOWN');
}
}, []);
useEffect(() => {
// Check permissions on app start
checkPermissions();
if (!window.electronAPI) return;
if (window.electronAPI) {
window.electronAPI.onSessionProgress((newProgress: SessionProgress) => {
setProgress(newProgress);
if (newProgress.state === 'COMPLETED' ||
newProgress.state === 'FAILED' ||
newProgress.state === 'STOPPED_AT_STEP_18') {
setIsRunning(false);
const checkAuth = async () => {
try {
const result = await window.electronAPI.checkAuth();
if (result.success && result.state) {
setAuthState(result.state as AuthState);
} else {
setAuthError(result.error || 'Failed to check authentication');
setAuthState('UNKNOWN');
}
});
}
}, [checkPermissions]);
} catch (error) {
setAuthError(error instanceof Error ? error.message : 'Failed to check authentication');
setAuthState('UNKNOWN');
}
};
const handleOpenPermissionSettings = async (pane?: 'accessibility' | 'screenRecording') => {
if (!window.electronAPI) return;
await window.electronAPI.openPermissionSettings(pane);
};
checkAuth();
const handleRequestAccessibility = async () => {
if (!window.electronAPI) return;
await window.electronAPI.requestAccessibility();
// Recheck permissions after request
setTimeout(checkPermissions, 500);
};
window.electronAPI.onSessionProgress((newProgress: SessionProgress) => {
setProgress(newProgress);
if (newProgress.state === 'COMPLETED' ||
newProgress.state === 'FAILED' ||
newProgress.state === 'STOPPED_AT_STEP_18') {
setIsRunning(false);
}
});
}, []);
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);
@@ -85,13 +111,7 @@ export function App() {
setSessionId(result.sessionId);
} else {
setIsRunning(false);
if (result.permissionError) {
// Update permission status
await checkPermissions();
alert(`Permission Error: ${result.error}`);
} else {
alert(`Failed to start automation: ${result.error}`);
}
alert(`Failed to start automation: ${result.error}`);
}
};
@@ -107,8 +127,47 @@ export function App() {
}
};
const isMacOS = permissionStatus?.platform === 'darwin';
const hasAllPermissions = missingPermissions.length === 0;
if (authState === 'CHECKING') {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
backgroundColor: '#1a1a1a',
}}>
<div style={{
width: '60px',
height: '60px',
border: '4px solid #333',
borderTopColor: '#007bff',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<style>{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
<p style={{ color: '#aaa', marginTop: '1.5rem', fontSize: '1.1rem' }}>
Checking authentication...
</p>
</div>
);
}
if (authState !== 'AUTHENTICATED') {
return (
<LoginPrompt
authState={authState}
errorMessage={authError}
onLogin={handleLogin}
onRetry={handleRetryAuth}
loginStatus={loginStatus}
/>
);
}
return (
<div style={{
@@ -128,126 +187,9 @@ export function App() {
Hosted Session Automation POC
</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
onSubmit={handleStartAutomation}
disabled={isRunning || (isMacOS && !hasAllPermissions)}
disabled={isRunning}
/>
{isRunning && (
<button

View File

@@ -0,0 +1,294 @@
import React from 'react';
type LoginStatus = 'idle' | 'waiting' | 'success' | 'error';
interface LoginPromptProps {
authState: string;
errorMessage?: string;
onLogin: () => void;
onRetry: () => void;
loginStatus?: LoginStatus;
}
export function LoginPrompt({
authState,
errorMessage,
onLogin,
onRetry,
loginStatus = 'idle'
}: LoginPromptProps) {
// Show success state when login completed
if (loginStatus === 'success') {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
backgroundColor: '#1a1a1a',
padding: '2rem',
}}>
<div style={{
maxWidth: '500px',
width: '100%',
padding: '2rem',
backgroundColor: '#252525',
borderRadius: '12px',
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.3)',
textAlign: 'center',
}}>
<div style={{
width: '80px',
height: '80px',
margin: '0 auto 1.5rem',
backgroundColor: '#1a472a',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2.5rem',
color: '#4ade80',
animation: 'scaleIn 0.3s ease-out',
}}>
</div>
<style>{`
@keyframes scaleIn {
from { transform: scale(0); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
<h1 style={{
color: '#4ade80',
fontSize: '1.75rem',
marginBottom: '0.5rem',
fontWeight: 600,
animation: 'fadeIn 0.4s ease-out 0.1s both',
}}>
Login Successful!
</h1>
<p style={{
color: '#aaa',
fontSize: '1rem',
marginBottom: '1rem',
lineHeight: 1.5,
animation: 'fadeIn 0.4s ease-out 0.2s both',
}}>
You're now connected to iRacing.
</p>
<p style={{
color: '#666',
fontSize: '0.9rem',
animation: 'fadeIn 0.4s ease-out 0.3s both',
}}>
Redirecting...
</p>
</div>
</div>
);
}
const getStateMessage = () => {
switch (authState) {
case 'EXPIRED':
return 'Your iRacing session has expired. Please log in again to continue.';
case 'LOGGED_OUT':
return 'You have been logged out. Please log in to use GridPilot.';
case 'UNKNOWN':
return errorMessage
? `Unable to verify authentication: ${errorMessage}`
: 'Unable to verify your authentication status.';
default:
return null; // Will show explanation instead
}
};
const stateMessage = getStateMessage();
const isWaiting = loginStatus === 'waiting';
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
backgroundColor: '#1a1a1a',
padding: '2rem',
}}>
<div style={{
maxWidth: '500px',
width: '100%',
padding: '2rem',
backgroundColor: '#252525',
borderRadius: '12px',
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.3)',
textAlign: 'center',
}}>
<div style={{
width: '80px',
height: '80px',
margin: '0 auto 1.5rem',
backgroundColor: '#333',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2.5rem',
}}>
{isWaiting ? '' : '🔐'}
</div>
<h1 style={{
color: '#fff',
fontSize: '1.75rem',
marginBottom: '0.5rem',
fontWeight: 600,
}}>
{isWaiting ? 'Waiting for Login...' : 'iRacing Login Required'}
</h1>
{stateMessage ? (
<p style={{
color: '#aaa',
fontSize: '1rem',
marginBottom: '2rem',
lineHeight: 1.5,
}}>
{stateMessage}
</p>
) : (
<div style={{
color: '#aaa',
fontSize: '1rem',
marginBottom: '2rem',
lineHeight: 1.6,
textAlign: 'left',
}}>
<p style={{ marginBottom: '1rem' }}>
<strong style={{ color: '#fff' }}>Why do I need to log in?</strong>
</p>
<p style={{ marginBottom: '0.75rem' }}>
GridPilot needs to access your iRacing account to create and manage hosted sessions on your behalf. This requires authentication with iRacing's website.
</p>
<ul style={{
margin: '0.75rem 0',
paddingLeft: '1.25rem',
color: '#888',
}}>
<li style={{ marginBottom: '0.5rem' }}> Your credentials are entered directly on iRacing.com</li>
<li style={{ marginBottom: '0.5rem' }}> GridPilot never sees or stores your password</li>
<li> Session cookies are stored locally for convenience</li>
</ul>
</div>
)}
{isWaiting ? (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1rem',
}}>
<div style={{
width: '40px',
height: '40px',
border: '3px solid #333',
borderTopColor: '#007bff',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<style>{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
<p style={{
color: '#aaa',
fontSize: '0.95rem',
}}>
A browser window has opened. Please log in to iRacing.
</p>
<p style={{
color: '#666',
fontSize: '0.85rem',
}}>
This window will update automatically when login is detected.
</p>
</div>
) : (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}>
<button
onClick={onLogin}
style={{
padding: '1rem 2rem',
backgroundColor: '#007bff',
color: '#fff',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '1.1rem',
fontWeight: 600,
transition: 'background-color 0.2s',
}}
onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#0056b3'}
onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#007bff'}
>
Log in to iRacing
</button>
{(authState === 'UNKNOWN' || errorMessage) && (
<button
onClick={onRetry}
style={{
padding: '0.75rem 1.5rem',
backgroundColor: 'transparent',
color: '#aaa',
border: '1px solid #555',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '0.95rem',
transition: 'all 0.2s',
}}
onMouseOver={(e) => {
e.currentTarget.style.borderColor = '#777';
e.currentTarget.style.color = '#fff';
}}
onMouseOut={(e) => {
e.currentTarget.style.borderColor = '#555';
e.currentTarget.style.color = '#aaa';
}}
>
Retry Connection
</button>
)}
</div>
)}
{!isWaiting && (
<p style={{
color: '#666',
fontSize: '0.85rem',
marginTop: '2rem',
lineHeight: 1.4,
}}>
A browser window will open for you to log in securely to iRacing.
The window will close automatically once login is complete.
</p>
)}
</div>
</div>
);
}