diff --git a/.env.example b/.env.example index 4e73bab..1076b71 100644 --- a/.env.example +++ b/.env.example @@ -64,11 +64,23 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" -# AI Provider Keys -OPENAI_API_KEY= -ANTHROPIC_API_KEY= -GEMINI_API_KEY= +# AI Provider Keys (Required for functionality) +# Get your OpenAI API key from: https://platform.openai.com/api-keys +OPENAI_API_KEY=your-openai-api-key-here # Realtime API Configuration VITE_OPENAI_API_KEY="${OPENAI_API_KEY}" VITE_REALTIME_RELAY_URL=wss://localhost:8080/realtime + +# NativePHP/Electron Configuration +# Change this to your own app identifier (reverse domain format) +NATIVEPHP_APP_ID=com.yourcompany.yourapp +# NATIVEPHP_APP_VERSION=DEBUG + +# Apple Developer Code Signing (Required for macOS distribution) +# Only needed if you want to build signed, distributable macOS apps +# Get these from your Apple Developer Account: https://developer.apple.com/account/ +# IMPORTANT: Never commit real values to version control! +NATIVEPHP_APPLE_ID=your-apple-id@example.com +NATIVEPHP_APPLE_ID_PASS=your-app-specific-password +NATIVEPHP_APPLE_TEAM_ID=YOUR_10_CHAR_TEAM_ID diff --git a/.gitignore b/.gitignore index cc07774..e78f041 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,24 @@ yarn-error.log /.zed /dist .DS_Store + +# Security & Certificates - Never commit these! +*.cer +*.p12 +*.mobileprovision +*.keychain +*.keychain-db +**/certificates/ +**/signing/ +.env.local +.env.*.local +*.pem +*.key.pub +keychain_password.txt +apple_certificates/ + +# Build artifacts that may contain sensitive data +/build/certificates/ +/build/signing/ +/build/*.cer +/build/*.p12 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 061381f..ffc9bc3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,9 +87,10 @@ Unsure where to begin contributing? You can start by looking through these issue php artisan migrate --database=nativephp ``` -5. **Add your OpenAI API key to `.env`:** +5. **Add your API keys to `.env`:** ``` - OPENAI_API_KEY=your-api-key-here + OPENAI_API_KEY=your-openai-api-key-here + NATIVEPHP_APP_ID=com.yourcompany.yourapp ``` 6. **Run the development server:** @@ -97,6 +98,56 @@ Unsure where to begin contributing? You can start by looking through these issue composer dev ``` +## 🔐 Code Signing & Security + +### For Contributors (Development) + +**You don't need Apple Developer certificates for contributing!** The project automatically creates unsigned builds for development, which are perfect for testing your changes. + +If you see warnings like this, it's completely normal: +``` +⚠️ No code signing certificate found - creating unsigned build +💡 To create signed builds, install an Apple Developer certificate in Keychain +``` + +### Security Guidelines + +**❌ NEVER commit these:** +- `.env` file with real credentials +- Certificate files (`.cer`, `.p12`) +- Apple Developer credentials +- Real API keys + +**✅ SAFE to commit:** +- `.env.example` (template only) +- Source code +- Build scripts (auto-detect certificates) +- Documentation + +### Security Hardening + +This project follows security best practices: + +**Minimal Entitlements**: The `entitlements.plist` file uses the principle of least privilege, requesting only: +- `com.apple.security.device.audio-input` - For audio input access +- `com.apple.security.device.microphone` - For microphone access +- `com.apple.security.device.screen-capture` - Required for ScreenCaptureKit system audio capture + +**Removed Dangerous Entitlements**: We explicitly avoid these high-risk permissions: +- `com.apple.security.cs.allow-jit` - Unnecessary JIT compilation +- `com.apple.security.cs.allow-unsigned-executable-memory` - Memory injection risk +- `com.apple.security.cs.disable-executable-page-protection` - Memory corruption risk +- `com.apple.security.cs.disable-library-validation` - Library injection risk + +### Before Committing + +Always verify no sensitive data: +```bash +git status +git diff --cached +grep -r "your-actual-api-key" . --exclude-dir=vendor +``` + ## Pull Request Process 1. **Before submitting:** diff --git a/README.md b/README.md index 044245a..750784e 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,6 @@ This project is licensed under the [MIT License](LICENSE) with the [Commons Clau - ✅ **Modify it** to fit your needs - ✅ **Self-host it** on your own infrastructure - ❌ **Cannot sell** as a hosted service or SaaS product -- ❌ **Cannot resell** without a commercial agreement +- ❌ **Cannot resell** -For commercial licensing inquiries, please reach out via [Discord](https://discord.gg/PhPMPrxcKw) or create an issue. \ No newline at end of file +For any questions, please reach out via [Discord](https://discord.gg/PhPMPrxcKw). diff --git a/app/Http/Middleware/CheckOnboarding.php b/app/Http/Middleware/CheckOnboarding.php index 1952f69..4c491fa 100644 --- a/app/Http/Middleware/CheckOnboarding.php +++ b/app/Http/Middleware/CheckOnboarding.php @@ -3,6 +3,8 @@ namespace App\Http\Middleware; use App\Services\ApiKeyService; +use App\Services\OnboardingStateService; +use App\Services\AudioCapturePermissionService; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -10,10 +12,17 @@ class CheckOnboarding { protected ApiKeyService $apiKeyService; + protected OnboardingStateService $onboardingService; + protected AudioCapturePermissionService $permissionService; - public function __construct(ApiKeyService $apiKeyService) - { + public function __construct( + ApiKeyService $apiKeyService, + OnboardingStateService $onboardingService, + AudioCapturePermissionService $permissionService + ) { $this->apiKeyService = $apiKeyService; + $this->onboardingService = $onboardingService; + $this->permissionService = $permissionService; } /** @@ -23,7 +32,7 @@ public function __construct(ApiKeyService $apiKeyService) */ public function handle(Request $request, Closure $next): Response { - // Routes that should be accessible without API key + // Routes that should be accessible without completed onboarding $excludedRoutes = [ 'onboarding', 'api-keys.edit', @@ -31,6 +40,13 @@ public function handle(Request $request, Closure $next): Response 'api-keys.destroy', 'api.openai.status', 'api.openai.api-key.store', + 'api.onboarding.status', + 'api.onboarding.step', + 'api.onboarding.complete', + 'api.permissions.screen-recording.status', + 'api.permissions.screen-recording.request', + 'api.permissions.screen-recording.check', + 'api.open-external', 'appearance', ]; @@ -44,10 +60,32 @@ public function handle(Request $request, Closure $next): Response return $next($request); } - // Check if API key exists - if (!$this->apiKeyService->hasApiKey()) { - // If not on onboarding page and no API key, redirect to onboarding + // Check if API key exists (Step 1) + $hasApiKey = $this->apiKeyService->hasApiKey(); + + // Check current system permissions (Step 2) - Always check real system state + $permissionCheck = $this->permissionService->checkScreenRecordingPermission(); + $hasPermission = $permissionCheck['granted'] ?? false; + + // Check if complete onboarding is finished (Step 3) + $isOnboardingComplete = $this->onboardingService->isOnboardingComplete(); + + // If any step is missing, redirect to onboarding + if (!$hasApiKey || !$hasPermission || !$isOnboardingComplete) { + // If not on onboarding page, redirect to onboarding if ($request->route() && $request->route()->getName() !== 'onboarding') { + // Update onboarding state based on what's missing + if (!$hasApiKey) { + // No API key - handle API key removal + $this->onboardingService->handleApiKeyRemoval(); + } else if (!$hasPermission) { + // API key exists but no permission - handle permission revocation + $this->onboardingService->handlePermissionRevocation(); + } else if (!$isOnboardingComplete) { + // Steps 1&2 complete but step 3 incomplete - handle incomplete onboarding + $this->onboardingService->handleIncompleteOnboarding(); + } + return redirect()->route('onboarding'); } } diff --git a/app/Providers/NativeAppServiceProvider.php b/app/Providers/NativeAppServiceProvider.php index b2657db..eb3384a 100644 --- a/app/Providers/NativeAppServiceProvider.php +++ b/app/Providers/NativeAppServiceProvider.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Schema; use Native\Laravel\Contracts\ProvidesPhpIni; use Native\Laravel\Facades\Window; +use Native\Laravel\Facades\Screen; class NativeAppServiceProvider implements ProvidesPhpIni { @@ -17,18 +18,21 @@ class NativeAppServiceProvider implements ProvidesPhpIni */ public function boot(): void { + // Get responsive window dimensions (75% of screen size) + $dimensions = $this->getResponsiveWindowDimensions(); + // Create an overlay window for sales assistant Window::open() ->route('realtime-agent') - ->width(1200) - ->height(700) - ->minWidth(400) - ->minHeight(500) + ->width($dimensions['width']) + ->height($dimensions['height']) + ->minWidth($dimensions['minWidth']) + ->minHeight($dimensions['minHeight']) ->titleBarStyle('hidden') ->transparent() ->backgroundColor('#00000000') ->resizable() - ->position(50, 50) + ->position(100) ->webPreferences([ 'nodeIntegration' => true, 'contextIsolation' => false, @@ -36,6 +40,7 @@ public function boot(): void 'backgroundThrottling' => false, 'sandbox' => false, ]) + ->rememberState() // Set window to floating panel level for better screen protection ->alwaysOnTop(false); @@ -77,4 +82,71 @@ protected function seedDatabaseIfNeeded(): void // The app will still work without seed data } } + + /** + * Get responsive window dimensions based on screen size. + * Returns 75% of screen dimensions with sensible minimums. + */ + protected function getResponsiveWindowDimensions(): array + { + try { + // Get primary display dimensions + $displays = null; + try { + $displays = Screen::displays(); + } catch (\Throwable $e) { + // Screen facade not available in non-native environment + throw new \Exception('Screen information not available'); + } + + // Handle case where displays() returns null (non-native environment) + if ($displays === null || empty($displays)) { + throw new \Exception('Screen information not available'); + } + + $primaryDisplay = $displays[0] ?? null; + + if (!$primaryDisplay) { + // Fallback to reasonable defaults if screen info unavailable + return [ + 'width' => 1280, + 'height' => 720, + 'minWidth' => 400, + 'minHeight' => 500, + ]; + } + + // Get screen dimensions from workArea (excludes taskbars/docks) + $screenWidth = $primaryDisplay['workArea']['width']; + $screenHeight = $primaryDisplay['workArea']['height']; + + // Calculate 75% of screen dimensions + $width = (int) ($screenWidth * 0.75); + $height = (int) ($screenHeight * 0.75); + + // Use fixed minimum dimensions (original values) + $minWidth = 400; + $minHeight = 500; + + // Ensure we don't exceed reasonable maximums (90% of screen) + $maxWidth = (int) ($screenWidth * 0.9); + $maxHeight = (int) ($screenHeight * 0.9); + + return [ + 'width' => min($width, $maxWidth), + 'height' => min($height, $maxHeight), + 'minWidth' => $minWidth, + 'minHeight' => $minHeight, + ]; + + } catch (\Exception $e) { + // Fallback to reasonable defaults if anything fails + return [ + 'width' => 1280, + 'height' => 720, + 'minWidth' => 400, + 'minHeight' => 500, + ]; + } + } } diff --git a/app/Services/AudioCapturePermissionService.php b/app/Services/AudioCapturePermissionService.php new file mode 100644 index 0000000..b0a9861 --- /dev/null +++ b/app/Services/AudioCapturePermissionService.php @@ -0,0 +1,284 @@ +getAudioCaptureBinaryPath(); + + if (!$binaryPath) { + return [ + 'granted' => false, + 'error' => 'Audio capture binary not found. Please rebuild the application.', + 'needs_rebuild' => true + ]; + } + + // Use the same "start" command that the working "Start Call" uses + $process = Process::input('{"command": "start"}' . PHP_EOL) + ->timeout(5) + ->start([$binaryPath]); + + // Wait briefly for the process to start and check permission + $startTime = time(); + $timeout = 5; + + while (time() - $startTime < $timeout && $process->running()) { + $output = $process->latestOutput(); + $errorOutput = $process->latestErrorOutput(); + + if (!empty($output)) { + $lines = explode("\n", $output); + + foreach ($lines as $line) { + $line = trim($line); + if (empty($line)) continue; + + // Try to parse JSON response + if ($decoded = json_decode($line, true)) { + if (isset($decoded['type'])) { + if ($decoded['type'] === 'status' && $decoded['state'] === 'capturing') { + // Permission granted! Stop the process immediately + $process->signal(15); // SIGTERM + return [ + 'granted' => true, + 'error' => null, + 'needs_rebuild' => false + ]; + } elseif ($decoded['type'] === 'error') { + $process->signal(15); // SIGTERM + // Check if it's a permission error + if (isset($decoded['code']) && $decoded['code'] === 1001) { + return [ + 'granted' => false, + 'error' => null, + 'needs_rebuild' => false + ]; + } + return [ + 'granted' => false, + 'error' => $decoded['message'] ?? 'Unknown error', + 'needs_rebuild' => false + ]; + } + } + } + } + } + + usleep(200000); // 200ms sleep + } + + // Kill the process if it's still running + if ($process->running()) { + $process->signal(15); // SIGTERM + } + + $result = $process->wait(); + + // Check final output if we didn't find status during loop + $allOutput = $result->output(); + $lines = explode("\n", $allOutput); + + foreach ($lines as $line) { + $line = trim($line); + if (empty($line)) continue; + + if ($decoded = json_decode($line, true)) { + if (isset($decoded['type'])) { + if ($decoded['type'] === 'status' && $decoded['state'] === 'capturing') { + return [ + 'granted' => true, + 'error' => null, + 'needs_rebuild' => false + ]; + } elseif ($decoded['type'] === 'error' && isset($decoded['code']) && $decoded['code'] === 1001) { + return [ + 'granted' => false, + 'error' => null, + 'needs_rebuild' => false + ]; + } + } + } + } + + // If we reach here, no clear permission status was found + return [ + 'granted' => false, + 'error' => 'Unable to determine permission status', + 'needs_rebuild' => false + ]; + + } catch (\Exception $e) { + Log::error('Screen recording permission check failed: ' . $e->getMessage()); + + return [ + 'granted' => false, + 'error' => 'Permission check failed: ' . $e->getMessage(), + 'needs_rebuild' => false + ]; + } + } + + /** + * Request screen recording permission by attempting to start capture + * This triggers the same permission dialog as "Start Call" and opens System Preferences + */ + public function requestScreenRecordingPermission(): array + { + try { + // Use the Swift binary to trigger permission request using the same "start" command + $binaryPath = $this->getAudioCaptureBinaryPath(); + + if (!$binaryPath) { + return [ + 'success' => false, + 'error' => 'Audio capture binary not found. Please rebuild the application.' + ]; + } + + // Use the same "start" command that triggers permission request + $process = Process::input('{"command": "start"}' . PHP_EOL) + ->timeout(15) + ->start([$binaryPath]); + + // Wait for the process to trigger permission request + $startTime = time(); + $timeout = 15; + $permissionRequested = false; + + while (time() - $startTime < $timeout && $process->running()) { + $output = $process->latestOutput(); + + if (!empty($output)) { + $lines = explode("\n", $output); + + foreach ($lines as $line) { + $line = trim($line); + if (empty($line)) continue; + + if ($decoded = json_decode($line, true)) { + if (isset($decoded['type'])) { + if ($decoded['type'] === 'status' && $decoded['state'] === 'capturing') { + // Permission was already granted + $process->signal(15); // SIGTERM + return [ + 'success' => true, + 'error' => null, + 'message' => 'Permission was already granted.' + ]; + } elseif ($decoded['type'] === 'error' && isset($decoded['code']) && $decoded['code'] === 1001) { + // Permission denied, system preferences should open + $permissionRequested = true; + break; + } + } + } + } + } + + if ($permissionRequested) { + break; + } + + usleep(200000); // 200ms sleep + } + + // Kill the process + if ($process->running()) { + $process->signal(15); // SIGTERM + } + + $process->wait(); + + return [ + 'success' => true, + 'error' => null, + 'message' => 'Permission request initiated. Please grant screen recording permission in System Preferences.' + ]; + + } catch (\Exception $e) { + Log::error('Screen recording permission request failed: ' . $e->getMessage()); + + return [ + 'success' => false, + 'error' => 'Permission request failed: ' . $e->getMessage() + ]; + } + } + + /** + * Get the path to the audio capture binary + */ + private function getAudioCaptureBinaryPath(): ?string + { + $basePath = base_path(); + + // Try the build directory first + $buildPath = $basePath . '/' . self::AUDIO_CAPTURE_BINARY; + if (file_exists($buildPath) && is_executable($buildPath)) { + return $buildPath; + } + + // Try the extras directory + $extrasPath = $basePath . '/' . self::EXTRAS_BINARY; + if (file_exists($extrasPath) && is_executable($extrasPath)) { + return $extrasPath; + } + + return null; + } + + /** + * Get permission status with user-friendly message + */ + public function getPermissionStatus(): array + { + $result = $this->checkScreenRecordingPermission(); + + if ($result['needs_rebuild']) { + return [ + 'status' => 'needs_rebuild', + 'message' => 'Audio capture needs to be rebuilt. Please run the build script.', + 'action' => 'rebuild' + ]; + } + + if ($result['error']) { + return [ + 'status' => 'error', + 'message' => $result['error'], + 'action' => 'retry' + ]; + } + + if ($result['granted']) { + return [ + 'status' => 'granted', + 'message' => 'Screen recording permission is granted. You can start capturing audio.', + 'action' => 'none' + ]; + } + + return [ + 'status' => 'denied', + 'message' => 'Screen recording permission is required for audio capture. Please grant permission in System Preferences.', + 'action' => 'request' + ]; + } +} \ No newline at end of file diff --git a/app/Services/OnboardingStateService.php b/app/Services/OnboardingStateService.php new file mode 100644 index 0000000..daeb3d6 --- /dev/null +++ b/app/Services/OnboardingStateService.php @@ -0,0 +1,199 @@ += 1 && $step <= 3) { + Cache::forever(self::STEP_CACHE_KEY, $step); + Log::info("Onboarding step set to: $step"); + } + } + + /** + * Mark a specific step as completed + */ + public function markStepCompleted(int $step): void + { + if ($step >= 1 && $step <= 3) { + $completedSteps = $this->getCompletedSteps(); + $completedSteps[$step] = true; + + Cache::forever(self::CACHE_KEY, $completedSteps); + Log::info("Onboarding step $step marked as completed"); + } + } + + /** + * Reset a specific step (mark as incomplete) + */ + public function resetStep(int $step): void + { + if ($step >= 1 && $step <= 3) { + $completedSteps = $this->getCompletedSteps(); + $completedSteps[$step] = false; + + Cache::forever(self::CACHE_KEY, $completedSteps); + Log::info("Onboarding step $step reset to incomplete"); + } + } + + /** + * Check if a specific step is completed + */ + public function isStepCompleted(int $step): bool + { + $completedSteps = $this->getCompletedSteps(); + return $completedSteps[$step] ?? false; + } + + /** + * Get all completed steps + */ + public function getCompletedSteps(): array + { + return Cache::get(self::CACHE_KEY, [ + 1 => false, // API Key + 2 => false, // Screen Recording Permission + 3 => false, // GitHub Star + ]); + } + + /** + * Check if onboarding is completely finished + */ + public function isOnboardingComplete(): bool + { + $completedSteps = $this->getCompletedSteps(); + return $completedSteps[1] && $completedSteps[2] && $completedSteps[3]; + } + + /** + * Get the next incomplete step + */ + public function getNextIncompleteStep(): int + { + $completedSteps = $this->getCompletedSteps(); + + for ($step = 1; $step <= 3; $step++) { + if (!($completedSteps[$step] ?? false)) { + return $step; + } + } + + return 3; // All steps completed, return last step + } + + /** + * Reset onboarding state (for testing or re-onboarding) + */ + public function resetOnboarding(): void + { + Cache::forget(self::CACHE_KEY); + Cache::forget(self::STEP_CACHE_KEY); + Log::info("Onboarding state reset"); + } + + /** + * Get onboarding status for API responses + */ + public function getOnboardingStatus(): array + { + $completedSteps = $this->getCompletedSteps(); + $currentStep = $this->getCurrentStep(); + $isComplete = $this->isOnboardingComplete(); + + return [ + 'current_step' => $currentStep, + 'completed_steps' => $completedSteps, + 'is_complete' => $isComplete, + 'next_step' => $isComplete ? null : $this->getNextIncompleteStep(), + ]; + } + + /** + * Advanced to next step if current step is completed + */ + public function advanceToNextStep(): int + { + $currentStep = $this->getCurrentStep(); + $nextStep = $this->getNextIncompleteStep(); + + // If current step is completed, advance to next + if ($this->isStepCompleted($currentStep) && $nextStep > $currentStep) { + $this->setCurrentStep($nextStep); + return $nextStep; + } + + return $currentStep; + } + + /** + * Handle permission revocation scenario + * Called when system permissions are revoked outside the app + */ + public function handlePermissionRevocation(): void + { + // Reset step 2 (permission step) to incomplete + $this->resetStep(2); + + // Set current step to 2 so user is taken back to permission screen + $this->setCurrentStep(2); + + Log::info("Permission revocation detected - user redirected to permission step"); + } + + /** + * Handle API key removal scenario + * Called when API key is removed outside the app + */ + public function handleApiKeyRemoval(): void + { + // Reset step 1 (API key step) to incomplete + $this->resetStep(1); + + // Also reset step 2 and 3 since they depend on step 1 + $this->resetStep(2); + $this->resetStep(3); + + // Set current step to 1 so user starts from beginning + $this->setCurrentStep(1); + + Log::info("API key removal detected - user redirected to API key step"); + } + + /** + * Handle incomplete onboarding scenario + * Called when steps 1&2 are complete but step 3 is missing + */ + public function handleIncompleteOnboarding(): void + { + // Ensure steps 1&2 are marked as completed + $this->markStepCompleted(1); + $this->markStepCompleted(2); + + // Set current step to 3 so user is taken to GitHub star step + $this->setCurrentStep(3); + + Log::info("Incomplete onboarding detected - user redirected to GitHub star step"); + } +} \ No newline at end of file diff --git a/build-swift-audio.sh b/build-swift-audio.sh index a21d419..dff6917 100755 --- a/build-swift-audio.sh +++ b/build-swift-audio.sh @@ -23,6 +23,56 @@ if [ $? -eq 0 ]; then echo "Executable location: build/native/macos-audio-capture" # Make sure it's executable chmod +x build/native/macos-audio-capture + + # Attempt to sign the binary with available certificate + echo "🔐 Attempting to sign Swift audio capture binary..." + + # Try to find a Developer ID Application certificate first (for distribution) + DIST_CERT=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | cut -d'"' -f2) + + # If no distribution cert, try Development certificate (for local testing) + if [ -z "$DIST_CERT" ]; then + DEV_CERT=$(security find-identity -v -p codesigning | grep "Apple Development" | head -1 | cut -d'"' -f2) + CERT_NAME="$DEV_CERT" + CERT_TYPE="Development" + else + CERT_NAME="$DIST_CERT" + CERT_TYPE="Distribution" + fi + + if [ -n "$CERT_NAME" ]; then + echo "📋 Using $CERT_TYPE certificate: $CERT_NAME" + codesign --force --sign "$CERT_NAME" \ + --options runtime \ + --entitlements native/macos-audio-capture/entitlements.plist \ + build/native/macos-audio-capture + + if [ $? -eq 0 ]; then + echo "✅ Swift audio capture signed successfully with $CERT_TYPE certificate" + + # Also sign the extras directory binary if it exists + if [ -f "extras/macos-audio-capture" ]; then + echo "🔐 Signing extras audio capture binary..." + codesign --force --sign "$CERT_NAME" \ + --options runtime \ + --entitlements native/macos-audio-capture/entitlements.plist \ + extras/macos-audio-capture + + if [ $? -eq 0 ]; then + echo "✅ Extras audio capture signed successfully" + else + echo "⚠️ Warning: Failed to sign extras audio capture (continuing anyway)" + fi + fi + else + echo "⚠️ Warning: Failed to sign Swift audio capture (continuing anyway)" + fi + else + echo "⚠️ No code signing certificate found - creating unsigned build" + echo "💡 To create signed builds, install an Apple Developer certificate in Keychain" + echo " For distribution: 'Developer ID Application' certificate" + echo " For development: 'Apple Development' certificate" + fi else echo "❌ Failed to build Swift audio capture" exit 1 diff --git a/build/native/macos-audio-capture b/build/native/macos-audio-capture index be8d60e..1b05092 100755 Binary files a/build/native/macos-audio-capture and b/build/native/macos-audio-capture differ diff --git a/config/nativephp.php b/config/nativephp.php index 3d3e458..3104b91 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -13,8 +13,7 @@ * usually in the form of a reverse domain name. * For example: com.nativephp.app */ - ' - ' => env('NATIVEPHP_APP_ID', 'com.nativephp.app'), + 'app_id' => env('NATIVEPHP_APP_ID', 'com.nativephp.app'), /** * If your application allows deep linking, you can specify the scheme diff --git a/extras/macos-audio-capture b/extras/macos-audio-capture index f4bae59..75dde4c 100755 Binary files a/extras/macos-audio-capture and b/extras/macos-audio-capture differ diff --git a/native/macos-audio-capture/entitlements.plist b/native/macos-audio-capture/entitlements.plist new file mode 100644 index 0000000..b2f1b0d --- /dev/null +++ b/native/macos-audio-capture/entitlements.plist @@ -0,0 +1,44 @@ + + + + + + + + com.apple.security.device.audio-input + + + + com.apple.security.device.microphone + + + + com.apple.security.device.screen-capture + + + + + \ No newline at end of file diff --git a/resources/js/pages/Onboarding.vue b/resources/js/pages/Onboarding.vue index 6287612..142b84e 100644 --- a/resources/js/pages/Onboarding.vue +++ b/resources/js/pages/Onboarding.vue @@ -23,10 +23,10 @@
@@ -136,8 +136,138 @@
- +
+
+
+ + + +
+

Screen Recording Permission

+

+ Clueless needs screen recording permission to capture system audio during meetings. This permission is required + for the app to work properly. +

+ +
+
+
+ + + +

Permission granted!

+
+
+ +
+
+ + + +

Permission required

+
+
+ +
+
+ + + + +

Checking permission...

+
+
+ +
+
+ + + +

{{ permissionMessage }}

+
+
+
+ + +
+ +
+ + +
+
+ + +
@@ -175,7 +305,7 @@
- +
@@ -1067,7 +1076,7 @@ watch(topics, (newTopics) => { const handleDashboardClick = async () => { if (isActive.value) { - const confirmEnd = confirm('You have an active call. Are you sure you want to end the call and view call history?'); + const confirmEnd = confirm('You have an active call. Are you sure you want to end the call and view dashboard?'); if (confirmEnd) { await stopSession(); router.visit('/conversations'); diff --git a/resources/js/pages/RealtimeAgent/Settings.vue b/resources/js/pages/RealtimeAgent/Settings.vue index 0fad73c..bdd9445 100644 --- a/resources/js/pages/RealtimeAgent/Settings.vue +++ b/resources/js/pages/RealtimeAgent/Settings.vue @@ -88,6 +88,194 @@
+ +
+
+

Permissions

+

Manage system permissions required for Clueless to function properly.

+
+ +
+ + +
+
+
+ + + +
+
+

Screen Recording

+

+ Required for system audio capture during meetings +

+
+
+
+
+ + + + Granted +
+
+ + + + Denied +
+
+ + + + + Checking... +
+
+ + + + Unknown +
+
+ + +
+
+
+
+ {{ permissionMessage }} +
+
+ + + +
+
+ + + +
+

+ Why is screen recording permission needed? +

+

+ Clueless uses macOS ScreenCaptureKit to capture system audio during meetings. This requires + screen recording permission, but the app only captures audio data - no visual content is + recorded or stored. +

+

+ To manually grant permission: Go to System Preferences → Privacy & Security → + Screen Recording, then enable Clueless. +

+
+
+
+
+
+
+
@@ -208,7 +396,7 @@ import AppLayout from '@/layouts/AppLayout.vue'; import type { BreadcrumbItem } from '@/types'; import { Head, router } from '@inertiajs/vue3'; import axios from 'axios'; -import { BookOpen, Home, Key, MessageCircle, MessageSquare, Variable } from 'lucide-vue-next'; +import { BookOpen, Home, Key, MessageCircle, MessageSquare, Shield, Variable } from 'lucide-vue-next'; import { onMounted, ref } from 'vue'; interface Template { @@ -244,6 +432,7 @@ const navItems = [ { id: 'templates', label: 'Templates', icon: MessageSquare }, { id: 'variables', label: 'Variables', icon: Variable }, { id: 'api', label: 'API Configuration', icon: Key }, + { id: 'permissions', label: 'Permissions', icon: Shield }, { id: 'knowledge', label: 'Knowledge Base', icon: BookOpen }, ]; @@ -255,6 +444,11 @@ const activeSection = ref(urlParams.get('section') || 'templates'); const apiKey = ref(''); const showApiKey = ref(false); +// Permission Management +const permissionStatus = ref('checking'); // 'checking', 'granted', 'denied', 'error' +const permissionMessage = ref(''); +const isRequestingPermission = ref(false); + // Template Management const templates = ref([]); @@ -273,6 +467,55 @@ const saveApiKey = () => { alert('API Key saved successfully!'); }; +const checkPermissionStatus = async () => { + try { + permissionStatus.value = 'checking'; + const response = await axios.get('/api/permissions/screen-recording/status'); + + if (response.data.status === 'granted') { + permissionStatus.value = 'granted'; + permissionMessage.value = response.data.message; + } else if (response.data.status === 'denied') { + permissionStatus.value = 'denied'; + permissionMessage.value = response.data.message; + } else if (response.data.status === 'error') { + permissionStatus.value = 'error'; + permissionMessage.value = response.data.message; + } else { + permissionStatus.value = 'error'; + permissionMessage.value = 'Unknown permission status'; + } + } catch (error) { + console.error('Failed to check permission status:', error); + permissionStatus.value = 'error'; + permissionMessage.value = 'Failed to check permission status'; + } +}; + +const requestPermission = async () => { + try { + isRequestingPermission.value = true; + const response = await axios.post('/api/permissions/screen-recording/request'); + + if (response.data.success) { + // Wait a moment for the permission dialog to appear + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Check permission status again + await checkPermissionStatus(); + } else { + permissionStatus.value = 'error'; + permissionMessage.value = response.data.error || 'Failed to request permission'; + } + } catch (error) { + console.error('Failed to request permission:', error); + permissionStatus.value = 'error'; + permissionMessage.value = 'Failed to request permission'; + } finally { + isRequestingPermission.value = false; + } +}; + const handleNavClick = (item: any) => { if (item.link) { router.visit(item.link); @@ -282,6 +525,11 @@ const handleNavClick = (item: any) => { url.searchParams.set('section', item.id); window.history.pushState({}, '', url.toString()); activeSection.value = item.id; + + // Check permission status if navigating to permissions section + if (item.id === 'permissions') { + checkPermissionStatus(); + } } }; @@ -333,5 +581,10 @@ onMounted(() => { if (savedKey) { apiKey.value = savedKey; } + + // Check permission status if on permissions section + if (activeSection.value === 'permissions') { + checkPermissionStatus(); + } }); diff --git a/routes/settings.php b/routes/settings.php index 78d52c7..9af458e 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -12,6 +12,51 @@ // API endpoint for saving API key (used by onboarding) Route::post('/api/openai/api-key', [ApiKeyController::class, 'store'])->name('api.openai.api-key.store'); +// Onboarding State API Routes +Route::prefix('api/onboarding')->group(function () { + Route::get('/status', function () { + $onboardingService = app(\App\Services\OnboardingStateService::class); + return response()->json($onboardingService->getOnboardingStatus()); + })->name('api.onboarding.status'); + + Route::post('/step', function (\Illuminate\Http\Request $request) { + $onboardingService = app(\App\Services\OnboardingStateService::class); + $step = $request->input('step'); + $markCompleted = $request->input('mark_completed', false); + + if ($step >= 1 && $step <= 3) { + $onboardingService->setCurrentStep($step); + + if ($markCompleted) { + $onboardingService->markStepCompleted($step); + } + + return response()->json([ + 'success' => true, + 'current_step' => $step, + 'status' => $onboardingService->getOnboardingStatus() + ]); + } + + return response()->json(['error' => 'Invalid step number'], 400); + })->name('api.onboarding.step'); + + Route::post('/complete', function () { + $onboardingService = app(\App\Services\OnboardingStateService::class); + + // Mark all steps as completed + $onboardingService->markStepCompleted(1); + $onboardingService->markStepCompleted(2); + $onboardingService->markStepCompleted(3); + + return response()->json([ + 'success' => true, + 'message' => 'Onboarding completed successfully', + 'status' => $onboardingService->getOnboardingStatus() + ]); + })->name('api.onboarding.complete'); +}); + Route::get('settings/appearance', function () { return Inertia::render('settings/Appearance'); })->name('appearance'); diff --git a/routes/web.php b/routes/web.php index 9b7c3ae..92fa477 100644 --- a/routes/web.php +++ b/routes/web.php @@ -45,6 +45,25 @@ ]); })->name('api.openai.status'); + +// Screen Recording Permission API Routes +Route::prefix('api/permissions')->group(function () { + Route::get('/screen-recording/status', function () { + $permissionService = app(\App\Services\AudioCapturePermissionService::class); + return response()->json($permissionService->getPermissionStatus()); + })->name('api.permissions.screen-recording.status'); + + Route::post('/screen-recording/request', function () { + $permissionService = app(\App\Services\AudioCapturePermissionService::class); + return response()->json($permissionService->requestScreenRecordingPermission()); + })->name('api.permissions.screen-recording.request'); + + Route::get('/screen-recording/check', function () { + $permissionService = app(\App\Services\AudioCapturePermissionService::class); + return response()->json($permissionService->checkScreenRecordingPermission()); + })->name('api.permissions.screen-recording.check'); +}); + // Open external URL in default browser (for NativePHP) Route::post('/api/open-external', function (\Illuminate\Http\Request $request) { $url = $request->input('url'); diff --git a/tests/Feature/Controllers/ConversationControllerTest.php b/tests/Feature/Controllers/ConversationControllerTest.php index 0764cb6..9c0fe29 100644 --- a/tests/Feature/Controllers/ConversationControllerTest.php +++ b/tests/Feature/Controllers/ConversationControllerTest.php @@ -3,13 +3,13 @@ use App\Models\ConversationSession; use App\Models\ConversationTranscript; use App\Models\ConversationInsight; -use App\Services\ApiKeyService; +use Tests\Traits\MocksOnboarding; + +uses(MocksOnboarding::class); beforeEach(function () { - // Mock API key service to return true (API key exists) for all conversation tests - $mockApiKeyService = Mockery::mock(ApiKeyService::class); - $mockApiKeyService->shouldReceive('hasApiKey')->andReturn(true); - $this->app->instance(ApiKeyService::class, $mockApiKeyService); + // Mock complete onboarding flow (API key + permissions + completion) for all conversation tests + $this->mockCompletedOnboarding(); // Create a test conversation session for some tests $this->session = ConversationSession::create([ diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index e62dcca..0220da7 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -1,22 +1,20 @@ shouldReceive('hasApiKey')->andReturn(true); - $this->app->instance(ApiKeyService::class, $mockApiKeyService); + // Mock complete onboarding flow (API key + permissions + completion) + $this->mockCompletedOnboarding(); $response = $this->get('/dashboard'); $response->assertStatus(200); }); test('realtime agent page is accessible', function () { - // Mock API key service to return true (API key exists) - $mockApiKeyService = Mockery::mock(ApiKeyService::class); - $mockApiKeyService->shouldReceive('hasApiKey')->andReturn(true); - $this->app->instance(ApiKeyService::class, $mockApiKeyService); + // Mock complete onboarding flow (API key + permissions + completion) + $this->mockCompletedOnboarding(); $response = $this->get('/realtime-agent'); $response->assertStatus(200); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 4e79405..d6dba9f 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,12 +1,12 @@ shouldReceive('hasApiKey')->andReturn(true); - $this->app->instance(ApiKeyService::class, $mockApiKeyService); + // Mock complete onboarding flow (API key + permissions + completion) + $this->mockCompletedOnboarding(); $response = $this->get('/'); diff --git a/tests/Traits/MocksOnboarding.php b/tests/Traits/MocksOnboarding.php new file mode 100644 index 0000000..9f27974 --- /dev/null +++ b/tests/Traits/MocksOnboarding.php @@ -0,0 +1,108 @@ +shouldReceive('hasApiKey')->andReturn(true); + $this->app->instance(ApiKeyService::class, $mockApiKeyService); + + // Mock AudioCapturePermissionService - Step 2 (Screen Recording Permission) + $mockPermissionService = Mockery::mock(AudioCapturePermissionService::class); + $mockPermissionService->shouldReceive('checkScreenRecordingPermission')->andReturn([ + 'granted' => true, + 'error' => null, + 'needs_rebuild' => false + ]); + $this->app->instance(AudioCapturePermissionService::class, $mockPermissionService); + + // Mock OnboardingStateService - Step 3 (Onboarding Completion) + $mockOnboardingService = Mockery::mock(OnboardingStateService::class); + $mockOnboardingService->shouldReceive('isOnboardingComplete')->andReturn(true); + $this->app->instance(OnboardingStateService::class, $mockOnboardingService); + } + + /** + * Mock incomplete onboarding (missing API key) to test redirection to onboarding + */ + protected function mockIncompleteOnboarding(): void + { + // Mock ApiKeyService - No API Key + $mockApiKeyService = Mockery::mock(ApiKeyService::class); + $mockApiKeyService->shouldReceive('hasApiKey')->andReturn(false); + $this->app->instance(ApiKeyService::class, $mockApiKeyService); + + // Mock AudioCapturePermissionService + $mockPermissionService = Mockery::mock(AudioCapturePermissionService::class); + $this->app->instance(AudioCapturePermissionService::class, $mockPermissionService); + + // Mock OnboardingStateService with handleApiKeyRemoval expectation + $mockOnboardingService = Mockery::mock(OnboardingStateService::class); + $mockOnboardingService->shouldReceive('handleApiKeyRemoval')->once(); + $this->app->instance(OnboardingStateService::class, $mockOnboardingService); + } + + /** + * Mock missing permissions scenario + */ + protected function mockMissingPermissions(): void + { + // Mock ApiKeyService - Has API Key + $mockApiKeyService = Mockery::mock(ApiKeyService::class); + $mockApiKeyService->shouldReceive('hasApiKey')->andReturn(true); + $this->app->instance(ApiKeyService::class, $mockApiKeyService); + + // Mock AudioCapturePermissionService - No Permission + $mockPermissionService = Mockery::mock(AudioCapturePermissionService::class); + $mockPermissionService->shouldReceive('checkScreenRecordingPermission')->andReturn([ + 'granted' => false, + 'error' => null, + 'needs_rebuild' => false + ]); + $this->app->instance(AudioCapturePermissionService::class, $mockPermissionService); + + // Mock OnboardingStateService with handlePermissionRevocation expectation + $mockOnboardingService = Mockery::mock(OnboardingStateService::class); + $mockOnboardingService->shouldReceive('handlePermissionRevocation')->once(); + $this->app->instance(OnboardingStateService::class, $mockOnboardingService); + } + + /** + * Mock incomplete onboarding (steps 1&2 complete but step 3 missing) + */ + protected function mockIncompleteGithubStep(): void + { + // Mock ApiKeyService - Has API Key + $mockApiKeyService = Mockery::mock(ApiKeyService::class); + $mockApiKeyService->shouldReceive('hasApiKey')->andReturn(true); + $this->app->instance(ApiKeyService::class, $mockApiKeyService); + + // Mock AudioCapturePermissionService - Has Permission + $mockPermissionService = Mockery::mock(AudioCapturePermissionService::class); + $mockPermissionService->shouldReceive('checkScreenRecordingPermission')->andReturn([ + 'granted' => true, + 'error' => null, + 'needs_rebuild' => false + ]); + $this->app->instance(AudioCapturePermissionService::class, $mockPermissionService); + + // Mock OnboardingStateService - Not Complete + $mockOnboardingService = Mockery::mock(OnboardingStateService::class); + $mockOnboardingService->shouldReceive('isOnboardingComplete')->andReturn(false); + $mockOnboardingService->shouldReceive('handleIncompleteOnboarding')->once(); + $this->app->instance(OnboardingStateService::class, $mockOnboardingService); + } +} \ No newline at end of file diff --git a/tests/Unit/Middleware/CheckOnboardingTest.php b/tests/Unit/Middleware/CheckOnboardingTest.php index 6663dd5..eb2ac96 100644 --- a/tests/Unit/Middleware/CheckOnboardingTest.php +++ b/tests/Unit/Middleware/CheckOnboardingTest.php @@ -4,6 +4,8 @@ use App\Http\Middleware\CheckOnboarding; use App\Services\ApiKeyService; +use App\Services\AudioCapturePermissionService; +use App\Services\OnboardingStateService; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Routing\Route; @@ -14,12 +16,21 @@ class CheckOnboardingTest extends TestCase { private CheckOnboarding $middleware; private ApiKeyService $mockApiKeyService; + private AudioCapturePermissionService $mockPermissionService; + private OnboardingStateService $mockOnboardingService; protected function setUp(): void { parent::setUp(); $this->mockApiKeyService = Mockery::mock(ApiKeyService::class); - $this->middleware = new CheckOnboarding($this->mockApiKeyService); + $this->mockPermissionService = Mockery::mock(AudioCapturePermissionService::class); + $this->mockOnboardingService = Mockery::mock(OnboardingStateService::class); + + $this->middleware = new CheckOnboarding( + $this->mockApiKeyService, + $this->mockOnboardingService, + $this->mockPermissionService + ); } protected function tearDown(): void @@ -35,7 +46,10 @@ public function test_allows_onboarding_route_without_api_key(): void $route->name('onboarding'); $request->setRouteResolver(fn() => $route); + // Excluded routes should not trigger any service calls $this->mockApiKeyService->shouldNotReceive('hasApiKey'); + $this->mockPermissionService->shouldNotReceive('checkScreenRecordingPermission'); + $this->mockOnboardingService->shouldNotReceive('isOnboardingComplete'); $response = $this->middleware->handle($request, function ($req) { return new Response('OK'); @@ -52,6 +66,13 @@ public function test_allows_api_key_settings_routes_without_api_key(): void 'api-keys.destroy', 'api.openai.status', 'api.openai.api-key.store', + 'api.onboarding.status', + 'api.onboarding.step', + 'api.onboarding.complete', + 'api.permissions.screen-recording.status', + 'api.permissions.screen-recording.request', + 'api.permissions.screen-recording.check', + 'api.open-external', 'appearance' ]; @@ -61,7 +82,10 @@ public function test_allows_api_key_settings_routes_without_api_key(): void $route->name($routeName); $request->setRouteResolver(fn() => $route); + // Excluded routes should not trigger any service calls $this->mockApiKeyService->shouldNotReceive('hasApiKey'); + $this->mockPermissionService->shouldNotReceive('checkScreenRecordingPermission'); + $this->mockOnboardingService->shouldNotReceive('isOnboardingComplete'); $response = $this->middleware->handle($request, function ($req) { return new Response('OK'); @@ -78,7 +102,10 @@ public function test_allows_api_routes_without_api_key(): void $route->name('api.some-endpoint'); $request->setRouteResolver(fn() => $route); + // API routes should not trigger any service calls $this->mockApiKeyService->shouldNotReceive('hasApiKey'); + $this->mockPermissionService->shouldNotReceive('checkScreenRecordingPermission'); + $this->mockOnboardingService->shouldNotReceive('isOnboardingComplete'); $response = $this->middleware->handle($request, function ($req) { return new Response('OK'); @@ -94,10 +121,24 @@ public function test_redirects_to_onboarding_when_no_api_key(): void $route->name('dashboard'); $request->setRouteResolver(fn() => $route); + // Mock missing API key scenario $this->mockApiKeyService->shouldReceive('hasApiKey') ->once() ->andReturn(false); + // The middleware checks all services even if API key is missing + $this->mockPermissionService->shouldReceive('checkScreenRecordingPermission') + ->once() + ->andReturn(['granted' => false]); + + $this->mockOnboardingService->shouldReceive('isOnboardingComplete') + ->once() + ->andReturn(false); + + // When API key is missing, the onboarding service should handle API key removal + $this->mockOnboardingService->shouldReceive('handleApiKeyRemoval') + ->once(); + $response = $this->middleware->handle($request, function ($req) { return new Response('Should not reach here'); }); @@ -106,17 +147,26 @@ public function test_redirects_to_onboarding_when_no_api_key(): void $this->assertTrue(str_contains($response->headers->get('Location'), '/onboarding')); } - public function test_allows_access_when_api_key_exists(): void + public function test_allows_access_when_all_onboarding_complete(): void { $request = Request::create('/dashboard', 'GET'); $route = new Route(['GET'], '/dashboard', []); $route->name('dashboard'); $request->setRouteResolver(fn() => $route); + // Mock complete onboarding scenario - all steps completed $this->mockApiKeyService->shouldReceive('hasApiKey') ->once() ->andReturn(true); + $this->mockPermissionService->shouldReceive('checkScreenRecordingPermission') + ->once() + ->andReturn(['granted' => true]); + + $this->mockOnboardingService->shouldReceive('isOnboardingComplete') + ->once() + ->andReturn(true); + $response = $this->middleware->handle($request, function ($req) { return new Response('Dashboard content'); }); @@ -133,12 +183,87 @@ public function test_handles_request_without_route(): void ->once() ->andReturn(false); + // The middleware checks all services even if API key is missing + $this->mockPermissionService->shouldReceive('checkScreenRecordingPermission') + ->once() + ->andReturn(['granted' => false]); + + $this->mockOnboardingService->shouldReceive('isOnboardingComplete') + ->once() + ->andReturn(false); + + // When there's no route, the middleware does NOT call any state handlers + // because the redirect condition requires a route to exist + // The middleware just continues normally + $response = $this->middleware->handle($request, function ($req) { return new Response('OK'); }); - // Without a route, middleware allows the request to continue + // Without a route, middleware allows the request to continue even with incomplete onboarding $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('OK', $response->getContent()); } + + public function test_redirects_when_permissions_missing(): void + { + $request = Request::create('/dashboard', 'GET'); + $route = new Route(['GET'], '/dashboard', []); + $route->name('dashboard'); + $request->setRouteResolver(fn() => $route); + + // API key exists but permissions are missing + $this->mockApiKeyService->shouldReceive('hasApiKey') + ->once() + ->andReturn(true); + + $this->mockPermissionService->shouldReceive('checkScreenRecordingPermission') + ->once() + ->andReturn(['granted' => false]); + + $this->mockOnboardingService->shouldReceive('isOnboardingComplete') + ->once() + ->andReturn(true); + + $this->mockOnboardingService->shouldReceive('handlePermissionRevocation') + ->once(); + + $response = $this->middleware->handle($request, function ($req) { + return new Response('Should not reach here'); + }); + + $this->assertEquals(302, $response->getStatusCode()); + $this->assertTrue(str_contains($response->headers->get('Location'), '/onboarding')); + } + + public function test_redirects_when_github_step_incomplete(): void + { + $request = Request::create('/dashboard', 'GET'); + $route = new Route(['GET'], '/dashboard', []); + $route->name('dashboard'); + $request->setRouteResolver(fn() => $route); + + // API key and permissions exist but onboarding not complete + $this->mockApiKeyService->shouldReceive('hasApiKey') + ->once() + ->andReturn(true); + + $this->mockPermissionService->shouldReceive('checkScreenRecordingPermission') + ->once() + ->andReturn(['granted' => true]); + + $this->mockOnboardingService->shouldReceive('isOnboardingComplete') + ->once() + ->andReturn(false); + + $this->mockOnboardingService->shouldReceive('handleIncompleteOnboarding') + ->once(); + + $response = $this->middleware->handle($request, function ($req) { + return new Response('Should not reach here'); + }); + + $this->assertEquals(302, $response->getStatusCode()); + $this->assertTrue(str_contains($response->headers->get('Location'), '/onboarding')); + } } \ No newline at end of file