From 7f336217211ec29ee9aed1e0ac6657e448084a0b Mon Sep 17 00:00:00 2001 From: TAPUZE Date: Tue, 27 Jan 2026 10:58:24 +0200 Subject: [PATCH] feat(windows): Add Windows companion app (Phase 0-2) - System tray icon with connection status - Exec approval dialogs with queue and timeout - Settings persistence to JSON (%LOCALAPPDATA%\Clawdbot) - Auto-start on login via Windows Registry - Notification sounds for approvals and connection events - WebView2 Control UI embedding - 23 unit tests passing - Build scripts and Inno Setup installer template - Community build guide documentation Phase 0: Protocol models, WebSocket client, logging Phase 1: Tray icon, exec approval dialogs, Control UI Phase 2: Settings, auto-start, sounds, settings UI --- apps/windows/.gitignore | 14 + apps/windows/ClawdbotWindows.sln | 86 ++ apps/windows/README.md | 226 +++++ apps/windows/installer/clawdbot.iss | 96 +++ apps/windows/scripts/build.ps1 | 115 +++ .../src/Clawdbot.Windows.Core/AppLogger.cs | 81 ++ .../src/Clawdbot.Windows.Core/AppSettings.cs | 164 ++++ .../Clawdbot.Windows.Core/AutoStartHelper.cs | 130 +++ .../Clawdbot.Windows.Core.csproj | 20 + .../ExecApprovalModels.cs | 82 ++ .../ExecApprovalService.cs | 212 +++++ .../Clawdbot.Windows.Core/GatewayChannel.cs | 472 +++++++++++ .../NotificationSounds.cs | 77 ++ .../Clawdbot.Windows.Core/WebView2Helper.cs | 85 ++ .../Clawdbot.Windows.Protocol.csproj | 15 + .../GatewayModels.cs | 769 ++++++++++++++++++ apps/windows/src/Clawdbot.Windows/App.xaml | 25 + apps/windows/src/Clawdbot.Windows/App.xaml.cs | 220 +++++ .../src/Clawdbot.Windows/Assets/clawdbot.ico | Bin 0 -> 96542 bytes .../Clawdbot.Windows/Clawdbot.Windows.csproj | 31 + .../Clawdbot.Windows/ExecApprovalDialog.xaml | 212 +++++ .../ExecApprovalDialog.xaml.cs | 252 ++++++ .../src/Clawdbot.Windows/MainWindow.xaml | 100 +++ .../src/Clawdbot.Windows/MainWindow.xaml.cs | 205 +++++ .../src/Clawdbot.Windows/SettingsWindow.xaml | 182 +++++ .../Clawdbot.Windows/SettingsWindow.xaml.cs | 141 ++++ .../src/Clawdbot.Windows/SystemTrayIcon.cs | 290 +++++++ .../Clawdbot.Windows.Tests.csproj | 27 + .../ExecApprovalTests.cs | 167 ++++ .../Phase0ValidationTests.cs | 286 +++++++ .../Clawdbot.Windows.Tests/SettingsTests.cs | 152 ++++ docs/docs.json | 1 + .../windows-companion-build-guide.md | 559 +++++++++++++ docs/platforms/windows.md | 61 +- 34 files changed, 5552 insertions(+), 3 deletions(-) create mode 100644 apps/windows/.gitignore create mode 100644 apps/windows/ClawdbotWindows.sln create mode 100644 apps/windows/README.md create mode 100644 apps/windows/installer/clawdbot.iss create mode 100644 apps/windows/scripts/build.ps1 create mode 100644 apps/windows/src/Clawdbot.Windows.Core/AppLogger.cs create mode 100644 apps/windows/src/Clawdbot.Windows.Core/AppSettings.cs create mode 100644 apps/windows/src/Clawdbot.Windows.Core/AutoStartHelper.cs create mode 100644 apps/windows/src/Clawdbot.Windows.Core/Clawdbot.Windows.Core.csproj create mode 100644 apps/windows/src/Clawdbot.Windows.Core/ExecApprovalModels.cs create mode 100644 apps/windows/src/Clawdbot.Windows.Core/ExecApprovalService.cs create mode 100644 apps/windows/src/Clawdbot.Windows.Core/GatewayChannel.cs create mode 100644 apps/windows/src/Clawdbot.Windows.Core/NotificationSounds.cs create mode 100644 apps/windows/src/Clawdbot.Windows.Core/WebView2Helper.cs create mode 100644 apps/windows/src/Clawdbot.Windows.Protocol/Clawdbot.Windows.Protocol.csproj create mode 100644 apps/windows/src/Clawdbot.Windows.Protocol/GatewayModels.cs create mode 100644 apps/windows/src/Clawdbot.Windows/App.xaml create mode 100644 apps/windows/src/Clawdbot.Windows/App.xaml.cs create mode 100644 apps/windows/src/Clawdbot.Windows/Assets/clawdbot.ico create mode 100644 apps/windows/src/Clawdbot.Windows/Clawdbot.Windows.csproj create mode 100644 apps/windows/src/Clawdbot.Windows/ExecApprovalDialog.xaml create mode 100644 apps/windows/src/Clawdbot.Windows/ExecApprovalDialog.xaml.cs create mode 100644 apps/windows/src/Clawdbot.Windows/MainWindow.xaml create mode 100644 apps/windows/src/Clawdbot.Windows/MainWindow.xaml.cs create mode 100644 apps/windows/src/Clawdbot.Windows/SettingsWindow.xaml create mode 100644 apps/windows/src/Clawdbot.Windows/SettingsWindow.xaml.cs create mode 100644 apps/windows/src/Clawdbot.Windows/SystemTrayIcon.cs create mode 100644 apps/windows/tests/Clawdbot.Windows.Tests/Clawdbot.Windows.Tests.csproj create mode 100644 apps/windows/tests/Clawdbot.Windows.Tests/ExecApprovalTests.cs create mode 100644 apps/windows/tests/Clawdbot.Windows.Tests/Phase0ValidationTests.cs create mode 100644 apps/windows/tests/Clawdbot.Windows.Tests/SettingsTests.cs create mode 100644 docs/platforms/windows-companion-build-guide.md 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 0000000000000000000000000000000000000000..ec5665f56e51d73d80a80b96dc2158eaf670f191 GIT binary patch literal 96542 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 X2AmpjYQU)hrv{uFaBAQ?(ZIg|%tDt< literal 0 HcmV?d00001 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +