diff --git a/apps/windows/.gitignore b/apps/windows/.gitignore new file mode 100644 index 000000000..a0b5ac9fb --- /dev/null +++ b/apps/windows/.gitignore @@ -0,0 +1,14 @@ +# Build outputs +bin/ +obj/ +publish/ +publish-*/ +dist/ + +# IDE +.vs/ +*.user +*.suo + +# WebView2 cache (generated at runtime) +*.WebView2/ diff --git a/apps/windows/ClawdbotWindows.sln b/apps/windows/ClawdbotWindows.sln new file mode 100644 index 000000000..e8023476a --- /dev/null +++ b/apps/windows/ClawdbotWindows.sln @@ -0,0 +1,86 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clawdbot.Windows.Protocol", "src\Clawdbot.Windows.Protocol\Clawdbot.Windows.Protocol.csproj", "{EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clawdbot.Windows.Core", "src\Clawdbot.Windows.Core\Clawdbot.Windows.Core.csproj", "{05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clawdbot.Windows", "src\Clawdbot.Windows\Clawdbot.Windows.csproj", "{E9C2C285-C332-428E-805D-D857DE5F17DC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clawdbot.Windows.Tests", "tests\Clawdbot.Windows.Tests\Clawdbot.Windows.Tests.csproj", "{C72866C4-C786-4B95-AF15-178167AFA4A9}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Debug|x64.ActiveCfg = Debug|Any CPU + {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Debug|x64.Build.0 = Debug|Any CPU + {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Debug|x86.ActiveCfg = Debug|Any CPU + {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Debug|x86.Build.0 = Debug|Any CPU + {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Release|Any CPU.Build.0 = Release|Any CPU + {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Release|x64.ActiveCfg = Release|Any CPU + {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Release|x64.Build.0 = Release|Any CPU + {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Release|x86.ActiveCfg = Release|Any CPU + {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56}.Release|x86.Build.0 = Release|Any CPU + {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Debug|x64.ActiveCfg = Debug|Any CPU + {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Debug|x64.Build.0 = Debug|Any CPU + {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Debug|x86.ActiveCfg = Debug|Any CPU + {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Debug|x86.Build.0 = Debug|Any CPU + {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Release|Any CPU.Build.0 = Release|Any CPU + {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Release|x64.ActiveCfg = Release|Any CPU + {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Release|x64.Build.0 = Release|Any CPU + {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Release|x86.ActiveCfg = Release|Any CPU + {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB}.Release|x86.Build.0 = Release|Any CPU + {E9C2C285-C332-428E-805D-D857DE5F17DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9C2C285-C332-428E-805D-D857DE5F17DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9C2C285-C332-428E-805D-D857DE5F17DC}.Debug|x64.ActiveCfg = Debug|Any CPU + {E9C2C285-C332-428E-805D-D857DE5F17DC}.Debug|x64.Build.0 = Debug|Any CPU + {E9C2C285-C332-428E-805D-D857DE5F17DC}.Debug|x86.ActiveCfg = Debug|Any CPU + {E9C2C285-C332-428E-805D-D857DE5F17DC}.Debug|x86.Build.0 = Debug|Any CPU + {E9C2C285-C332-428E-805D-D857DE5F17DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9C2C285-C332-428E-805D-D857DE5F17DC}.Release|Any CPU.Build.0 = Release|Any CPU + {E9C2C285-C332-428E-805D-D857DE5F17DC}.Release|x64.ActiveCfg = Release|Any CPU + {E9C2C285-C332-428E-805D-D857DE5F17DC}.Release|x64.Build.0 = Release|Any CPU + {E9C2C285-C332-428E-805D-D857DE5F17DC}.Release|x86.ActiveCfg = Release|Any CPU + {E9C2C285-C332-428E-805D-D857DE5F17DC}.Release|x86.Build.0 = Release|Any CPU + {C72866C4-C786-4B95-AF15-178167AFA4A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C72866C4-C786-4B95-AF15-178167AFA4A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C72866C4-C786-4B95-AF15-178167AFA4A9}.Debug|x64.ActiveCfg = Debug|Any CPU + {C72866C4-C786-4B95-AF15-178167AFA4A9}.Debug|x64.Build.0 = Debug|Any CPU + {C72866C4-C786-4B95-AF15-178167AFA4A9}.Debug|x86.ActiveCfg = Debug|Any CPU + {C72866C4-C786-4B95-AF15-178167AFA4A9}.Debug|x86.Build.0 = Debug|Any CPU + {C72866C4-C786-4B95-AF15-178167AFA4A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C72866C4-C786-4B95-AF15-178167AFA4A9}.Release|Any CPU.Build.0 = Release|Any CPU + {C72866C4-C786-4B95-AF15-178167AFA4A9}.Release|x64.ActiveCfg = Release|Any CPU + {C72866C4-C786-4B95-AF15-178167AFA4A9}.Release|x64.Build.0 = Release|Any CPU + {C72866C4-C786-4B95-AF15-178167AFA4A9}.Release|x86.ActiveCfg = Release|Any CPU + {C72866C4-C786-4B95-AF15-178167AFA4A9}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {EE6987D0-BFD2-4823-B6A9-BF49A4C1DE56} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {05A04E95-BD60-4DA9-87AD-ED578AFCEDEB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E9C2C285-C332-428E-805D-D857DE5F17DC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C72866C4-C786-4B95-AF15-178167AFA4A9} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/apps/windows/README.md b/apps/windows/README.md new file mode 100644 index 000000000..249aca032 --- /dev/null +++ b/apps/windows/README.md @@ -0,0 +1,226 @@ +# Clawdbot Windows Companion + +Windows companion application for the Clawdbot Gateway, providing system tray integration and Control UI access. + +## Project Structure + +``` +apps/windows/ +├── ClawdbotWindows.sln # Solution file +├── src/ +│ ├── Clawdbot.Windows/ # Main WPF application +│ │ ├── App.xaml(.cs) # Application entry point +│ │ ├── MainWindow.xaml(.cs) # WebView2 Control UI window +│ │ ├── ExecApprovalDialog.xaml(.cs) # Command approval dialog +│ │ ├── SettingsWindow.xaml(.cs) # Settings UI dialog +│ │ └── SystemTrayIcon.cs # System tray implementation +│ ├── Clawdbot.Windows.Core/ # Gateway client and services +│ │ ├── GatewayChannel.cs # WebSocket client +│ │ ├── AppLogger.cs # File-based logging +│ │ ├── AppSettings.cs # Settings persistence (JSON) +│ │ ├── AutoStartHelper.cs # Windows auto-start (Registry) +│ │ ├── NotificationSounds.cs # System notification sounds +│ │ ├── WebView2Helper.cs # WebView2 runtime detection +│ │ ├── ExecApprovalModels.cs # Exec approval request/response types +│ │ └── ExecApprovalService.cs # Exec approval event handling +│ └── Clawdbot.Windows.Protocol/ # Auto-generated protocol models +│ └── GatewayModels.cs # Protocol v3 types +└── tests/ + └── Clawdbot.Windows.Tests/ # Unit and integration tests + ├── Phase0ValidationTests.cs # Gateway connection tests + ├── ExecApprovalTests.cs # Exec approval model tests + └── SettingsTests.cs # Settings persistence tests +``` + +## Prerequisites + +- .NET 9.0 SDK +- Windows 10 version 1903 or later (for WebView2) +- Microsoft Edge WebView2 Runtime (auto-detected, downloads prompted if missing) +- Clawdbot Gateway running (in WSL2 or natively) + +## Building + +```powershell +cd apps/windows +dotnet build +``` + +## Running + +```powershell +# From the apps/windows directory +dotnet run --project src\Clawdbot.Windows +``` + +Or run the built executable directly: +```powershell +.\src\Clawdbot.Windows\bin\Debug\net9.0-windows\Clawdbot.exe +``` + +The application will: +1. Start minimized to the system tray +2. Attempt to connect to the Gateway at `ws://127.0.0.1:18789/` +3. Display connection status via the tray icon color +4. Show the Control UI in a WebView2 window when double-clicking the tray icon + +## Logging + +Logs are written to: +``` +%LOCALAPPDATA%\Clawdbot\logs\clawdbot-YYYY-MM-DD.log +``` + +View logs in PowerShell: +```powershell +Get-Content "$env:LOCALAPPDATA\Clawdbot\logs\clawdbot-$(Get-Date -Format 'yyyy-MM-dd').log" -Wait +``` + +## Settings + +Settings are stored in: +``` +%LOCALAPPDATA%\Clawdbot\settings.json +``` + +Access settings via the system tray icon → **Settings**. + +### Available Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| Gateway URL | `ws://127.0.0.1:18789/` | WebSocket URL of the Gateway | +| Start on login | Off | Launch automatically at Windows startup | +| Minimize to tray | On | Minimize to tray instead of closing | +| Play sounds | On | Play notification sounds for events | +| Show connection notifications | On | Show balloon notifications | + +### Auto-Start + +When enabled, adds a registry entry to: +``` +HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Run +``` + +This starts Clawdbot automatically when you log in to Windows. + +## Exec Approval Dialogs + +When an AI agent requests to run a command, Clawdbot shows an approval dialog: + +![Exec Approval Dialog](../../docs/images/windows-exec-approval.png) + +### Features +- **Command display**: Shows the full command in a monospaced font +- **Context details**: Working directory, executable path, host, agent ID, security level +- **Countdown timer**: Auto-denies after timeout (typically 60 seconds) +- **Three actions**: + - **Allow Once** (green): Run this command once + - **Always Allow** (blue): Add to allowlist and run + - **Don't Allow** (red): Deny the command + +### Testing +In Debug builds, right-click the system tray icon and select **Debug > Test Approval Dialog** to see a sample dialog. + +### Keyboard Shortcuts +- **Enter**: Allow Once (default button) +- **Escape**: Don't Allow + +## Testing + +### Unit Tests +```powershell +dotnet test +``` + +Current test results: **23 passing, 7 skipped** (integration tests require Gateway) + +### Integration Tests (requires running Gateway) +Start the Gateway first: +```bash +# In WSL2 or Linux terminal +clawdbot gateway run --bind loopback --port 18789 +``` + +Then the integration tests will run automatically (they detect if the Gateway is available). + +## Architecture + +### Clawdbot.Windows.Protocol +Auto-generated C# models matching the Gateway's TypeScript schema (Protocol version 3). +- Request/response frames +- Event payloads +- Configuration types + +### Clawdbot.Windows.Core +Gateway client with: +- WebSocket connection management +- Automatic reconnection with exponential backoff +- Request/response correlation +- Event subscription and dispatch +- File-based logging (AppLogger) +- WebView2 runtime detection (WebView2Helper) + +### Clawdbot.Windows (WPF App) +- System tray icon with context menu +- WebView2 embedding the Control UI (`http://127.0.0.1:18789/ui`) +- Connection status visualization +- Graceful exit handling + +## Development Status + +**Phase 0: Foundation Validation** ✅ +- [x] Project structure +- [x] Protocol models (GatewayModels.cs) +- [x] Gateway WebSocket client (GatewayChannel.cs) +- [x] Unit tests passing (23/23) +- [x] Application icon from favicon.ico +- [x] File-based debug logging +- [x] WebView2 runtime detection + +**Phase 1: Core Features** ✅ +- [x] System tray icon with context menu +- [x] WebView2 Control UI embedding +- [x] Connection status in tray tooltip +- [x] Exec approval dialogs (ExecApprovalDialog.xaml) +- [x] Exec approval service with queue management +- [x] Timeout handling (auto-deny on expiry) +- [x] Debug menu for testing dialogs + +**Phase 2: Production Ready** ✅ +- [x] Settings persistence (`%LOCALAPPDATA%\Clawdbot\settings.json`) +- [x] Auto-start on login (Windows Registry) +- [x] Settings UI window +- [x] Notification sounds (Windows system sounds) +- [x] Settings tests (6 new tests) + +**Phase 3: Distribution** 🔲 +- [ ] Installer/packaging (MSIX or MSI) +- [ ] Auto-update mechanism +- [ ] Code signing +- [ ] Release automation + +See [Windows-Companion-App-Plan.md](../../Windows-Companion-App-Plan.md) for the full roadmap. + +## Known Issues + +1. **No Gateway Available**: Without a running Gateway, the app shows "Disconnected" status. Need to install pnpm/bun to build and run the Gateway locally. + +2. **Manual Testing Required**: Some features (tray icon visibility, window opening) require manual verification. + +3. **WebView2 Control UI**: When Gateway is not connected, the WebView2 shows a "Waiting for Gateway connection..." overlay. + +## Gateway Connection + +The Windows companion connects to the Clawdbot Gateway via WebSocket: + +| Component | URL | +|-----------|-----| +| Gateway WebSocket | `ws://127.0.0.1:18789/` | +| Control UI | `http://127.0.0.1:18789/ui` | + +The Gateway must be running for the companion app to function. + +## License + +MIT - see [LICENSE](../../LICENSE) in the project root. diff --git a/apps/windows/installer/clawdbot.iss b/apps/windows/installer/clawdbot.iss new file mode 100644 index 000000000..ad8cac267 --- /dev/null +++ b/apps/windows/installer/clawdbot.iss @@ -0,0 +1,96 @@ +; Clawdbot Windows Companion - Inno Setup Script +; Download Inno Setup from: https://jrsoftware.org/isdl.php + +#define MyAppName "Clawdbot" +#define MyAppVersion "2025.1.25" +#define MyAppPublisher "Clawdbot" +#define MyAppURL "https://clawd.bot" +#define MyAppExeName "Clawdbot.exe" + +[Setup] +; NOTE: AppId uniquely identifies this application +AppId={{8E7B9A42-F3D8-4C91-B5E6-1A2B3C4D5E6F} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL}/support +AppUpdatesURL={#MyAppURL}/updates +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +AllowNoIcons=yes +; Output settings +OutputDir=..\dist +OutputBaseFilename=ClawdbotSetup-{#MyAppVersion} +; Compression +Compression=lzma2/ultra64 +SolidCompression=yes +; Require admin for Program Files install +PrivilegesRequired=admin +PrivilegesRequiredOverridesAllowed=dialog +; Visual settings +SetupIconFile=..\src\Clawdbot.Windows\Assets\clawdbot.ico +WizardStyle=modern +; Minimum Windows version (Windows 10 1903) +MinVersion=10.0.18362 + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "startupicon"; Description: "Start Clawdbot when Windows starts"; GroupDescription: "Startup:"; Flags: unchecked + +[Files] +; Main application files from publish folder +Source: "..\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +; Start menu +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" +; Desktop (optional) +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Registry] +; Auto-start on login (if selected) +Root: HKCU; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "Clawdbot"; ValueData: """{app}\{#MyAppExeName}"""; Flags: uninsdeletevalue; Tasks: startupicon + +[Run] +; Option to launch after install +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + +[UninstallRun] +; Stop the app before uninstall +Filename: "taskkill"; Parameters: "/F /IM {#MyAppExeName}"; Flags: runhidden; RunOnceId: "StopClawdbot" + +[UninstallDelete] +; Clean up app data (optional - ask user?) +; Type: filesandordirs; Name: "{localappdata}\Clawdbot" + +[Code] +// Check for .NET 9 runtime (framework-dependent builds only) +function IsDotNet9Installed: Boolean; +var + ResultCode: Integer; +begin + // Try to run dotnet --list-runtimes and check for 9.0 + Result := Exec('dotnet', '--list-runtimes', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0); +end; + +function InitializeSetup: Boolean; +begin + Result := True; + + // Uncomment for framework-dependent builds: + // if not IsDotNet9Installed then + // begin + // if MsgBox('Clawdbot requires .NET 9.0 Runtime.'#13#13'Would you like to download it now?', + // mbConfirmation, MB_YESNO) = IDYES then + // begin + // ShellExec('open', 'https://dotnet.microsoft.com/download/dotnet/9.0', '', '', SW_SHOW, ewNoWait, ResultCode); + // end; + // Result := False; + // end; +end; diff --git a/apps/windows/scripts/build.ps1 b/apps/windows/scripts/build.ps1 new file mode 100644 index 000000000..6b3718a53 --- /dev/null +++ b/apps/windows/scripts/build.ps1 @@ -0,0 +1,115 @@ +# Clawdbot Windows Companion - Build & Package Script +# Run from apps/windows directory + +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release", + + [switch]$SelfContained, + [switch]$SingleFile, + [switch]$CreateInstaller +) + +$ErrorActionPreference = "Stop" + +# Paths +$ProjectPath = "src\Clawdbot.Windows\Clawdbot.Windows.csproj" +$OutputDir = "publish" +$InstallerDir = "installer" + +Write-Host "=== Clawdbot Windows Companion Build ===" -ForegroundColor Cyan +Write-Host "Configuration: $Configuration" +Write-Host "Self-Contained: $SelfContained" +Write-Host "Single File: $SingleFile" +Write-Host "" + +# Clean previous builds +if (Test-Path $OutputDir) { + Write-Host "Cleaning previous publish output..." -ForegroundColor Yellow + Remove-Item -Recurse -Force $OutputDir +} + +# Build arguments +$PublishArgs = @( + "publish", + $ProjectPath, + "-c", $Configuration, + "-o", $OutputDir, + "-r", "win-x64" +) + +if ($SelfContained) { + $PublishArgs += "--self-contained", "true" + Write-Host "Building self-contained (includes .NET runtime)..." -ForegroundColor Green +} else { + $PublishArgs += "--self-contained", "false" + Write-Host "Building framework-dependent (requires .NET 9 runtime)..." -ForegroundColor Green +} + +if ($SingleFile) { + $PublishArgs += "-p:PublishSingleFile=true" + $PublishArgs += "-p:IncludeNativeLibrariesForSelfExtract=true" + Write-Host "Creating single-file executable..." -ForegroundColor Green +} + +# Run dotnet publish +Write-Host "" +Write-Host "Running: dotnet $($PublishArgs -join ' ')" -ForegroundColor Gray +& dotnet @PublishArgs + +if ($LASTEXITCODE -ne 0) { + Write-Host "Build failed!" -ForegroundColor Red + exit 1 +} + +# Get output info +$ExePath = Join-Path $OutputDir "Clawdbot.exe" +if (Test-Path $ExePath) { + $ExeSize = (Get-Item $ExePath).Length / 1MB + Write-Host "" + Write-Host "Build successful!" -ForegroundColor Green + Write-Host "Output: $ExePath" + Write-Host "Size: $([math]::Round($ExeSize, 2)) MB" + + # List all files + Write-Host "" + Write-Host "Published files:" -ForegroundColor Cyan + Get-ChildItem $OutputDir -Recurse | Where-Object { !$_.PSIsContainer } | ForEach-Object { + $RelPath = $_.FullName.Replace((Resolve-Path $OutputDir).Path + "\", "") + $Size = [math]::Round($_.Length / 1KB, 1) + Write-Host " $RelPath ($Size KB)" + } +} + +# Create installer if requested +if ($CreateInstaller) { + Write-Host "" + Write-Host "=== Creating Installer ===" -ForegroundColor Cyan + + # Check for Inno Setup + $InnoPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" + if (!(Test-Path $InnoPath)) { + Write-Host "Inno Setup not found at: $InnoPath" -ForegroundColor Yellow + Write-Host "Download from: https://jrsoftware.org/isdl.php" -ForegroundColor Yellow + Write-Host "Skipping installer creation." -ForegroundColor Yellow + } else { + # Create installer script if not exists + $IssPath = Join-Path $InstallerDir "clawdbot.iss" + if (!(Test-Path $IssPath)) { + Write-Host "Installer script not found: $IssPath" -ForegroundColor Yellow + Write-Host "Run: .\scripts\create-installer-script.ps1 first" -ForegroundColor Yellow + } else { + Write-Host "Building installer with Inno Setup..." + & $InnoPath $IssPath + if ($LASTEXITCODE -eq 0) { + Write-Host "Installer created successfully!" -ForegroundColor Green + } + } + } +} + +Write-Host "" +Write-Host "=== Done ===" -ForegroundColor Cyan +Write-Host "" +Write-Host "To run: .\$ExePath" +Write-Host "To test: dotnet test" diff --git a/apps/windows/src/Clawdbot.Windows.Core/AppLogger.cs b/apps/windows/src/Clawdbot.Windows.Core/AppLogger.cs new file mode 100644 index 000000000..46d9b5d5f --- /dev/null +++ b/apps/windows/src/Clawdbot.Windows.Core/AppLogger.cs @@ -0,0 +1,81 @@ +using System.Diagnostics; + +namespace Clawdbot.Windows.Core; + +/// +/// Simple file-based logger for debugging +/// +public static class AppLogger +{ + private static readonly string LogPath; + private static readonly object WriteLock = new(); + + static AppLogger() + { + // Log to user's AppData folder + var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var logDir = Path.Combine(appData, "Clawdbot", "logs"); + Directory.CreateDirectory(logDir); + + LogPath = Path.Combine(logDir, $"clawdbot-{DateTime.Now:yyyy-MM-dd}.log"); + + // Log startup + Info($"=== Clawdbot Windows Companion Started ==="); + Info($"Log path: {LogPath}"); + Info($".NET Version: {Environment.Version}"); + Info($"OS: {Environment.OSVersion}"); + Info($"Machine: {Environment.MachineName}"); + } + + public static void Info(string message) + { + Write("INFO", message); + } + + public static void Debug(string message) + { + Write("DEBUG", message); + } + + public static void Warn(string message) + { + Write("WARN", message); + } + + public static void Error(string message, Exception? ex = null) + { + Write("ERROR", message); + if (ex != null) + { + Write("ERROR", $" Exception: {ex.GetType().Name}: {ex.Message}"); + Write("ERROR", $" Stack: {ex.StackTrace}"); + if (ex.InnerException != null) + { + Write("ERROR", $" Inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + } + } + } + + private static void Write(string level, string message) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"); + var logLine = $"[{timestamp}] [{level,-5}] {message}"; + + lock (WriteLock) + { + try + { + File.AppendAllText(LogPath, logLine + Environment.NewLine); + } + catch + { + // Ignore logging failures + } + } + + // Also write to debug output + System.Diagnostics.Debug.WriteLine(logLine); + } + + public static string GetLogPath() => LogPath; +} diff --git a/apps/windows/src/Clawdbot.Windows.Core/AppSettings.cs b/apps/windows/src/Clawdbot.Windows.Core/AppSettings.cs new file mode 100644 index 000000000..0f9224234 --- /dev/null +++ b/apps/windows/src/Clawdbot.Windows.Core/AppSettings.cs @@ -0,0 +1,164 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Clawdbot.Windows.Core; + +/// +/// Application settings persisted to disk. +/// Stored at %LOCALAPPDATA%\Clawdbot\settings.json +/// +public class AppSettings +{ + private static readonly string SettingsDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Clawdbot"); + + private static readonly string SettingsPath = Path.Combine(SettingsDirectory, "settings.json"); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// + /// Gateway WebSocket URL (default: ws://127.0.0.1:18789/) + /// + [JsonPropertyName("gatewayUrl")] + public string GatewayUrl { get; set; } = "ws://127.0.0.1:18789/"; + + /// + /// Whether to start Clawdbot when Windows starts + /// + [JsonPropertyName("startOnLogin")] + public bool StartOnLogin { get; set; } = false; + + /// + /// Whether to minimize to tray instead of closing when X is clicked + /// + [JsonPropertyName("minimizeToTray")] + public bool MinimizeToTray { get; set; } = true; + + /// + /// Whether to play notification sounds + /// + [JsonPropertyName("playSounds")] + public bool PlaySounds { get; set; } = true; + + /// + /// Whether to show balloon notifications for connection status changes + /// + [JsonPropertyName("showConnectionNotifications")] + public bool ShowConnectionNotifications { get; set; } = true; + + /// + /// Auto-reconnect interval in seconds (0 = disabled) + /// + [JsonPropertyName("reconnectIntervalSeconds")] + public int ReconnectIntervalSeconds { get; set; } = 5; + + /// + /// Exec approval timeout in seconds (0 = use server default) + /// + [JsonPropertyName("execApprovalTimeoutSeconds")] + public int ExecApprovalTimeoutSeconds { get; set; } = 0; + + /// + /// Last window position (X) + /// + [JsonPropertyName("windowX")] + public double? WindowX { get; set; } + + /// + /// Last window position (Y) + /// + [JsonPropertyName("windowY")] + public double? WindowY { get; set; } + + /// + /// Last window width + /// + [JsonPropertyName("windowWidth")] + public double? WindowWidth { get; set; } + + /// + /// Last window height + /// + [JsonPropertyName("windowHeight")] + public double? WindowHeight { get; set; } + + /// + /// Loads settings from disk, or returns default settings if file doesn't exist. + /// + public static AppSettings Load() + { + try + { + if (File.Exists(SettingsPath)) + { + var json = File.ReadAllText(SettingsPath); + var settings = JsonSerializer.Deserialize(json, JsonOptions); + if (settings != null) + { + AppLogger.Info($"Loaded settings from {SettingsPath}"); + return settings; + } + } + } + catch (Exception ex) + { + AppLogger.Warn($"Failed to load settings: {ex.Message}"); + } + + AppLogger.Info("Using default settings"); + return new AppSettings(); + } + + /// + /// Saves settings to disk. + /// + public void Save() + { + try + { + // Ensure directory exists + Directory.CreateDirectory(SettingsDirectory); + + var json = JsonSerializer.Serialize(this, JsonOptions); + File.WriteAllText(SettingsPath, json); + + AppLogger.Info($"Saved settings to {SettingsPath}"); + } + catch (Exception ex) + { + AppLogger.Error($"Failed to save settings: {ex.Message}"); + } + } + + /// + /// Resets settings to defaults and saves. + /// + public void Reset() + { + GatewayUrl = "ws://127.0.0.1:18789/"; + StartOnLogin = false; + MinimizeToTray = true; + PlaySounds = true; + ShowConnectionNotifications = true; + ReconnectIntervalSeconds = 5; + ExecApprovalTimeoutSeconds = 0; + WindowX = null; + WindowY = null; + WindowWidth = null; + WindowHeight = null; + + Save(); + AppLogger.Info("Settings reset to defaults"); + } + + /// + /// Gets the path to the settings file. + /// + public static string GetSettingsFilePath() => SettingsPath; +} diff --git a/apps/windows/src/Clawdbot.Windows.Core/AutoStartHelper.cs b/apps/windows/src/Clawdbot.Windows.Core/AutoStartHelper.cs new file mode 100644 index 000000000..3320dfc79 --- /dev/null +++ b/apps/windows/src/Clawdbot.Windows.Core/AutoStartHelper.cs @@ -0,0 +1,130 @@ +using Microsoft.Win32; + +namespace Clawdbot.Windows.Core; + +/// +/// Manages Windows auto-start (Run at login) functionality via Registry. +/// +public static class AutoStartHelper +{ + private const string RegistryKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; + private const string AppName = "Clawdbot"; + + /// + /// Checks if auto-start is currently enabled. + /// + public static bool IsEnabled() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, false); + var value = key?.GetValue(AppName); + return value != null; + } + catch (Exception ex) + { + AppLogger.Warn($"Failed to check auto-start status: {ex.Message}"); + return false; + } + } + + /// + /// Enables auto-start by adding Registry entry. + /// + public static bool Enable() + { + try + { + var exePath = GetExecutablePath(); + if (string.IsNullOrEmpty(exePath) || !File.Exists(exePath)) + { + AppLogger.Error($"Cannot enable auto-start: executable not found at {exePath}"); + return false; + } + + using var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, true); + if (key == null) + { + AppLogger.Error("Failed to open Registry key for auto-start"); + return false; + } + + // Add quotes around path in case it contains spaces + key.SetValue(AppName, $"\"{exePath}\" --minimized"); + + AppLogger.Info($"Auto-start enabled: {exePath}"); + return true; + } + catch (Exception ex) + { + AppLogger.Error($"Failed to enable auto-start: {ex.Message}"); + return false; + } + } + + /// + /// Disables auto-start by removing Registry entry. + /// + public static bool Disable() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, true); + if (key == null) + { + AppLogger.Warn("Registry key not found for auto-start"); + return true; // Already disabled + } + + if (key.GetValue(AppName) != null) + { + key.DeleteValue(AppName); + AppLogger.Info("Auto-start disabled"); + } + + return true; + } + catch (Exception ex) + { + AppLogger.Error($"Failed to disable auto-start: {ex.Message}"); + return false; + } + } + + /// + /// Sets auto-start enabled/disabled based on the parameter. + /// + public static bool SetEnabled(bool enabled) + { + return enabled ? Enable() : Disable(); + } + + /// + /// Gets the path to the current executable. + /// + private static string GetExecutablePath() + { + // Get the entry assembly location + var entryAssembly = System.Reflection.Assembly.GetEntryAssembly(); + if (entryAssembly != null) + { + var location = entryAssembly.Location; + + // If running via dotnet, the location will be the DLL, not the EXE + // Try to find the corresponding .exe + if (location.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + var exePath = Path.ChangeExtension(location, ".exe"); + if (File.Exists(exePath)) + { + return exePath; + } + } + + return location; + } + + // Fallback: use process path + return Environment.ProcessPath ?? string.Empty; + } +} diff --git a/apps/windows/src/Clawdbot.Windows.Core/Clawdbot.Windows.Core.csproj b/apps/windows/src/Clawdbot.Windows.Core/Clawdbot.Windows.Core.csproj new file mode 100644 index 000000000..7ea09110f --- /dev/null +++ b/apps/windows/src/Clawdbot.Windows.Core/Clawdbot.Windows.Core.csproj @@ -0,0 +1,20 @@ + + + + net9.0-windows + enable + enable + Clawdbot.Windows.Core + Core Gateway client and services for Clawdbot Windows companion + + + + + + + + + + + + diff --git a/apps/windows/src/Clawdbot.Windows.Core/ExecApprovalModels.cs b/apps/windows/src/Clawdbot.Windows.Core/ExecApprovalModels.cs new file mode 100644 index 000000000..c9cee59e3 --- /dev/null +++ b/apps/windows/src/Clawdbot.Windows.Core/ExecApprovalModels.cs @@ -0,0 +1,82 @@ +namespace Clawdbot.Windows.Core; + +/// +/// Request for exec approval from the Gateway +/// +public class ExecApprovalPromptRequest +{ + public required string Command { get; init; } + public string? Cwd { get; init; } + public string? Host { get; init; } + public string? Security { get; init; } + public string? Ask { get; init; } + public string? AgentId { get; init; } + public string? ResolvedPath { get; init; } + public string? SessionKey { get; init; } +} + +/// +/// Gateway exec approval request wrapper +/// +public class GatewayExecApprovalRequest +{ + public required string Id { get; init; } + public required ExecApprovalPromptRequest Request { get; init; } + public long CreatedAtMs { get; init; } + public long ExpiresAtMs { get; init; } + + /// + /// Check if the request has expired + /// + public bool IsExpired => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() > ExpiresAtMs; + + /// + /// Time remaining until expiration + /// + public TimeSpan TimeRemaining + { + get + { + var remainingMs = ExpiresAtMs - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return remainingMs > 0 ? TimeSpan.FromMilliseconds(remainingMs) : TimeSpan.Zero; + } + } +} + +/// +/// User's decision on an exec approval request +/// +public enum ExecApprovalDecision +{ + /// + /// Allow this command to run once + /// + AllowOnce, + + /// + /// Allow this command and add to allowlist + /// + AllowAlways, + + /// + /// Deny the command + /// + Deny +} + +/// +/// Extension methods for ExecApprovalDecision +/// +public static class ExecApprovalDecisionExtensions +{ + /// + /// Convert decision to the wire format expected by Gateway + /// + public static string ToWireFormat(this ExecApprovalDecision decision) => decision switch + { + ExecApprovalDecision.AllowOnce => "allow-once", + ExecApprovalDecision.AllowAlways => "allow-always", + ExecApprovalDecision.Deny => "deny", + _ => "deny" + }; +} diff --git a/apps/windows/src/Clawdbot.Windows.Core/ExecApprovalService.cs b/apps/windows/src/Clawdbot.Windows.Core/ExecApprovalService.cs new file mode 100644 index 000000000..207dc527f --- /dev/null +++ b/apps/windows/src/Clawdbot.Windows.Core/ExecApprovalService.cs @@ -0,0 +1,212 @@ +using System.Text.Json; +using Clawdbot.Windows.Protocol; + +namespace Clawdbot.Windows.Core; + +/// +/// Handles exec approval requests from the Gateway. +/// Manages the queue of pending approvals and dispatches them to the UI thread. +/// +public sealed class ExecApprovalService : IDisposable +{ + private readonly GatewayChannel _gateway; + private readonly Queue _pendingRequests = new(); + private readonly object _lock = new(); + private bool _isShowingDialog; + private bool _disposed; + + /// + /// Raised when an exec approval request is received from the Gateway. + /// Handler should show the dialog and return the decision. + /// + public event Func? ApprovalRequested; + + /// + /// Raised when there are no more pending approvals. + /// + public event Action? QueueEmpty; + + /// + /// Gets the number of pending approval requests. + /// + public int PendingCount + { + get + { + lock (_lock) return _pendingRequests.Count; + } + } + + public ExecApprovalService(GatewayChannel gateway) + { + _gateway = gateway ?? throw new ArgumentNullException(nameof(gateway)); + _gateway.EventReceived += OnGatewayEvent; + } + + private void OnGatewayEvent(object? sender, GatewayEventArgs e) + { + if (e.EventName == "exec.approval.requested") + { + HandleApprovalRequested(e); + } + } + + private void HandleApprovalRequested(GatewayEventArgs evt) + { + try + { + // Parse the request from the event payload + var request = ParseApprovalRequest(evt.Payload); + if (request == null) + { + AppLogger.Warn("Failed to parse exec.approval.requested event payload"); + return; + } + + AppLogger.Info($"Received exec approval request: id={request.Id}, command={TruncateCommand(request.Request.Command)}"); + + // Add to queue and process + lock (_lock) + { + _pendingRequests.Enqueue(request); + } + + ProcessNextRequest(); + } + catch (Exception ex) + { + AppLogger.Error($"Error handling exec.approval.requested: {ex.Message}"); + } + } + + private static GatewayExecApprovalRequest? ParseApprovalRequest(JsonElement? payload) + { + if (payload == null || !payload.HasValue) return null; + + try + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + return payload.Value.Deserialize(options); + } + catch (Exception ex) + { + AppLogger.Error($"Failed to parse approval request: {ex.Message}"); + return null; + } + } + + private void ProcessNextRequest() + { + GatewayExecApprovalRequest? request = null; + + lock (_lock) + { + if (_isShowingDialog || _pendingRequests.Count == 0) + { + return; + } + + // Skip expired requests + while (_pendingRequests.Count > 0) + { + var candidate = _pendingRequests.Peek(); + if (candidate.IsExpired) + { + _pendingRequests.Dequeue(); + AppLogger.Info($"Skipping expired approval request: id={candidate.Id}"); + // Auto-deny expired requests + SendDecision(candidate.Id, ExecApprovalDecision.Deny); + } + else + { + request = _pendingRequests.Dequeue(); + break; + } + } + + if (request == null) + { + QueueEmpty?.Invoke(); + return; + } + + _isShowingDialog = true; + } + + // Show approval dialog on UI thread + ShowApprovalDialog(request); + } + + private void ShowApprovalDialog(GatewayExecApprovalRequest request) + { + try + { + // The caller is responsible for UI thread dispatch + var decision = ApprovalRequested?.Invoke(request) ?? ExecApprovalDecision.Deny; + HandleDecision(request.Id, decision); + } + catch (Exception ex) + { + AppLogger.Error($"Error showing approval dialog: {ex.Message}"); + HandleDecision(request.Id, ExecApprovalDecision.Deny); + } + } + + private void HandleDecision(string requestId, ExecApprovalDecision decision) + { + AppLogger.Info($"User decision for request {requestId}: {decision}"); + + // Send decision to Gateway + SendDecision(requestId, decision); + + lock (_lock) + { + _isShowingDialog = false; + } + + // Process next request in queue + ProcessNextRequest(); + } + + private void SendDecision(string requestId, ExecApprovalDecision decision) + { + try + { + var payload = new + { + id = requestId, + decision = decision.ToWireFormat() + }; + + _ = _gateway.SendAsync("exec.approval.resolve", payload); + AppLogger.Info($"Sent exec approval decision: id={requestId}, decision={decision.ToWireFormat()}"); + } + catch (Exception ex) + { + AppLogger.Error($"Failed to send approval decision: {ex.Message}"); + } + } + + private static string TruncateCommand(string command, int maxLength = 50) + { + if (string.IsNullOrEmpty(command)) return "(empty)"; + if (command.Length <= maxLength) return command; + return command[..(maxLength - 3)] + "..."; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _gateway.EventReceived -= OnGatewayEvent; + + lock (_lock) + { + _pendingRequests.Clear(); + } + } +} diff --git a/apps/windows/src/Clawdbot.Windows.Core/GatewayChannel.cs b/apps/windows/src/Clawdbot.Windows.Core/GatewayChannel.cs new file mode 100644 index 000000000..0cb6ab15b --- /dev/null +++ b/apps/windows/src/Clawdbot.Windows.Core/GatewayChannel.cs @@ -0,0 +1,472 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Clawdbot.Windows.Protocol; + +namespace Clawdbot.Windows.Core; + +/// +/// Gateway connection state +/// +public enum GatewayState +{ + Disconnected, + Connecting, + Connected, + Reconnecting +} + +/// +/// Exception thrown when Gateway request fails +/// +public class GatewayException : Exception +{ + public string Code { get; } + public object? ErrorData { get; } + + public GatewayException(string code, string message, object? data = null) + : base(message) + { + Code = code; + ErrorData = data; + } +} + +/// +/// Event args for Gateway events +/// +public class GatewayEventArgs : EventArgs +{ + public required string EventName { get; init; } + public JsonElement? Payload { get; init; } +} + +/// +/// Event args for state changes +/// +public class GatewayStateChangedEventArgs : EventArgs +{ + public GatewayState OldState { get; init; } + public GatewayState NewState { get; init; } +} + +/// +/// WebSocket client for Clawdbot Gateway. +/// Handles connection, reconnection, request/response correlation, and event dispatch. +/// +public class GatewayChannel : IAsyncDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = true + }; + + private readonly string _url; + private readonly ConnectParams _connectParams; + private ClientWebSocket? _webSocket; + private CancellationTokenSource? _receiveCts; + private Task? _receiveTask; + + private readonly ConcurrentDictionary> _pending = new(); + private int _requestId; + private GatewayState _state = GatewayState.Disconnected; + + private int _reconnectAttempts; + private const int MaxReconnectAttempts = 10; + private static readonly int[] BackoffMs = { 100, 200, 500, 1000, 2000, 5000, 10000, 15000, 30000, 60000 }; + + /// + /// Current connection state + /// + public GatewayState State => _state; + + /// + /// Snapshot received on connection + /// + public Snapshot? Snapshot { get; private set; } + + /// + /// Hello response from Gateway + /// + public HelloOk? HelloResponse { get; private set; } + + /// + /// Fired when an event is received from Gateway + /// + public event EventHandler? EventReceived; + + /// + /// Fired when connection state changes + /// + public event EventHandler? StateChanged; + + /// + /// Fired when connection is lost + /// + public event EventHandler? Disconnected; + + /// + /// Fired when reconnection succeeds + /// + public event EventHandler? Reconnected; + + /// + /// Create a new Gateway channel + /// + /// WebSocket URL, e.g. ws://127.0.0.1:18789/ + /// Optional connection parameters + public GatewayChannel(string url = "ws://127.0.0.1:18789/", ConnectParams? connectParams = null) + { + _url = url.TrimEnd('/') + "/"; + _connectParams = connectParams ?? new ConnectParams(); + } + + /// + /// Connect to Gateway + /// + public async Task ConnectAsync(CancellationToken cancellationToken = default) + { + if (_state == GatewayState.Connected) + return; + + SetState(GatewayState.Connecting); + + try + { + _webSocket = new ClientWebSocket(); + await _webSocket.ConnectAsync(new Uri(_url), cancellationToken); + + // Send hello + var helloJson = JsonSerializer.Serialize(_connectParams, JsonOptions); + var helloBytes = Encoding.UTF8.GetBytes(helloJson); + await _webSocket.SendAsync(helloBytes, WebSocketMessageType.Text, true, cancellationToken); + + // Receive hello.ok + var helloResponse = await ReceiveOneAsync(cancellationToken); + HelloResponse = JsonSerializer.Deserialize(helloResponse, JsonOptions) + ?? throw new GatewayException("PROTOCOL_ERROR", "Invalid hello response"); + + Snapshot = HelloResponse.Snapshot; + + // Start receive loop + _receiveCts = new CancellationTokenSource(); + _receiveTask = ReceiveLoopAsync(_receiveCts.Token); + + _reconnectAttempts = 0; + SetState(GatewayState.Connected); + } + catch (Exception) when (_state == GatewayState.Connecting) + { + SetState(GatewayState.Disconnected); + throw; + } + } + + /// + /// Send a request to Gateway and wait for response + /// + public async Task RequestAsync( + string method, + object? parameters = null, + CancellationToken cancellationToken = default) + { + var responseJson = await RequestRawAsync(method, parameters, cancellationToken); + return JsonSerializer.Deserialize(responseJson, JsonOptions) + ?? throw new GatewayException("PROTOCOL_ERROR", $"Failed to deserialize response for {method}"); + } + + /// + /// Send a request to Gateway and wait for raw JSON response + /// + public async Task RequestRawAsync( + string method, + object? parameters = null, + CancellationToken cancellationToken = default) + { + EnsureConnected(); + + var id = Interlocked.Increment(ref _requestId).ToString(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + if (!_pending.TryAdd(id, tcs)) + throw new InvalidOperationException($"Duplicate request ID: {id}"); + + try + { + var request = new RequestFrame + { + Id = id, + Method = method, + Params = parameters + }; + + var json = JsonSerializer.Serialize(request, JsonOptions); + var bytes = Encoding.UTF8.GetBytes(json); + + await _webSocket!.SendAsync(bytes, WebSocketMessageType.Text, true, cancellationToken); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(30)); + + using (timeoutCts.Token.Register(() => tcs.TrySetCanceled())) + { + return await tcs.Task; + } + } + finally + { + _pending.TryRemove(id, out _); + } + } + + /// + /// Send a request without waiting for response (fire and forget) + /// + public async Task SendAsync(string method, object? parameters = null, CancellationToken cancellationToken = default) + { + EnsureConnected(); + + var id = Interlocked.Increment(ref _requestId).ToString(); + var request = new RequestFrame + { + Id = id, + Method = method, + Params = parameters + }; + + var json = JsonSerializer.Serialize(request, JsonOptions); + var bytes = Encoding.UTF8.GetBytes(json); + + await _webSocket!.SendAsync(bytes, WebSocketMessageType.Text, true, cancellationToken); + } + + /// + /// Disconnect from Gateway + /// + public async Task DisconnectAsync() + { + _receiveCts?.Cancel(); + + if (_webSocket?.State == WebSocketState.Open) + { + try + { + await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client disconnect", CancellationToken.None); + } + catch + { + // Ignore close errors + } + } + + _webSocket?.Dispose(); + _webSocket = null; + SetState(GatewayState.Disconnected); + + // Cancel all pending requests + foreach (var (_, tcs) in _pending) + { + tcs.TrySetCanceled(); + } + _pending.Clear(); + } + + public async ValueTask DisposeAsync() + { + await DisconnectAsync(); + _receiveCts?.Dispose(); + GC.SuppressFinalize(this); + } + + private async Task ReceiveOneAsync(CancellationToken cancellationToken) + { + var buffer = new byte[8192]; + using var ms = new MemoryStream(); + + WebSocketReceiveResult result; + do + { + result = await _webSocket!.ReceiveAsync(buffer, cancellationToken); + ms.Write(buffer, 0, result.Count); + } while (!result.EndOfMessage); + + ms.Position = 0; + return await JsonSerializer.DeserializeAsync(ms, JsonOptions, cancellationToken); + } + + private async Task ReceiveLoopAsync(CancellationToken cancellationToken) + { + var buffer = new byte[8192]; + using var ms = new MemoryStream(); + + try + { + while (!cancellationToken.IsCancellationRequested && _webSocket?.State == WebSocketState.Open) + { + ms.SetLength(0); + + WebSocketReceiveResult result; + do + { + result = await _webSocket.ReceiveAsync(buffer, cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + { + await HandleDisconnectAsync(); + return; + } + ms.Write(buffer, 0, result.Count); + } while (!result.EndOfMessage); + + ms.Position = 0; + var json = await JsonSerializer.DeserializeAsync(ms, JsonOptions, cancellationToken); + ProcessMessage(json); + } + } + catch (OperationCanceledException) + { + // Normal cancellation + } + catch (WebSocketException) + { + await HandleDisconnectAsync(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Gateway receive error: {ex}"); + await HandleDisconnectAsync(); + } + } + + private void ProcessMessage(JsonElement json) + { + if (!json.TryGetProperty("type", out var typeElement)) + return; + + var type = typeElement.GetString(); + + switch (type) + { + case "response": + HandleResponse(json); + break; + case "event": + HandleEvent(json); + break; + } + } + + private void HandleResponse(JsonElement json) + { + if (!json.TryGetProperty("id", out var idElement)) + return; + + var id = idElement.GetString(); + if (id == null || !_pending.TryRemove(id, out var tcs)) + return; + + if (json.TryGetProperty("error", out var errorElement)) + { + var error = JsonSerializer.Deserialize(errorElement, JsonOptions); + tcs.TrySetException(new GatewayException( + error?.Code ?? "UNKNOWN", + error?.Message ?? "Unknown error", + error?.Data)); + } + else if (json.TryGetProperty("payload", out var payloadElement)) + { + tcs.TrySetResult(payloadElement); + } + else + { + // Empty success response + tcs.TrySetResult(default); + } + } + + private void HandleEvent(JsonElement json) + { + if (!json.TryGetProperty("event", out var eventElement)) + return; + + var eventName = eventElement.GetString(); + if (eventName == null) + return; + + json.TryGetProperty("payload", out var payload); + + // Update local snapshot for certain events + if (eventName == "snapshot" && payload.ValueKind == JsonValueKind.Object) + { + Snapshot = JsonSerializer.Deserialize(payload, JsonOptions); + } + + EventReceived?.Invoke(this, new GatewayEventArgs + { + EventName = eventName, + Payload = payload + }); + } + + private async Task HandleDisconnectAsync() + { + if (_state == GatewayState.Disconnected) + return; + + SetState(GatewayState.Disconnected); + Disconnected?.Invoke(this, EventArgs.Empty); + + // Attempt reconnection + await TryReconnectAsync(); + } + + private async Task TryReconnectAsync() + { + if (_reconnectAttempts >= MaxReconnectAttempts) + { + System.Diagnostics.Debug.WriteLine("Max reconnect attempts reached"); + return; + } + + SetState(GatewayState.Reconnecting); + + var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)]; + _reconnectAttempts++; + + await Task.Delay(delay); + + try + { + await ConnectAsync(); + Reconnected?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Reconnect attempt {_reconnectAttempts} failed: {ex.Message}"); + await TryReconnectAsync(); + } + } + + private void SetState(GatewayState newState) + { + var oldState = _state; + if (oldState == newState) + return; + + _state = newState; + StateChanged?.Invoke(this, new GatewayStateChangedEventArgs + { + OldState = oldState, + NewState = newState + }); + } + + private void EnsureConnected() + { + if (_state != GatewayState.Connected || _webSocket?.State != WebSocketState.Open) + throw new InvalidOperationException("Not connected to Gateway"); + } +} diff --git a/apps/windows/src/Clawdbot.Windows.Core/NotificationSounds.cs b/apps/windows/src/Clawdbot.Windows.Core/NotificationSounds.cs new file mode 100644 index 000000000..d444462fe --- /dev/null +++ b/apps/windows/src/Clawdbot.Windows.Core/NotificationSounds.cs @@ -0,0 +1,77 @@ +using System.Media; + +namespace Clawdbot.Windows.Core; + +/// +/// Plays Windows notification sounds for various events. +/// +public static class NotificationSounds +{ + /// + /// Play when an exec approval request arrives. + /// + public static void PlayApprovalRequest() + { + PlaySystemSound(SystemSounds.Exclamation); + } + + /// + /// Play when approval is granted. + /// + public static void PlayApprovalGranted() + { + PlaySystemSound(SystemSounds.Asterisk); + } + + /// + /// Play when approval is denied. + /// + public static void PlayApprovalDenied() + { + PlaySystemSound(SystemSounds.Hand); + } + + /// + /// Play when Gateway connects successfully. + /// + public static void PlayConnected() + { + PlaySystemSound(SystemSounds.Asterisk); + } + + /// + /// Play when Gateway disconnects. + /// + public static void PlayDisconnected() + { + PlaySystemSound(SystemSounds.Exclamation); + } + + /// + /// Play a generic notification sound. + /// + public static void PlayNotification() + { + PlaySystemSound(SystemSounds.Asterisk); + } + + /// + /// Play an error sound. + /// + public static void PlayError() + { + PlaySystemSound(SystemSounds.Hand); + } + + private static void PlaySystemSound(SystemSound sound) + { + try + { + sound.Play(); + } + catch (Exception ex) + { + AppLogger.Debug($"Failed to play system sound: {ex.Message}"); + } + } +} diff --git a/apps/windows/src/Clawdbot.Windows.Core/WebView2Helper.cs b/apps/windows/src/Clawdbot.Windows.Core/WebView2Helper.cs new file mode 100644 index 000000000..19fc6bdae --- /dev/null +++ b/apps/windows/src/Clawdbot.Windows.Core/WebView2Helper.cs @@ -0,0 +1,85 @@ +using Microsoft.Win32; + +namespace Clawdbot.Windows.Core; + +/// +/// Helper for WebView2 runtime detection +/// +public static class WebView2Helper +{ + /// + /// Checks if WebView2 Runtime is installed + /// + public static bool IsWebView2RuntimeInstalled() + { + // Check for WebView2 Runtime in registry (both 32-bit and 64-bit) + var version = GetWebView2Version(); + return version != null; + } + + /// + /// Gets the installed WebView2 Runtime version + /// + public static string? GetWebView2Version() + { + // Try 64-bit location + var version = GetRegistryValue( + @"SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}", + "pv", + RegistryView.Registry64); + + if (version != null) + return version; + + // Try 32-bit location + version = GetRegistryValue( + @"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}", + "pv", + RegistryView.Registry64); + + if (version != null) + return version; + + // Try default view + version = GetRegistryValue( + @"SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}", + "pv", + RegistryView.Default); + + return version; + } + + private static string? GetRegistryValue(string subKey, string valueName, RegistryView view) + { + try + { + using var key = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view); + using var subKeyHandle = key.OpenSubKey(subKey); + return subKeyHandle?.GetValue(valueName)?.ToString(); + } + catch + { + return null; + } + } + + /// + /// Gets the download URL for WebView2 Runtime + /// + public static string GetDownloadUrl() + { + return "https://go.microsoft.com/fwlink/p/?LinkId=2124703"; + } + + /// + /// Opens the WebView2 download page in the default browser + /// + public static void OpenDownloadPage() + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = GetDownloadUrl(), + UseShellExecute = true + }); + } +} diff --git a/apps/windows/src/Clawdbot.Windows.Protocol/Clawdbot.Windows.Protocol.csproj b/apps/windows/src/Clawdbot.Windows.Protocol/Clawdbot.Windows.Protocol.csproj new file mode 100644 index 000000000..be7d5f8f0 --- /dev/null +++ b/apps/windows/src/Clawdbot.Windows.Protocol/Clawdbot.Windows.Protocol.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + Clawdbot.Windows.Protocol + Auto-generated Gateway protocol models for Clawdbot Windows companion + + + + + + + diff --git a/apps/windows/src/Clawdbot.Windows.Protocol/GatewayModels.cs b/apps/windows/src/Clawdbot.Windows.Protocol/GatewayModels.cs new file mode 100644 index 000000000..891490ed6 --- /dev/null +++ b/apps/windows/src/Clawdbot.Windows.Protocol/GatewayModels.cs @@ -0,0 +1,769 @@ +// +// This file contains protocol models for the Clawdbot Gateway. +// Based on: src/gateway/protocol/schema.ts +// Protocol Version: 3 +// + +#nullable enable +using System.Text.Json.Serialization; + +namespace Clawdbot.Windows.Protocol; + +/// +/// Gateway protocol version +/// +public static class GatewayProtocol +{ + public const int Version = 3; + public const int MinVersion = 3; + public const int MaxVersion = 3; +} + +/// +/// Gateway error codes +/// +public static class ErrorCodes +{ + public const string NotLinked = "NOT_LINKED"; + public const string NotPaired = "NOT_PAIRED"; + public const string AgentTimeout = "AGENT_TIMEOUT"; + public const string InvalidRequest = "INVALID_REQUEST"; + public const string Unavailable = "UNAVAILABLE"; + public const string ConfigHashMismatch = "CONFIG_HASH_MISMATCH"; +} + +#region Frame Types + +/// +/// Base class for all Gateway frames +/// +public abstract record GatewayFrame +{ + [JsonPropertyName("type")] + public abstract string Type { get; } +} + +/// +/// Request frame sent to Gateway +/// +public record RequestFrame : GatewayFrame +{ + [JsonPropertyName("type")] + public override string Type => "request"; + + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("method")] + public required string Method { get; init; } + + [JsonPropertyName("params")] + public object? Params { get; init; } +} + +/// +/// Response frame received from Gateway +/// +public record ResponseFrame : GatewayFrame +{ + [JsonPropertyName("type")] + public override string Type => "response"; + + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("payload")] + public object? Payload { get; init; } + + [JsonPropertyName("error")] + public GatewayError? Error { get; init; } +} + +/// +/// Event frame pushed by Gateway +/// +public record EventFrame : GatewayFrame +{ + [JsonPropertyName("type")] + public override string Type => "event"; + + [JsonPropertyName("event")] + public required string Event { get; init; } + + [JsonPropertyName("payload")] + public object? Payload { get; init; } +} + +/// +/// Gateway error response +/// +public record GatewayError +{ + [JsonPropertyName("code")] + public required string Code { get; init; } + + [JsonPropertyName("message")] + public required string Message { get; init; } + + [JsonPropertyName("data")] + public object? Data { get; init; } +} + +#endregion + +#region Connection Types + +/// +/// Parameters for connecting to Gateway +/// +public record ConnectParams +{ + [JsonPropertyName("minProtocol")] + public int MinProtocol { get; init; } = GatewayProtocol.MinVersion; + + [JsonPropertyName("maxProtocol")] + public int MaxProtocol { get; init; } = GatewayProtocol.MaxVersion; + + [JsonPropertyName("client")] + public Dictionary Client { get; init; } = new() + { + ["name"] = "Clawdbot Windows", + ["version"] = "0.1.0", + ["platform"] = "windows" + }; + + [JsonPropertyName("caps")] + public List? Caps { get; init; } + + [JsonPropertyName("commands")] + public List? Commands { get; init; } + + [JsonPropertyName("role")] + public string? Role { get; init; } + + [JsonPropertyName("scopes")] + public List? Scopes { get; init; } +} + +/// +/// Successful hello response from Gateway +/// +public record HelloOk +{ + [JsonPropertyName("type")] + public string Type { get; init; } = "hello.ok"; + + [JsonPropertyName("protocol")] + public int Protocol { get; init; } + + [JsonPropertyName("server")] + public Dictionary? Server { get; init; } + + [JsonPropertyName("features")] + public Dictionary? Features { get; init; } + + [JsonPropertyName("snapshot")] + public Snapshot? Snapshot { get; init; } + + [JsonPropertyName("canvasHostUrl")] + public string? CanvasHostUrl { get; init; } + + [JsonPropertyName("policy")] + public Dictionary? Policy { get; init; } +} + +#endregion + +#region Snapshot Types + +/// +/// Full state snapshot from Gateway +/// +public record Snapshot +{ + [JsonPropertyName("presence")] + public List Presence { get; init; } = new(); + + [JsonPropertyName("health")] + public object? Health { get; init; } + + [JsonPropertyName("stateVersion")] + public StateVersion? StateVersion { get; init; } + + [JsonPropertyName("uptimeMs")] + public long UptimeMs { get; init; } + + [JsonPropertyName("configPath")] + public string? ConfigPath { get; init; } + + [JsonPropertyName("stateDir")] + public string? StateDir { get; init; } + + [JsonPropertyName("sessionDefaults")] + public SessionDefaults? SessionDefaults { get; init; } +} + +/// +/// Presence entry for connected device +/// +public record PresenceEntry +{ + [JsonPropertyName("host")] + public string? Host { get; init; } + + [JsonPropertyName("ip")] + public string? Ip { get; init; } + + [JsonPropertyName("version")] + public string? Version { get; init; } + + [JsonPropertyName("platform")] + public string? Platform { get; init; } + + [JsonPropertyName("deviceFamily")] + public string? DeviceFamily { get; init; } + + [JsonPropertyName("ts")] + public long Ts { get; init; } + + [JsonPropertyName("deviceId")] + public string? DeviceId { get; init; } + + [JsonPropertyName("roles")] + public List? Roles { get; init; } +} + +/// +/// State version for tracking changes +/// +public record StateVersion +{ + [JsonPropertyName("presence")] + public int Presence { get; init; } + + [JsonPropertyName("health")] + public int Health { get; init; } +} + +/// +/// Session defaults +/// +public record SessionDefaults +{ + [JsonPropertyName("defaultAgentId")] + public required string DefaultAgentId { get; init; } + + [JsonPropertyName("mainKey")] + public required string MainKey { get; init; } + + [JsonPropertyName("mainSessionKey")] + public required string MainSessionKey { get; init; } + + [JsonPropertyName("scope")] + public string? Scope { get; init; } +} + +#endregion + +#region Config Types + +/// +/// Response from config.get +/// +public record ConfigGetResponse +{ + [JsonPropertyName("raw")] + public required string Raw { get; init; } + + [JsonPropertyName("hash")] + public required string Hash { get; init; } + + [JsonPropertyName("path")] + public required string Path { get; init; } +} + +/// +/// Parameters for config.apply +/// +public record ConfigApplyParams +{ + [JsonPropertyName("raw")] + public required string Raw { get; init; } + + [JsonPropertyName("baseHash")] + public string? BaseHash { get; init; } + + [JsonPropertyName("sessionKey")] + public string? SessionKey { get; init; } + + [JsonPropertyName("restartDelayMs")] + public int? RestartDelayMs { get; init; } +} + +/// +/// Parameters for config.patch +/// +public record ConfigPatchParams +{ + [JsonPropertyName("raw")] + public required string Raw { get; init; } + + [JsonPropertyName("baseHash")] + public string? BaseHash { get; init; } + + [JsonPropertyName("sessionKey")] + public string? SessionKey { get; init; } + + [JsonPropertyName("restartDelayMs")] + public int? RestartDelayMs { get; init; } +} + +/// +/// Response from config.schema +/// +public record ConfigSchemaResponse +{ + [JsonPropertyName("schema")] + public required object Schema { get; init; } + + [JsonPropertyName("uiHints")] + public Dictionary? UiHints { get; init; } + + [JsonPropertyName("version")] + public required string Version { get; init; } + + [JsonPropertyName("generatedAt")] + public required string GeneratedAt { get; init; } +} + +/// +/// UI hints for config schema +/// +public record ConfigUiHint +{ + [JsonPropertyName("label")] + public string? Label { get; init; } + + [JsonPropertyName("help")] + public string? Help { get; init; } + + [JsonPropertyName("group")] + public string? Group { get; init; } + + [JsonPropertyName("order")] + public int? Order { get; init; } + + [JsonPropertyName("advanced")] + public bool? Advanced { get; init; } + + [JsonPropertyName("sensitive")] + public bool? Sensitive { get; init; } + + [JsonPropertyName("placeholder")] + public string? Placeholder { get; init; } +} + +#endregion + +#region Channel Types + +/// +/// Parameters for channels.status +/// +public record ChannelsStatusParams +{ + [JsonPropertyName("probe")] + public bool? Probe { get; init; } + + [JsonPropertyName("timeoutMs")] + public int? TimeoutMs { get; init; } +} + +/// +/// Response from channels.status +/// +public record ChannelsStatusResponse +{ + [JsonPropertyName("ts")] + public long Ts { get; init; } + + [JsonPropertyName("channelOrder")] + public List ChannelOrder { get; init; } = new(); + + [JsonPropertyName("channelLabels")] + public Dictionary ChannelLabels { get; init; } = new(); + + [JsonPropertyName("channelDetailLabels")] + public Dictionary? ChannelDetailLabels { get; init; } + + [JsonPropertyName("channelAccounts")] + public Dictionary> ChannelAccounts { get; init; } = new(); + + [JsonPropertyName("channelDefaultAccountId")] + public Dictionary? ChannelDefaultAccountId { get; init; } +} + +/// +/// Snapshot for a channel account +/// +public record ChannelAccountSnapshot +{ + [JsonPropertyName("accountId")] + public required string AccountId { get; init; } + + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("enabled")] + public bool? Enabled { get; init; } + + [JsonPropertyName("configured")] + public bool? Configured { get; init; } + + [JsonPropertyName("linked")] + public bool? Linked { get; init; } + + [JsonPropertyName("running")] + public bool? Running { get; init; } + + [JsonPropertyName("connected")] + public bool? Connected { get; init; } + + [JsonPropertyName("lastConnectedAt")] + public long? LastConnectedAt { get; init; } + + [JsonPropertyName("lastError")] + public string? LastError { get; init; } + + [JsonPropertyName("lastInboundAt")] + public long? LastInboundAt { get; init; } + + [JsonPropertyName("lastOutboundAt")] + public long? LastOutboundAt { get; init; } +} + +/// +/// Parameters for channels.logout +/// +public record ChannelsLogoutParams +{ + [JsonPropertyName("channel")] + public required string Channel { get; init; } + + [JsonPropertyName("accountId")] + public string? AccountId { get; init; } +} + +#endregion + +#region Session Types + +/// +/// Parameters for sessions.list +/// +public record SessionsListParams +{ + [JsonPropertyName("limit")] + public int? Limit { get; init; } + + [JsonPropertyName("activeMinutes")] + public int? ActiveMinutes { get; init; } + + [JsonPropertyName("includeGlobal")] + public bool? IncludeGlobal { get; init; } + + [JsonPropertyName("includeDerivedTitles")] + public bool? IncludeDerivedTitles { get; init; } + + [JsonPropertyName("includeLastMessage")] + public bool? IncludeLastMessage { get; init; } + + [JsonPropertyName("agentId")] + public string? AgentId { get; init; } + + [JsonPropertyName("search")] + public string? Search { get; init; } +} + +/// +/// Session info +/// +public record SessionInfo +{ + [JsonPropertyName("key")] + public required string Key { get; init; } + + [JsonPropertyName("agentId")] + public string? AgentId { get; init; } + + [JsonPropertyName("label")] + public string? Label { get; init; } + + [JsonPropertyName("derivedTitle")] + public string? DerivedTitle { get; init; } + + [JsonPropertyName("lastMessage")] + public string? LastMessage { get; init; } + + [JsonPropertyName("lastActivityAt")] + public long? LastActivityAt { get; init; } + + [JsonPropertyName("createdAt")] + public long? CreatedAt { get; init; } + + [JsonPropertyName("model")] + public string? Model { get; init; } + + [JsonPropertyName("thinkingLevel")] + public string? ThinkingLevel { get; init; } +} + +/// +/// Parameters for sessions.patch +/// +public record SessionsPatchParams +{ + [JsonPropertyName("key")] + public required string Key { get; init; } + + [JsonPropertyName("label")] + public string? Label { get; init; } + + [JsonPropertyName("thinkingLevel")] + public string? ThinkingLevel { get; init; } + + [JsonPropertyName("model")] + public string? Model { get; init; } + + [JsonPropertyName("sendPolicy")] + public string? SendPolicy { get; init; } +} + +/// +/// Parameters for sessions.delete +/// +public record SessionsDeleteParams +{ + [JsonPropertyName("key")] + public required string Key { get; init; } + + [JsonPropertyName("deleteTranscript")] + public bool? DeleteTranscript { get; init; } +} + +#endregion + +#region Exec Approval Types + +/// +/// Response from execApprovals.get +/// +public record ExecApprovalsGetResponse +{ + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("exists")] + public bool Exists { get; init; } + + [JsonPropertyName("hash")] + public required string Hash { get; init; } + + [JsonPropertyName("file")] + public required ExecApprovalsFile File { get; init; } +} + +/// +/// Exec approvals file structure +/// +public record ExecApprovalsFile +{ + [JsonPropertyName("version")] + public int Version { get; init; } = 1; + + [JsonPropertyName("socket")] + public ExecApprovalsSocket? Socket { get; init; } + + [JsonPropertyName("defaults")] + public ExecApprovalsDefaults? Defaults { get; init; } + + [JsonPropertyName("agents")] + public Dictionary? Agents { get; init; } +} + +/// +/// Socket config for exec approvals +/// +public record ExecApprovalsSocket +{ + [JsonPropertyName("path")] + public string? Path { get; init; } + + [JsonPropertyName("token")] + public string? Token { get; init; } +} + +/// +/// Default exec approval settings +/// +public record ExecApprovalsDefaults +{ + [JsonPropertyName("security")] + public string? Security { get; init; } + + [JsonPropertyName("ask")] + public string? Ask { get; init; } + + [JsonPropertyName("askFallback")] + public string? AskFallback { get; init; } + + [JsonPropertyName("autoAllowSkills")] + public bool? AutoAllowSkills { get; init; } +} + +/// +/// Per-agent exec approval settings +/// +public record ExecApprovalsAgent +{ + [JsonPropertyName("security")] + public string? Security { get; init; } + + [JsonPropertyName("ask")] + public string? Ask { get; init; } + + [JsonPropertyName("askFallback")] + public string? AskFallback { get; init; } + + [JsonPropertyName("autoAllowSkills")] + public bool? AutoAllowSkills { get; init; } + + [JsonPropertyName("allowlist")] + public List? Allowlist { get; init; } +} + +/// +/// Allowlist entry for exec approvals +/// +public record ExecApprovalsAllowlistEntry +{ + [JsonPropertyName("id")] + public string? Id { get; init; } + + [JsonPropertyName("pattern")] + public required string Pattern { get; init; } + + [JsonPropertyName("lastUsedAt")] + public long? LastUsedAt { get; init; } + + [JsonPropertyName("lastUsedCommand")] + public string? LastUsedCommand { get; init; } + + [JsonPropertyName("lastResolvedPath")] + public string? LastResolvedPath { get; init; } +} + +/// +/// Exec approval request event +/// +public record ExecApprovalRequest +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("command")] + public required string Command { get; init; } + + [JsonPropertyName("cwd")] + public string? Cwd { get; init; } + + [JsonPropertyName("host")] + public string? Host { get; init; } + + [JsonPropertyName("security")] + public string? Security { get; init; } + + [JsonPropertyName("ask")] + public string? Ask { get; init; } + + [JsonPropertyName("agentId")] + public string? AgentId { get; init; } + + [JsonPropertyName("resolvedPath")] + public string? ResolvedPath { get; init; } + + [JsonPropertyName("sessionKey")] + public string? SessionKey { get; init; } + + [JsonPropertyName("timeoutMs")] + public int? TimeoutMs { get; init; } +} + +/// +/// Parameters for execApproval.resolve +/// +public record ExecApprovalResolveParams +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("decision")] + public required string Decision { get; init; } +} + +#endregion + +#region Cron Types + +/// +/// Cron job +/// +public record CronJob +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("schedule")] + public required string Schedule { get; init; } + + [JsonPropertyName("message")] + public required string Message { get; init; } + + [JsonPropertyName("sessionKey")] + public string? SessionKey { get; init; } + + [JsonPropertyName("enabled")] + public bool Enabled { get; init; } = true; + + [JsonPropertyName("lastRunAt")] + public long? LastRunAt { get; init; } + + [JsonPropertyName("nextRunAt")] + public long? NextRunAt { get; init; } +} + +/// +/// Parameters for cron.add +/// +public record CronAddParams +{ + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("schedule")] + public required string Schedule { get; init; } + + [JsonPropertyName("message")] + public required string Message { get; init; } + + [JsonPropertyName("sessionKey")] + public string? SessionKey { get; init; } + + [JsonPropertyName("enabled")] + public bool Enabled { get; init; } = true; +} + +#endregion diff --git a/apps/windows/src/Clawdbot.Windows/App.xaml b/apps/windows/src/Clawdbot.Windows/App.xaml new file mode 100644 index 000000000..89846dd69 --- /dev/null +++ b/apps/windows/src/Clawdbot.Windows/App.xaml @@ -0,0 +1,25 @@ + + + + #E53935 + #B71C1C + #1E1E1E + #2D2D2D + #FFFFFF + #B0B0B0 + #4CAF50 + #FFC107 + + + + + + + + + + + diff --git a/apps/windows/src/Clawdbot.Windows/App.xaml.cs b/apps/windows/src/Clawdbot.Windows/App.xaml.cs new file mode 100644 index 000000000..ae659fd1b --- /dev/null +++ b/apps/windows/src/Clawdbot.Windows/App.xaml.cs @@ -0,0 +1,220 @@ +using System.Windows; +using Clawdbot.Windows.Core; + +namespace Clawdbot.Windows; + +/// +/// Main application entry point +/// +public partial class App : Application +{ + private AppSettings? _settings; + private GatewayChannel? _gateway; + private SystemTrayIcon? _trayIcon; + private ExecApprovalService? _execApprovalService; + + /// + /// Gets the current application settings. + /// + public AppSettings Settings => _settings ?? new AppSettings(); + + protected override async void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + AppLogger.Info("Application startup initiated"); + + // Load settings + _settings = AppSettings.Load(); + AppLogger.Info($"Settings loaded. Gateway URL: {_settings.GatewayUrl}"); + + // Set up global exception handlers + DispatcherUnhandledException += (_, args) => + { + AppLogger.Error("Unhandled UI exception", args.Exception); + args.Handled = true; + }; + AppDomain.CurrentDomain.UnhandledException += (_, args) => + { + AppLogger.Error("Unhandled domain exception", args.ExceptionObject as Exception); + }; + TaskScheduler.UnobservedTaskException += (_, args) => + { + AppLogger.Error("Unobserved task exception", args.Exception); + args.SetObserved(); + }; + + try + { + // Initialize Gateway connection with URL from settings + AppLogger.Info($"Creating GatewayChannel for {_settings.GatewayUrl}"); + _gateway = new GatewayChannel(_settings.GatewayUrl); + + // Initialize system tray + AppLogger.Info("Creating SystemTrayIcon"); + _trayIcon = new SystemTrayIcon(_gateway, _settings); + _trayIcon.ExitRequested += (_, _) => + { + AppLogger.Info("Exit requested from tray icon"); + Shutdown(); + }; + _trayIcon.ShowWindowRequested += (_, _) => + { + AppLogger.Info("Show window requested from tray icon"); + ShowMainWindow(); + }; + _trayIcon.SettingsRequested += (_, _) => + { + AppLogger.Info("Settings requested from tray icon"); + ShowSettingsWindow(); + }; + _trayIcon.ShowWindowRequested += (_, _) => + { + AppLogger.Info("Show window requested from tray icon"); + ShowMainWindow(); + }; + + AppLogger.Info("System tray icon created successfully"); + + // Try to connect to Gateway + AppLogger.Info("Attempting to connect to Gateway"); + try + { + await _gateway.ConnectAsync(); + AppLogger.Info("Gateway connected successfully"); + _trayIcon.UpdateStatus(GatewayState.Connected); + } + catch (Exception ex) + { + AppLogger.Warn($"Gateway connection failed: {ex.Message}"); + _trayIcon.UpdateStatus(GatewayState.Disconnected, ex.Message); + } + + // Subscribe to Gateway events + _gateway.StateChanged += (_, args) => + { + AppLogger.Debug($"Gateway state changed: {args.OldState} -> {args.NewState}"); + Dispatcher.Invoke(() => + { + _trayIcon?.UpdateStatus(args.NewState); + + // Play sound notification + if (_settings?.PlaySounds == true) + { + if (args.NewState == GatewayState.Connected) + NotificationSounds.PlayConnected(); + else if (args.NewState == GatewayState.Disconnected && args.OldState == GatewayState.Connected) + NotificationSounds.PlayDisconnected(); + } + }); + }; + + _gateway.EventReceived += OnGatewayEvent; + + // Initialize exec approval service + AppLogger.Info("Setting up ExecApprovalService"); + _execApprovalService = new ExecApprovalService(_gateway); + _execApprovalService.ApprovalRequested += OnExecApprovalRequested; + + AppLogger.Info("Application startup completed"); + } + catch (Exception ex) + { + AppLogger.Error("Fatal error during startup", ex); + MessageBox.Show($"Failed to start Clawdbot: {ex.Message}", "Clawdbot Error", + MessageBoxButton.OK, MessageBoxImage.Error); + Shutdown(-1); + } + } + + protected override async void OnExit(ExitEventArgs e) + { + AppLogger.Info($"Application exiting with code {e.ApplicationExitCode}"); + + _execApprovalService?.Dispose(); + _trayIcon?.Dispose(); + + if (_gateway != null) + await _gateway.DisposeAsync(); + + AppLogger.Info("Application exit complete"); + base.OnExit(e); + } + + private void OnGatewayEvent(object? sender, GatewayEventArgs e) + { + // Gateway events are now handled by ExecApprovalService + AppLogger.Debug($"Gateway event received: {e.EventName}"); + } + + private ExecApprovalDecision OnExecApprovalRequested(GatewayExecApprovalRequest request) + { + AppLogger.Info($"Showing exec approval dialog for request: {request.Id}"); + + // Play approval request sound + if (_settings?.PlaySounds == true) + NotificationSounds.PlayApprovalRequest(); + + var decision = ExecApprovalDialog.ShowApproval(request); + + // Play result sound + if (_settings?.PlaySounds == true) + { + if (decision == ExecApprovalDecision.Deny) + NotificationSounds.PlayApprovalDenied(); + else + NotificationSounds.PlayApprovalGranted(); + } + + return decision; + } + + private void ShowMainWindow() + { + if (MainWindow == null) + { + MainWindow = new MainWindow(_gateway!); + } + + MainWindow.Show(); + MainWindow.Activate(); + } + + private void ShowSettingsWindow() + { + var settingsWindow = new SettingsWindow(_settings!); + settingsWindow.Owner = MainWindow; + var result = settingsWindow.ShowDialog(); + + // If Gateway URL changed and user wants to reconnect + if (result == true && _gateway != null) + { + _ = ReconnectGatewayAsync(); + } + } + + private async Task ReconnectGatewayAsync() + { + try + { + AppLogger.Info("Reconnecting to Gateway with new URL..."); + await _gateway!.DisconnectAsync(); + + // Create new gateway with updated URL + var oldGateway = _gateway; + _gateway = new GatewayChannel(_settings!.GatewayUrl); + + // Dispose old gateway + await oldGateway.DisposeAsync(); + + // Reconnect + await _gateway.ConnectAsync(); + AppLogger.Info("Reconnected to Gateway successfully"); + } + catch (Exception ex) + { + AppLogger.Error($"Failed to reconnect: {ex.Message}"); + _trayIcon?.UpdateStatus(GatewayState.Disconnected, ex.Message); + } + } +} diff --git a/apps/windows/src/Clawdbot.Windows/Assets/clawdbot.ico b/apps/windows/src/Clawdbot.Windows/Assets/clawdbot.ico new file mode 100644 index 000000000..ec5665f56 Binary files /dev/null and b/apps/windows/src/Clawdbot.Windows/Assets/clawdbot.ico differ diff --git a/apps/windows/src/Clawdbot.Windows/Clawdbot.Windows.csproj b/apps/windows/src/Clawdbot.Windows/Clawdbot.Windows.csproj new file mode 100644 index 000000000..eacaa162d --- /dev/null +++ b/apps/windows/src/Clawdbot.Windows/Clawdbot.Windows.csproj @@ -0,0 +1,31 @@ + + + + WinExe + net9.0-windows + enable + enable + true + Assets\clawdbot.ico + Clawdbot.Windows + Clawdbot + Clawdbot Windows Companion - System tray app for Clawdbot Gateway + + + + + PreserveNewest + + + + + + + + + + + + + + diff --git a/apps/windows/src/Clawdbot.Windows/ExecApprovalDialog.xaml b/apps/windows/src/Clawdbot.Windows/ExecApprovalDialog.xaml new file mode 100644 index 000000000..63235fe7d --- /dev/null +++ b/apps/windows/src/Clawdbot.Windows/ExecApprovalDialog.xaml @@ -0,0 +1,212 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +