Initial commit: Moltbot Windows Hub monorepo
- Moltbot.Tray: Windows system tray companion - Moltbot.Shared: Gateway client library - Moltbot.CommandPalette: PowerToys Command Palette extension Consolidated from clawdbot-windows-tray, clawdbot-shared, and moltbot-commandpalette-extension repos.
This commit is contained in:
commit
db54ae50b2
345
.gitignore
vendored
Normal file
345
.gitignore
vendored
Normal file
@ -0,0 +1,345 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Oo]ut/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these files may be extracted
|
||||
*.azurePubxml
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment the next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
CrystalDecisions.ReportingServices.ViewerObjectModel.dll
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Scott Hanselman
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
109
README.md
Normal file
109
README.md
Normal file
@ -0,0 +1,109 @@
|
||||
# 🦞 Moltbot Windows Hub
|
||||
|
||||
A Windows companion suite for [Moltbot](https://moltbot.com) - the AI-powered personal assistant.
|
||||
|
||||
## Projects
|
||||
|
||||
This monorepo contains three projects:
|
||||
|
||||
| Project | Description |
|
||||
|---------|-------------|
|
||||
| **Moltbot.Tray** | System tray application for quick access to Moltbot |
|
||||
| **Moltbot.Shared** | Shared gateway client library |
|
||||
| **Moltbot.CommandPalette** | PowerToys Command Palette extension |
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- .NET 9.0 SDK
|
||||
- Windows 10/11
|
||||
- PowerToys (for Command Palette extension)
|
||||
|
||||
### Build
|
||||
```bash
|
||||
dotnet build
|
||||
```
|
||||
|
||||
### Run Tray App
|
||||
```bash
|
||||
dotnet run --project src/Moltbot.Tray
|
||||
```
|
||||
|
||||
## 📦 Moltbot.Tray
|
||||
|
||||
Windows system tray companion that connects to your local Moltbot gateway.
|
||||
|
||||
### Features
|
||||
- 🦞 Lobster icon in system tray (connected/disconnected states)
|
||||
- 💬 Quick Send - Send messages via global hotkey (Ctrl+Shift+M)
|
||||
- 🌐 Web Chat - Embedded chat window
|
||||
- 📊 Status Display - View sessions and channels
|
||||
- 🔔 Toast Notifications - Clickable Windows notifications
|
||||
- 🚀 Auto-start with Windows
|
||||
- ⚙️ Settings management
|
||||
|
||||
### Mac Parity Status
|
||||
|
||||
| Feature | Mac | Windows |
|
||||
|---------|-----|---------|
|
||||
| System tray icon | ✅ | ✅ |
|
||||
| Connection status | ✅ | ✅ |
|
||||
| Quick send hotkey | ✅ | ✅ |
|
||||
| Web chat window | ✅ | ✅ |
|
||||
| Toast notifications | ✅ | ✅ |
|
||||
| Auto-start | ✅ | ✅ |
|
||||
| Session display | ✅ | ✅ |
|
||||
| Channel health | ✅ | ✅ |
|
||||
| Deep links | ✅ | 🔄 |
|
||||
|
||||
## 📦 Moltbot.CommandPalette
|
||||
|
||||
PowerToys Command Palette extension for quick Moltbot access.
|
||||
|
||||
### Commands
|
||||
- **🦞 Open Dashboard** - Launch web dashboard
|
||||
- **💬 Quick Send** - Send a message
|
||||
- **📊 Full Status** - View gateway status
|
||||
- **⚡ Sessions** - View active sessions
|
||||
- **📡 Channels** - View channel health
|
||||
- **🔄 Health Check** - Trigger health refresh
|
||||
|
||||
### Installation
|
||||
1. Build the solution in Release mode
|
||||
2. Deploy the MSIX package via Visual Studio
|
||||
3. Open Command Palette (Win+Alt+Space)
|
||||
4. Type "Moltbot" to see commands
|
||||
|
||||
## 📦 Moltbot.Shared
|
||||
|
||||
Shared library containing:
|
||||
- `MoltbotGatewayClient` - WebSocket client for gateway protocol
|
||||
- `IMoltbotLogger` - Logging interface
|
||||
- Data models (SessionInfo, ChannelHealth, etc.)
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
moltbot-windows-hub/
|
||||
├── src/
|
||||
│ ├── Moltbot.Shared/ # Shared gateway library
|
||||
│ ├── Moltbot.Tray/ # System tray app
|
||||
│ └── Moltbot.CommandPalette/ # PowerToys extension
|
||||
├── moltbot-windows-hub.sln
|
||||
├── README.md
|
||||
├── LICENSE
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Settings are stored in:
|
||||
- Tray: `%APPDATA%\MoltbotTray\settings.json`
|
||||
- Logs: `%APPDATA%\MoltbotTray\moltbot-tray.log`
|
||||
|
||||
Default gateway: `ws://localhost:18789`
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE)
|
||||
9
moltbot-windows-hub.slnx
Normal file
9
moltbot-windows-hub.slnx
Normal file
@ -0,0 +1,9 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Moltbot.CommandPalette/Moltbot.CommandPalette.csproj">
|
||||
<Platform Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/Moltbot.Shared/Moltbot.Shared.csproj" />
|
||||
<Project Path="src/Moltbot.Tray/Moltbot.Tray.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
BIN
src/Moltbot.CommandPalette/Assets/LockScreenLogo.scale-200.png
Normal file
BIN
src/Moltbot.CommandPalette/Assets/LockScreenLogo.scale-200.png
Normal file
Binary file not shown.
BIN
src/Moltbot.CommandPalette/Assets/SplashScreen.scale-200.png
Normal file
BIN
src/Moltbot.CommandPalette/Assets/SplashScreen.scale-200.png
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/Moltbot.CommandPalette/Assets/Square44x44Logo.scale-200.png
Normal file
BIN
src/Moltbot.CommandPalette/Assets/Square44x44Logo.scale-200.png
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/Moltbot.CommandPalette/Assets/StoreLogo.png
Normal file
BIN
src/Moltbot.CommandPalette/Assets/StoreLogo.png
Normal file
Binary file not shown.
BIN
src/Moltbot.CommandPalette/Assets/Wide310x150Logo.scale-200.png
Normal file
BIN
src/Moltbot.CommandPalette/Assets/Wide310x150Logo.scale-200.png
Normal file
Binary file not shown.
10
src/Moltbot.CommandPalette/Directory.Build.props
Normal file
10
src/Moltbot.CommandPalette/Directory.Build.props
Normal file
@ -0,0 +1,10 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Platforms>x64;ARM64</Platforms>
|
||||
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
||||
<AnalysisMode>Recommended</AnalysisMode>
|
||||
<_SkipUpgradeNetAnalyzersNuGetWarning>true</_SkipUpgradeNetAnalyzersNuGetWarning>
|
||||
<NuGetAuditMode>direct</NuGetAuditMode>
|
||||
<PlatformTarget>$(Platform)</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
18
src/Moltbot.CommandPalette/Directory.Packages.props
Normal file
18
src/Moltbot.CommandPalette/Directory.Packages.props
Normal file
@ -0,0 +1,18 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.5.250829002" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
|
||||
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" />
|
||||
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4188" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.8.250907003" />
|
||||
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.8" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
101
src/Moltbot.CommandPalette/Moltbot.CommandPalette.csproj
Normal file
101
src/Moltbot.CommandPalette/Moltbot.CommandPalette.csproj
Normal file
@ -0,0 +1,101 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<RootNamespace>Moltbot</RootNamespace>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
|
||||
<WindowsSdkPackageVersion>10.0.26100.68-preview</WindowsSdkPackageVersion>
|
||||
<TargetFramework>net9.0-windows10.0.26100.0</TargetFramework>
|
||||
<TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion>
|
||||
<SupportedOSPlatformVersion>10.0.19041.0</SupportedOSPlatformVersion>
|
||||
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
||||
|
||||
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
|
||||
<EnableMsixTooling>true</EnableMsixTooling>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Assets\SplashScreen.scale-200.png" />
|
||||
<Content Include="Assets\LockScreenLogo.scale-200.png" />
|
||||
<Content Include="Assets\Square150x150Logo.scale-200.png" />
|
||||
<Content Include="Assets\Square44x44Logo.scale-200.png" />
|
||||
<Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" />
|
||||
<Content Include="Assets\StoreLogo.png" />
|
||||
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Manifest Include="$(ApplicationManifest)" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging
|
||||
Tools extension to be activated for this project even if the Windows App SDK Nuget
|
||||
package has not yet been restored.
|
||||
-->
|
||||
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
|
||||
<ProjectCapability Include="Msix" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CommandPalette.Extensions" />
|
||||
<PackageReference Include="Microsoft.Windows.CsWinRT" />
|
||||
<PackageReference Include="Shmuelie.WinRTServer" />
|
||||
|
||||
<!-- Needed to enable building an MSIX package -->
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.MSIX">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Moltbot.Shared\Moltbot.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Defining the "HasPackageAndPublishMenuAddedByProject" property here allows the Solution
|
||||
Explorer "Package and Publish" context menu entry to be enabled for this project even if
|
||||
the Windows App SDK Nuget package has not yet been restored.
|
||||
-->
|
||||
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
|
||||
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
|
||||
<CsWinRTAotOptimizerEnabled>true</CsWinRTAotOptimizerEnabled>
|
||||
<CsWinRTAotWarningLevel>2</CsWinRTAotWarningLevel>
|
||||
<!-- Suppress DynamicallyAccessedMemberTypes.PublicParameterlessConstructor in fallback code path of Windows SDK projection -->
|
||||
<WarningsNotAsErrors>IL2081;$(WarningsNotAsErrors)</WarningsNotAsErrors>
|
||||
|
||||
<!-- When publishing trimmed, make sure to treat trimming warnings as build errors -->
|
||||
<ILLinkTreatWarningsAsErrors>true</ILLinkTreatWarningsAsErrors>
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
|
||||
<!-- In Debug builds, trimming is disabled by default, but all the trim &
|
||||
AOT warnings are enabled. This gives debug builds a tighter inner loop,
|
||||
while at least warning about future trim violations -->
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
|
||||
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
|
||||
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
|
||||
<EnableAotAnalyzer>true</EnableAotAnalyzer>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'!='Debug'">
|
||||
<!-- In Release builds, trimming is enabled by default.
|
||||
feel free to disable this if needed -->
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
|
||||
<!-- In release, also ignore the aforementioned ILLink warning -->
|
||||
<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
</Project>
|
||||
|
||||
35
src/Moltbot.CommandPalette/Moltbot.cs
Normal file
35
src/Moltbot.CommandPalette/Moltbot.cs
Normal file
@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
|
||||
namespace Moltbot;
|
||||
|
||||
[Guid("b71e1e6d-20f4-4fd8-9d8e-cc5dc94ca8b5")]
|
||||
public sealed partial class Moltbot : IExtension, IDisposable
|
||||
{
|
||||
private readonly ManualResetEvent _extensionDisposedEvent;
|
||||
|
||||
private readonly MoltbotCommandsProvider _provider = new();
|
||||
|
||||
public Moltbot(ManualResetEvent extensionDisposedEvent)
|
||||
{
|
||||
this._extensionDisposedEvent = extensionDisposedEvent;
|
||||
}
|
||||
|
||||
public object? GetProvider(ProviderType providerType)
|
||||
{
|
||||
return providerType switch
|
||||
{
|
||||
ProviderType.Commands => _provider,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose() => this._extensionDisposedEvent.Set();
|
||||
}
|
||||
|
||||
29
src/Moltbot.CommandPalette/MoltbotCommandsProvider.cs
Normal file
29
src/Moltbot.CommandPalette/MoltbotCommandsProvider.cs
Normal file
@ -0,0 +1,29 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Moltbot;
|
||||
|
||||
public partial class MoltbotCommandsProvider : CommandProvider
|
||||
{
|
||||
private readonly ICommandItem[] _commands;
|
||||
|
||||
public MoltbotCommandsProvider()
|
||||
{
|
||||
DisplayName = "Moltbot";
|
||||
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||
_commands = [
|
||||
new CommandItem(new MoltbotPage()) { Title = DisplayName },
|
||||
];
|
||||
}
|
||||
|
||||
public override ICommandItem[] TopLevelCommands()
|
||||
{
|
||||
return _commands;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
80
src/Moltbot.CommandPalette/Package.appxmanifest
Normal file
80
src/Moltbot.CommandPalette/Package.appxmanifest
Normal file
@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
|
||||
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
IgnorableNamespaces="uap uap3 rescap">
|
||||
|
||||
<Identity
|
||||
Name="Moltbot"
|
||||
Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US"
|
||||
Version="0.0.1.0" />
|
||||
<!-- When you're ready to publish your extension, you'll need to change the
|
||||
Publisher= to match your own identity -->
|
||||
|
||||
<Properties>
|
||||
<DisplayName>Moltbot</DisplayName>
|
||||
<PublisherDisplayName>A Lone Developer</PublisherDisplayName>
|
||||
<Logo>Assets\StoreLogo.png</Logo>
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.19041.0" MaxVersionTested="10.0.19041.0" />
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.19041.0" />
|
||||
</Dependencies>
|
||||
|
||||
<Resources>
|
||||
<Resource Language="x-generate"/>
|
||||
</Resources>
|
||||
|
||||
<Applications>
|
||||
<Application Id="App"
|
||||
Executable="$targetnametoken$.exe"
|
||||
EntryPoint="$targetentrypoint$">
|
||||
<uap:VisualElements
|
||||
DisplayName="Moltbot"
|
||||
Description="Moltbot"
|
||||
BackgroundColor="transparent"
|
||||
Square150x150Logo="Assets\Square150x150Logo.png"
|
||||
Square44x44Logo="Assets\Square44x44Logo.png">
|
||||
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
|
||||
<uap:SplashScreen Image="Assets\SplashScreen.png" />
|
||||
</uap:VisualElements>
|
||||
<Extensions>
|
||||
<com:Extension Category="windows.comServer">
|
||||
<com:ComServer>
|
||||
<com:ExeServer Executable="Moltbot.exe" Arguments="-RegisterProcessAsComServer" DisplayName="Moltbot">
|
||||
<com:Class Id="b71e1e6d-20f4-4fd8-9d8e-cc5dc94ca8b5" DisplayName="Moltbot" />
|
||||
</com:ExeServer>
|
||||
</com:ComServer>
|
||||
</com:Extension>
|
||||
<uap3:Extension Category="windows.appExtension">
|
||||
<uap3:AppExtension Name="com.microsoft.commandpalette"
|
||||
Id="ID"
|
||||
PublicFolder="Public"
|
||||
DisplayName="Moltbot"
|
||||
Description="Moltbot">
|
||||
<uap3:Properties>
|
||||
<CmdPalProvider>
|
||||
<Activation>
|
||||
<CreateInstance ClassId="b71e1e6d-20f4-4fd8-9d8e-cc5dc94ca8b5" />
|
||||
</Activation>
|
||||
<SupportedInterfaces>
|
||||
<Commands/>
|
||||
</SupportedInterfaces>
|
||||
</CmdPalProvider>
|
||||
</uap3:Properties>
|
||||
</uap3:AppExtension>
|
||||
</uap3:Extension>
|
||||
</Extensions>
|
||||
</Application>
|
||||
</Applications>
|
||||
|
||||
<Capabilities>
|
||||
<Capability Name="internetClient" />
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
</Capabilities>
|
||||
</Package>
|
||||
513
src/Moltbot.CommandPalette/Pages/MoltbotPage.cs
Normal file
513
src/Moltbot.CommandPalette/Pages/MoltbotPage.cs
Normal file
@ -0,0 +1,513 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Moltbot.Shared;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Moltbot;
|
||||
|
||||
internal sealed partial class MoltbotPage : ListPage
|
||||
{
|
||||
private static string _gatewayUrl = "ws://localhost:18789";
|
||||
private static string _token = "";
|
||||
|
||||
public MoltbotPage()
|
||||
{
|
||||
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||
Title = "Moltbot";
|
||||
Name = "Open";
|
||||
|
||||
// Try to load settings from tray app
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
private static void LoadSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var settingsPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"MoltbotTray", "settings.json");
|
||||
|
||||
if (File.Exists(settingsPath))
|
||||
{
|
||||
var json = File.ReadAllText(settingsPath);
|
||||
var settings = System.Text.Json.JsonDocument.Parse(json);
|
||||
|
||||
if (settings.RootElement.TryGetProperty("GatewayUrl", out var url))
|
||||
_gatewayUrl = url.GetString() ?? _gatewayUrl;
|
||||
if (settings.RootElement.TryGetProperty("Token", out var token))
|
||||
_token = token.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public override IListItem[] GetItems()
|
||||
{
|
||||
var items = new List<IListItem>
|
||||
{
|
||||
new ListItem(new OpenDashboardCommand(_gatewayUrl, _token))
|
||||
{
|
||||
Title = "🦞 Open Dashboard",
|
||||
Subtitle = "Open Moltbot web dashboard in browser"
|
||||
},
|
||||
new ListItem(new QuickSendCommand(_gatewayUrl, _token))
|
||||
{
|
||||
Title = "💬 Quick Send",
|
||||
Subtitle = "Send a message to Moltbot"
|
||||
},
|
||||
new ListItem(new StatusPage(_gatewayUrl, _token))
|
||||
{
|
||||
Title = "📊 Full Status",
|
||||
Subtitle = "View gateway, sessions, and channels"
|
||||
},
|
||||
new ListItem(new SessionsPage(_gatewayUrl, _token))
|
||||
{
|
||||
Title = "⚡ Sessions",
|
||||
Subtitle = "View active agent sessions"
|
||||
},
|
||||
new ListItem(new ChannelsPage(_gatewayUrl, _token))
|
||||
{
|
||||
Title = "📡 Channels",
|
||||
Subtitle = "View Telegram, WhatsApp status"
|
||||
},
|
||||
new ListItem(new HealthCheckCommand(_gatewayUrl, _token))
|
||||
{
|
||||
Title = "🔄 Health Check",
|
||||
Subtitle = "Run a gateway health check"
|
||||
}
|
||||
};
|
||||
|
||||
return items.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command to open the Moltbot dashboard in the browser.
|
||||
/// </summary>
|
||||
internal sealed partial class OpenDashboardCommand : InvokableCommand
|
||||
{
|
||||
private readonly string _gatewayUrl;
|
||||
private readonly string _token;
|
||||
|
||||
public OpenDashboardCommand(string gatewayUrl, string token)
|
||||
{
|
||||
_gatewayUrl = gatewayUrl;
|
||||
_token = token;
|
||||
}
|
||||
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = GetDashboardUrl(_gatewayUrl, _token);
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url)
|
||||
{
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
|
||||
return CommandResult.Hide();
|
||||
}
|
||||
|
||||
internal static string GetDashboardUrl(string gatewayUrl, string token)
|
||||
{
|
||||
var url = gatewayUrl
|
||||
.Replace("ws://", "http://")
|
||||
.Replace("wss://", "https://");
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
var separator = url.Contains('?') ? "&" : "?";
|
||||
url = $"{url}{separator}token={Uri.EscapeDataString(token)}";
|
||||
}
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command to send a quick message - prompts for input then sends.
|
||||
/// </summary>
|
||||
internal sealed partial class QuickSendCommand : InvokableCommand
|
||||
{
|
||||
private readonly string _gatewayUrl;
|
||||
private readonly string _token;
|
||||
|
||||
public QuickSendCommand(string gatewayUrl, string token)
|
||||
{
|
||||
_gatewayUrl = gatewayUrl;
|
||||
_token = token;
|
||||
Name = "Send message to Moltbot";
|
||||
}
|
||||
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
// Open a simple input dialog using Windows forms
|
||||
try
|
||||
{
|
||||
// Use the dashboard URL with a message prompt
|
||||
var url = OpenDashboardCommand.GetDashboardUrl(_gatewayUrl, _token);
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url)
|
||||
{
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
|
||||
return CommandResult.Hide();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command to run a health check.
|
||||
/// </summary>
|
||||
internal sealed partial class HealthCheckCommand : InvokableCommand
|
||||
{
|
||||
private readonly string _gatewayUrl;
|
||||
private readonly string _token;
|
||||
|
||||
public HealthCheckCommand(string gatewayUrl, string token)
|
||||
{
|
||||
_gatewayUrl = gatewayUrl;
|
||||
_token = token;
|
||||
}
|
||||
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
// Just run the health check and show a toast/notification
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new MoltbotGatewayClient(_gatewayUrl, _token);
|
||||
await client.ConnectAsync();
|
||||
await client.CheckHealthAsync();
|
||||
await Task.Delay(1000);
|
||||
await client.DisconnectAsync();
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
|
||||
// Keep palette open - user can check status page
|
||||
return CommandResult.KeepOpen();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Page showing active sessions.
|
||||
/// </summary>
|
||||
internal sealed partial class SessionsPage : ContentPage
|
||||
{
|
||||
private readonly string _gatewayUrl;
|
||||
private readonly string _token;
|
||||
|
||||
public SessionsPage(string gatewayUrl, string token)
|
||||
{
|
||||
_gatewayUrl = gatewayUrl;
|
||||
_token = token;
|
||||
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||
Title = "Sessions";
|
||||
Name = "View sessions";
|
||||
}
|
||||
|
||||
public override IContent[] GetContent()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("## ⚡ Active Sessions");
|
||||
sb.AppendLine();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new MoltbotGatewayClient(_gatewayUrl, _token);
|
||||
|
||||
var task = client.ConnectAsync();
|
||||
task.Wait(TimeSpan.FromSeconds(3));
|
||||
|
||||
if (!task.IsCompletedSuccessfully)
|
||||
{
|
||||
sb.AppendLine("❌ Could not connect to gateway");
|
||||
return [new MarkdownContent { Body = sb.ToString() }];
|
||||
}
|
||||
|
||||
client.RequestSessionsAsync().Wait(TimeSpan.FromSeconds(2));
|
||||
var sessions = client.GetSessionList();
|
||||
|
||||
if (sessions.Length == 0)
|
||||
{
|
||||
sb.AppendLine("_No active sessions_");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Group by main/sub
|
||||
var mainSessions = new List<SessionInfo>();
|
||||
var subSessions = new List<SessionInfo>();
|
||||
|
||||
foreach (var s in sessions)
|
||||
{
|
||||
if (s.IsMain) mainSessions.Add(s);
|
||||
else subSessions.Add(s);
|
||||
}
|
||||
|
||||
if (mainSessions.Count > 0)
|
||||
{
|
||||
sb.AppendLine("### ⚡ Main Sessions");
|
||||
foreach (var s in mainSessions)
|
||||
{
|
||||
sb.AppendLine($"- **{s.ShortKey}**");
|
||||
if (!string.IsNullOrEmpty(s.Model))
|
||||
sb.AppendLine($" - Model: `{s.Model}`");
|
||||
if (!string.IsNullOrEmpty(s.Channel))
|
||||
sb.AppendLine($" - Channel: {s.Channel}");
|
||||
if (s.StartedAt.HasValue)
|
||||
sb.AppendLine($" - Started: {s.StartedAt:g}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (subSessions.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"### 🔹 Sub-Sessions ({subSessions.Count})");
|
||||
foreach (var s in subSessions)
|
||||
{
|
||||
var activity = !string.IsNullOrEmpty(s.CurrentActivity) ? $" - {s.CurrentActivity}" : "";
|
||||
sb.AppendLine($"- {s.ShortKey}{activity}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.DisconnectAsync().Wait(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sb.AppendLine($"❌ Error: {ex.Message}");
|
||||
}
|
||||
|
||||
return [new MarkdownContent { Body = sb.ToString() }];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Page showing channel health.
|
||||
/// </summary>
|
||||
internal sealed partial class ChannelsPage : ContentPage
|
||||
{
|
||||
private readonly string _gatewayUrl;
|
||||
private readonly string _token;
|
||||
private ChannelHealth[]? _channels;
|
||||
|
||||
public ChannelsPage(string gatewayUrl, string token)
|
||||
{
|
||||
_gatewayUrl = gatewayUrl;
|
||||
_token = token;
|
||||
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||
Title = "Channels";
|
||||
Name = "View channels";
|
||||
}
|
||||
|
||||
public override IContent[] GetContent()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("## 📡 Channel Status");
|
||||
sb.AppendLine();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new MoltbotGatewayClient(_gatewayUrl, _token);
|
||||
|
||||
client.ChannelHealthUpdated += (s, channels) => _channels = channels;
|
||||
|
||||
var task = client.ConnectAsync();
|
||||
task.Wait(TimeSpan.FromSeconds(3));
|
||||
|
||||
if (!task.IsCompletedSuccessfully)
|
||||
{
|
||||
sb.AppendLine("❌ Could not connect to gateway");
|
||||
return [new MarkdownContent { Body = sb.ToString() }];
|
||||
}
|
||||
|
||||
// Health check fetches channel status
|
||||
client.CheckHealthAsync().Wait(TimeSpan.FromSeconds(2));
|
||||
|
||||
if (_channels == null || _channels.Length == 0)
|
||||
{
|
||||
sb.AppendLine("_No channels configured_");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var ch in _channels)
|
||||
{
|
||||
var statusIcon = ch.Status.ToLowerInvariant() switch
|
||||
{
|
||||
"running" or "ok" or "connected" => "🟢",
|
||||
"ready" => "🟡",
|
||||
"linked" => "🔵",
|
||||
"stopped" or "configured" => "⚪",
|
||||
"error" => "🔴",
|
||||
_ => "⚫"
|
||||
};
|
||||
|
||||
var name = char.ToUpper(ch.Name[0]) + ch.Name[1..];
|
||||
sb.AppendLine($"### {statusIcon} {name}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- **Status:** {ch.Status}");
|
||||
|
||||
if (ch.IsLinked)
|
||||
sb.AppendLine("- **Linked:** ✅ Yes");
|
||||
|
||||
if (!string.IsNullOrEmpty(ch.AuthAge))
|
||||
sb.AppendLine($"- **Auth Age:** {ch.AuthAge}");
|
||||
|
||||
if (!string.IsNullOrEmpty(ch.Error))
|
||||
sb.AppendLine($"- **Error:** ⚠️ {ch.Error}");
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
client.DisconnectAsync().Wait(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sb.AppendLine($"❌ Error: {ex.Message}");
|
||||
}
|
||||
|
||||
return [new MarkdownContent { Body = sb.ToString() }];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Page showing full Moltbot status information.
|
||||
/// </summary>
|
||||
internal sealed partial class StatusPage : ContentPage
|
||||
{
|
||||
private readonly string _gatewayUrl;
|
||||
private readonly string _token;
|
||||
private ChannelHealth[]? _channels;
|
||||
|
||||
public StatusPage(string gatewayUrl, string token)
|
||||
{
|
||||
_gatewayUrl = gatewayUrl;
|
||||
_token = token;
|
||||
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||
Title = "Moltbot Status";
|
||||
Name = "View status";
|
||||
}
|
||||
|
||||
public override IContent[] GetContent()
|
||||
{
|
||||
var markdown = new MarkdownContent
|
||||
{
|
||||
Body = GetStatusMarkdown()
|
||||
};
|
||||
return [markdown];
|
||||
}
|
||||
|
||||
private string GetStatusMarkdown()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new MoltbotGatewayClient(_gatewayUrl, _token);
|
||||
|
||||
client.ChannelHealthUpdated += (s, channels) => _channels = channels;
|
||||
|
||||
var task = client.ConnectAsync();
|
||||
task.Wait(TimeSpan.FromSeconds(3));
|
||||
|
||||
if (!task.IsCompletedSuccessfully)
|
||||
{
|
||||
return "## ❌ Disconnected\n\nCould not connect to gateway.\n\nMake sure Moltbot gateway is running.";
|
||||
}
|
||||
|
||||
sb.AppendLine("## 🦞 Moltbot Status");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("### Connection");
|
||||
sb.AppendLine($"- **Gateway:** `{_gatewayUrl}`");
|
||||
sb.AppendLine("- **Status:** ✅ Connected");
|
||||
sb.AppendLine();
|
||||
|
||||
// Get health and sessions
|
||||
client.CheckHealthAsync().Wait(TimeSpan.FromSeconds(2));
|
||||
client.RequestSessionsAsync().Wait(TimeSpan.FromSeconds(1));
|
||||
|
||||
var sessions = client.GetSessionList();
|
||||
|
||||
// Sessions
|
||||
sb.AppendLine("### ⚡ Sessions");
|
||||
if (sessions.Length == 0)
|
||||
{
|
||||
sb.AppendLine("_No active sessions_");
|
||||
}
|
||||
else
|
||||
{
|
||||
var mainCount = 0;
|
||||
var subCount = 0;
|
||||
foreach (var s in sessions)
|
||||
{
|
||||
if (s.IsMain) mainCount++;
|
||||
else subCount++;
|
||||
}
|
||||
sb.AppendLine($"- Main: **{mainCount}** | Sub: **{subCount}**");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var s in sessions.Take(5))
|
||||
{
|
||||
var icon = s.IsMain ? "⚡" : "🔹";
|
||||
sb.AppendLine($"- {icon} {s.DisplayText}");
|
||||
}
|
||||
|
||||
if (sessions.Length > 5)
|
||||
sb.AppendLine($"- _...and {sessions.Length - 5} more_");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Channels
|
||||
sb.AppendLine("### 📡 Channels");
|
||||
if (_channels == null || _channels.Length == 0)
|
||||
{
|
||||
sb.AppendLine("_No channels configured_");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var ch in _channels)
|
||||
{
|
||||
var statusIcon = ch.Status.ToLowerInvariant() switch
|
||||
{
|
||||
"running" or "ok" => "🟢",
|
||||
"ready" => "🟡",
|
||||
"linked" => "🔵",
|
||||
"stopped" => "⚪",
|
||||
"error" => "🔴",
|
||||
_ => "⚫"
|
||||
};
|
||||
var name = char.ToUpper(ch.Name[0]) + ch.Name[1..];
|
||||
sb.AppendLine($"- {statusIcon} **{name}:** {ch.Status}");
|
||||
}
|
||||
}
|
||||
|
||||
client.DisconnectAsync().Wait(TimeSpan.FromSeconds(1));
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine("_Use 🦞 Open Dashboard for the full web interface_");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"## ❌ Error\n\n{ex.Message}\n\nMake sure the gateway is running and your settings are correct.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
src/Moltbot.CommandPalette/Program.cs
Normal file
44
src/Moltbot.CommandPalette/Program.cs
Normal file
@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Shmuelie.WinRTServer;
|
||||
using Shmuelie.WinRTServer.CsWinRT;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Moltbot;
|
||||
|
||||
public class Program
|
||||
{
|
||||
[MTAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer")
|
||||
{
|
||||
global::Shmuelie.WinRTServer.ComServer server = new();
|
||||
|
||||
ManualResetEvent extensionDisposedEvent = new(false);
|
||||
|
||||
// We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called.
|
||||
// This makes sure that only one instance of SampleExtension is alive, which is returned every time the host asks for the IExtension object.
|
||||
// If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate.
|
||||
Moltbot extensionInstance = new(extensionDisposedEvent);
|
||||
server.RegisterClass<Moltbot, IExtension>(() => extensionInstance);
|
||||
server.Start();
|
||||
|
||||
// This will make the main thread wait until the event is signalled by the extension class.
|
||||
// Since we have single instance of the extension object, we exit as soon as it is disposed.
|
||||
extensionDisposedEvent.WaitOne();
|
||||
server.Stop();
|
||||
server.UnsafeDispose();
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Not being launched as a Extension... exiting.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
src/Moltbot.CommandPalette/Properties/launchSettings.json
Normal file
11
src/Moltbot.CommandPalette/Properties/launchSettings.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Moltbot (Package)": {
|
||||
"commandName": "MsixPackage",
|
||||
"doNotLaunchApp": true
|
||||
},
|
||||
"Moltbot (Unpackaged)": {
|
||||
"commandName": "Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/Moltbot.CommandPalette/app.manifest
Normal file
20
src/Moltbot.CommandPalette/app.manifest
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="Moltbot.app"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- The ID below informs the system that this application is compatible with OS features first introduced in Windows 10.
|
||||
It is necessary to support features in unpackaged applications, for example the custom titlebar implementation.
|
||||
For more info see https://docs.microsoft.com/windows/apps/windows-app-sdk/use-windows-app-sdk-run-time#declare-os-compatibility-in-your-application-manifest -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
12
src/Moltbot.CommandPalette/nuget.config
Normal file
12
src/Moltbot.CommandPalette/nuget.config
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
35
src/Moltbot.Shared/IMoltbotLogger.cs
Normal file
35
src/Moltbot.Shared/IMoltbotLogger.cs
Normal file
@ -0,0 +1,35 @@
|
||||
namespace Moltbot.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Simple logger interface for the gateway client.
|
||||
/// Implementations can write to file, console, debug output, etc.
|
||||
/// </summary>
|
||||
public interface IMoltbotLogger
|
||||
{
|
||||
void Info(string message);
|
||||
void Warn(string message);
|
||||
void Error(string message, Exception? ex = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default no-op logger for when logging isn't needed.
|
||||
/// </summary>
|
||||
public class NullLogger : IMoltbotLogger
|
||||
{
|
||||
public static readonly NullLogger Instance = new();
|
||||
public void Info(string message) { }
|
||||
public void Warn(string message) { }
|
||||
public void Error(string message, Exception? ex = null) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Console logger for simple debugging.
|
||||
/// </summary>
|
||||
public class ConsoleLogger : IMoltbotLogger
|
||||
{
|
||||
public void Info(string message) => Console.WriteLine($"[INFO] {message}");
|
||||
public void Warn(string message) => Console.WriteLine($"[WARN] {message}");
|
||||
public void Error(string message, Exception? ex = null) =>
|
||||
Console.WriteLine($"[ERROR] {message}{(ex != null ? $": {ex.Message}" : "")}");
|
||||
}
|
||||
|
||||
186
src/Moltbot.Shared/Models.cs
Normal file
186
src/Moltbot.Shared/Models.cs
Normal file
@ -0,0 +1,186 @@
|
||||
namespace Moltbot.Shared;
|
||||
|
||||
public enum ConnectionStatus
|
||||
{
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Error
|
||||
}
|
||||
|
||||
public enum ActivityKind
|
||||
{
|
||||
Idle,
|
||||
Job,
|
||||
Exec,
|
||||
Read,
|
||||
Write,
|
||||
Edit,
|
||||
Search,
|
||||
Browser,
|
||||
Message,
|
||||
Tool
|
||||
}
|
||||
|
||||
public class AgentActivity
|
||||
{
|
||||
public string SessionKey { get; set; } = "";
|
||||
public bool IsMain { get; set; }
|
||||
public ActivityKind Kind { get; set; } = ActivityKind.Idle;
|
||||
public string State { get; set; } = "";
|
||||
public string ToolName { get; set; } = "";
|
||||
public string Label { get; set; } = "";
|
||||
|
||||
public string Glyph => Kind switch
|
||||
{
|
||||
ActivityKind.Exec => "💻",
|
||||
ActivityKind.Read => "📄",
|
||||
ActivityKind.Write => "✍️",
|
||||
ActivityKind.Edit => "📝",
|
||||
ActivityKind.Search => "🔍",
|
||||
ActivityKind.Browser => "🌐",
|
||||
ActivityKind.Message => "💬",
|
||||
ActivityKind.Tool => "🛠️",
|
||||
ActivityKind.Job => "⚡",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
public string DisplayText => Kind == ActivityKind.Idle
|
||||
? ""
|
||||
: $"{(IsMain ? "Main" : "Sub")} · {Glyph} {Label}";
|
||||
}
|
||||
|
||||
public class MoltbotNotification
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
public string Message { get; set; } = "";
|
||||
public string Type { get; set; } = "";
|
||||
}
|
||||
|
||||
public class ChannelHealth
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Status { get; set; } = "unknown";
|
||||
public bool IsLinked { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public string? AuthAge { get; set; }
|
||||
public string? Type { get; set; }
|
||||
|
||||
public string DisplayText
|
||||
{
|
||||
get
|
||||
{
|
||||
var label = Status.ToLowerInvariant() switch
|
||||
{
|
||||
"ok" or "connected" or "running" => "[ON]",
|
||||
"linked" => "[LINKED]",
|
||||
"ready" => "[READY]",
|
||||
"connecting" or "reconnecting" => "[...]",
|
||||
"error" or "disconnected" => "[ERR]",
|
||||
"stale" => "[STALE]",
|
||||
"configured" or "stopped" => "[OFF]",
|
||||
"not configured" => "[N/A]",
|
||||
_ => "[OFF]"
|
||||
};
|
||||
var detail = IsLinked && AuthAge != null ? $"linked · {AuthAge}" : Status;
|
||||
if (Error != null) detail += $" ({Error})";
|
||||
return $"{label} {Capitalize(Name)}: {detail}";
|
||||
}
|
||||
}
|
||||
|
||||
private static string Capitalize(string s) =>
|
||||
string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0]) + s[1..];
|
||||
}
|
||||
|
||||
public class SessionInfo
|
||||
{
|
||||
public string Key { get; set; } = "";
|
||||
public bool IsMain { get; set; }
|
||||
public string Status { get; set; } = "unknown";
|
||||
public string? Model { get; set; }
|
||||
public string? Channel { get; set; }
|
||||
public string? CurrentActivity { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime LastSeen { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public string DisplayText
|
||||
{
|
||||
get
|
||||
{
|
||||
var prefix = IsMain ? "Main" : "Sub";
|
||||
var parts = new List<string> { prefix };
|
||||
|
||||
if (!string.IsNullOrEmpty(Channel))
|
||||
parts.Add(Channel);
|
||||
|
||||
if (!string.IsNullOrEmpty(CurrentActivity))
|
||||
parts.Add(CurrentActivity);
|
||||
else if (!string.IsNullOrEmpty(Status) && Status != "unknown" && Status != "active")
|
||||
parts.Add(Status);
|
||||
|
||||
return string.Join(" · ", parts);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets a shortened, user-friendly version of the session key.</summary>
|
||||
public string ShortKey
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(Key)) return "unknown";
|
||||
|
||||
// Extract meaningful part from session keys like "agent:main:subagent:uuid"
|
||||
var parts = Key.Split(':');
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
// Return something like "subagent" or "cron"
|
||||
return parts[^2]; // Second to last part
|
||||
}
|
||||
|
||||
// For file paths, just return filename
|
||||
if (Key.Contains('/') || Key.Contains('\\'))
|
||||
{
|
||||
return Path.GetFileName(Key);
|
||||
}
|
||||
|
||||
return Key.Length > 20 ? Key[..17] + "..." : Key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class GatewayUsageInfo
|
||||
{
|
||||
public long InputTokens { get; set; }
|
||||
public long OutputTokens { get; set; }
|
||||
public long TotalTokens { get; set; }
|
||||
public double CostUsd { get; set; }
|
||||
public int RequestCount { get; set; }
|
||||
public string? Model { get; set; }
|
||||
|
||||
public string DisplayText
|
||||
{
|
||||
get
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (TotalTokens > 0)
|
||||
parts.Add($"Tokens: {FormatCount(TotalTokens)}");
|
||||
if (CostUsd > 0)
|
||||
parts.Add($"${CostUsd:F2}");
|
||||
if (RequestCount > 0)
|
||||
parts.Add($"{RequestCount} requests");
|
||||
if (!string.IsNullOrEmpty(Model))
|
||||
parts.Add(Model);
|
||||
return parts.Count > 0
|
||||
? string.Join(" · ", parts)
|
||||
: "No usage data";
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatCount(long n)
|
||||
{
|
||||
if (n >= 1_000_000) return $"{n / 1_000_000.0:F1}M";
|
||||
if (n >= 1_000) return $"{n / 1_000.0:F1}K";
|
||||
return n.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
11
src/Moltbot.Shared/Moltbot.Shared.csproj
Normal file
11
src/Moltbot.Shared/Moltbot.Shared.csproj
Normal file
@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Moltbot.Shared</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
862
src/Moltbot.Shared/MoltbotGatewayClient.cs
Normal file
862
src/Moltbot.Shared/MoltbotGatewayClient.cs
Normal file
@ -0,0 +1,862 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Moltbot.Shared;
|
||||
|
||||
public class MoltbotGatewayClient : IDisposable
|
||||
{
|
||||
private ClientWebSocket? _webSocket;
|
||||
private readonly string _gatewayUrl;
|
||||
private readonly string _token;
|
||||
private readonly IMoltbotLogger _logger;
|
||||
private CancellationTokenSource _cts;
|
||||
private bool _disposed;
|
||||
private int _reconnectAttempts;
|
||||
private static readonly int[] BackoffMs = { 1000, 2000, 4000, 8000, 15000, 30000, 60000 };
|
||||
|
||||
// Tracked state
|
||||
private readonly Dictionary<string, SessionInfo> _sessions = new();
|
||||
private GatewayUsageInfo? _usage;
|
||||
|
||||
// Events
|
||||
public event EventHandler<ConnectionStatus>? StatusChanged;
|
||||
public event EventHandler<MoltbotNotification>? NotificationReceived;
|
||||
public event EventHandler<AgentActivity>? ActivityChanged;
|
||||
public event EventHandler<ChannelHealth[]>? ChannelHealthUpdated;
|
||||
public event EventHandler<SessionInfo[]>? SessionsUpdated;
|
||||
public event EventHandler<GatewayUsageInfo>? UsageUpdated;
|
||||
|
||||
public MoltbotGatewayClient(string gatewayUrl, string token, IMoltbotLogger? logger = null)
|
||||
{
|
||||
_gatewayUrl = gatewayUrl;
|
||||
_token = token;
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
_cts = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
public async Task ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
StatusChanged?.Invoke(this, ConnectionStatus.Connecting);
|
||||
_logger.Info($"Connecting to gateway: {_gatewayUrl}");
|
||||
|
||||
_webSocket = new ClientWebSocket();
|
||||
_webSocket.Options.KeepAliveInterval = TimeSpan.FromSeconds(30);
|
||||
// Set Origin header to localhost to satisfy secure context check
|
||||
_webSocket.Options.SetRequestHeader("Origin", "http://localhost:18789");
|
||||
var uri = new Uri(_gatewayUrl);
|
||||
await _webSocket.ConnectAsync(uri, _cts.Token);
|
||||
|
||||
_reconnectAttempts = 0;
|
||||
_logger.Info("Gateway connected, waiting for challenge...");
|
||||
|
||||
// Don't send connect yet - wait for challenge event in ListenForMessagesAsync
|
||||
_ = Task.Run(() => ListenForMessagesAsync(), _cts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Connection failed", ex);
|
||||
StatusChanged?.Invoke(this, ConnectionStatus.Error);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
if (_webSocket?.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disconnecting", CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn($"Error during disconnect: {ex.Message}");
|
||||
}
|
||||
}
|
||||
StatusChanged?.Invoke(this, ConnectionStatus.Disconnected);
|
||||
_logger.Info("Disconnected");
|
||||
}
|
||||
|
||||
public async Task CheckHealthAsync()
|
||||
{
|
||||
if (_webSocket?.State != WebSocketState.Open)
|
||||
{
|
||||
await ReconnectWithBackoffAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var req = new
|
||||
{
|
||||
type = "req",
|
||||
id = Guid.NewGuid().ToString(),
|
||||
method = "health",
|
||||
@params = new { deep = true }
|
||||
};
|
||||
await SendRawAsync(JsonSerializer.Serialize(req));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Health check failed", ex);
|
||||
StatusChanged?.Invoke(this, ConnectionStatus.Error);
|
||||
await ReconnectWithBackoffAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendChatMessageAsync(string message)
|
||||
{
|
||||
if (_webSocket?.State != WebSocketState.Open)
|
||||
throw new InvalidOperationException("Gateway connection is not open");
|
||||
|
||||
var req = new
|
||||
{
|
||||
type = "req",
|
||||
id = Guid.NewGuid().ToString(),
|
||||
method = "chat.send",
|
||||
@params = new { message }
|
||||
};
|
||||
await SendRawAsync(JsonSerializer.Serialize(req));
|
||||
_logger.Info($"Sent chat message ({message.Length} chars)");
|
||||
}
|
||||
|
||||
/// <summary>Request session list from gateway.</summary>
|
||||
public async Task RequestSessionsAsync()
|
||||
{
|
||||
if (_webSocket?.State != WebSocketState.Open) return;
|
||||
var req = new
|
||||
{
|
||||
type = "req",
|
||||
id = Guid.NewGuid().ToString(),
|
||||
method = "sessions.list"
|
||||
};
|
||||
await SendRawAsync(JsonSerializer.Serialize(req));
|
||||
}
|
||||
|
||||
/// <summary>Request usage/context info from gateway (may not be supported on all gateways).</summary>
|
||||
public async Task RequestUsageAsync()
|
||||
{
|
||||
// Usage endpoint may not exist on all gateways - fail silently
|
||||
if (_webSocket?.State != WebSocketState.Open) return;
|
||||
try
|
||||
{
|
||||
var req = new
|
||||
{
|
||||
type = "req",
|
||||
id = Guid.NewGuid().ToString(),
|
||||
method = "usage"
|
||||
};
|
||||
await SendRawAsync(JsonSerializer.Serialize(req));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// --- Connection management ---
|
||||
|
||||
private async Task ReconnectWithBackoffAsync()
|
||||
{
|
||||
var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)];
|
||||
_reconnectAttempts++;
|
||||
_logger.Warn($"Reconnecting in {delay}ms (attempt {_reconnectAttempts})");
|
||||
StatusChanged?.Invoke(this, ConnectionStatus.Connecting);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, _cts.Token);
|
||||
_webSocket?.Dispose();
|
||||
_webSocket = null;
|
||||
await ConnectAsync();
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Reconnect failed", ex);
|
||||
StatusChanged?.Invoke(this, ConnectionStatus.Error);
|
||||
// Don't recurse — the listen loop will trigger reconnect again
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendConnectMessageAsync(string? nonce = null)
|
||||
{
|
||||
// Use "cli" client ID for native apps - no browser security checks
|
||||
var msg = new
|
||||
{
|
||||
type = "req",
|
||||
id = Guid.NewGuid().ToString(),
|
||||
method = "connect",
|
||||
@params = new
|
||||
{
|
||||
minProtocol = 3,
|
||||
maxProtocol = 3,
|
||||
client = new
|
||||
{
|
||||
id = "cli", // Native client ID
|
||||
version = "1.0.0",
|
||||
platform = "windows",
|
||||
mode = "cli",
|
||||
displayName = "Clawdbot Windows Tray"
|
||||
},
|
||||
role = "operator",
|
||||
scopes = new[] { "operator.admin", "operator.approvals", "operator.pairing" },
|
||||
caps = Array.Empty<string>(),
|
||||
commands = Array.Empty<string>(),
|
||||
permissions = new { },
|
||||
auth = new { token = _token },
|
||||
locale = "en-US",
|
||||
userAgent = "clawdbot-windows-tray/1.0.0"
|
||||
}
|
||||
};
|
||||
await SendRawAsync(JsonSerializer.Serialize(msg));
|
||||
}
|
||||
|
||||
private async Task SendRawAsync(string message)
|
||||
{
|
||||
if (_webSocket?.State == WebSocketState.Open)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(message);
|
||||
await _webSocket.SendAsync(new ArraySegment<byte>(bytes),
|
||||
WebSocketMessageType.Text, true, _cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Message loop ---
|
||||
|
||||
private async Task ListenForMessagesAsync()
|
||||
{
|
||||
var buffer = new byte[16384]; // Larger buffer for big events
|
||||
var sb = new StringBuilder();
|
||||
|
||||
try
|
||||
{
|
||||
while (_webSocket?.State == WebSocketState.Open && !_cts.Token.IsCancellationRequested)
|
||||
{
|
||||
var result = await _webSocket.ReceiveAsync(
|
||||
new ArraySegment<byte>(buffer), _cts.Token);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Text)
|
||||
{
|
||||
sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
ProcessMessage(sb.ToString());
|
||||
sb.Clear();
|
||||
}
|
||||
}
|
||||
else if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
var closeStatus = _webSocket.CloseStatus?.ToString() ?? "unknown";
|
||||
var closeDesc = _webSocket.CloseStatusDescription ?? "no description";
|
||||
_logger.Info($"Server closed connection: {closeStatus} - {closeDesc}");
|
||||
StatusChanged?.Invoke(this, ConnectionStatus.Disconnected);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
|
||||
{
|
||||
_logger.Warn("Connection closed prematurely");
|
||||
StatusChanged?.Invoke(this, ConnectionStatus.Disconnected);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Listen error", ex);
|
||||
StatusChanged?.Invoke(this, ConnectionStatus.Error);
|
||||
}
|
||||
|
||||
// Auto-reconnect if not intentionally disposed
|
||||
if (!_disposed && !_cts.Token.IsCancellationRequested)
|
||||
{
|
||||
await ReconnectWithBackoffAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Message processing ---
|
||||
|
||||
private void ProcessMessage(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("type", out var typeProp)) return;
|
||||
var type = typeProp.GetString();
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "res":
|
||||
HandleResponse(root);
|
||||
break;
|
||||
case "event":
|
||||
HandleEvent(root);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.Warn($"JSON parse error: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Message processing error", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleResponse(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("payload", out var payload)) return;
|
||||
|
||||
// Handle hello-ok
|
||||
if (payload.TryGetProperty("type", out var t) && t.GetString() == "hello-ok")
|
||||
{
|
||||
_logger.Info("Handshake complete (hello-ok)");
|
||||
StatusChanged?.Invoke(this, ConnectionStatus.Connected);
|
||||
|
||||
// Request initial state after handshake
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(500);
|
||||
await CheckHealthAsync();
|
||||
await RequestSessionsAsync();
|
||||
await RequestUsageAsync();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle health response — channels
|
||||
if (payload.TryGetProperty("channels", out var channels))
|
||||
{
|
||||
ParseChannelHealth(channels);
|
||||
}
|
||||
|
||||
// Handle sessions response
|
||||
if (payload.TryGetProperty("sessions", out var sessions))
|
||||
{
|
||||
ParseSessions(sessions);
|
||||
}
|
||||
|
||||
// Handle usage response
|
||||
if (payload.TryGetProperty("usage", out var usage))
|
||||
{
|
||||
ParseUsage(usage);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleEvent(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("event", out var eventProp)) return;
|
||||
var eventType = eventProp.GetString();
|
||||
|
||||
switch (eventType)
|
||||
{
|
||||
case "connect.challenge":
|
||||
HandleConnectChallenge(root);
|
||||
break;
|
||||
case "agent":
|
||||
HandleAgentEvent(root);
|
||||
break;
|
||||
case "health":
|
||||
if (root.TryGetProperty("payload", out var hp) &&
|
||||
hp.TryGetProperty("channels", out var ch))
|
||||
ParseChannelHealth(ch);
|
||||
break;
|
||||
case "chat":
|
||||
HandleChatEvent(root);
|
||||
break;
|
||||
case "session":
|
||||
HandleSessionEvent(root);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleConnectChallenge(JsonElement root)
|
||||
{
|
||||
string? nonce = null;
|
||||
if (root.TryGetProperty("payload", out var payload) &&
|
||||
payload.TryGetProperty("nonce", out var nonceProp))
|
||||
{
|
||||
nonce = nonceProp.GetString();
|
||||
}
|
||||
|
||||
_logger.Info($"Received challenge, nonce: {nonce}");
|
||||
_ = SendConnectMessageAsync(nonce);
|
||||
}
|
||||
|
||||
private void HandleAgentEvent(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("payload", out var payload)) return;
|
||||
|
||||
// Determine session
|
||||
var sessionKey = "unknown";
|
||||
if (root.TryGetProperty("sessionKey", out var sk))
|
||||
sessionKey = sk.GetString() ?? "unknown";
|
||||
var isMain = sessionKey == "main" || sessionKey.Contains(":main:");
|
||||
|
||||
// Parse activity from stream field
|
||||
if (payload.TryGetProperty("stream", out var streamProp))
|
||||
{
|
||||
var stream = streamProp.GetString();
|
||||
|
||||
if (stream == "job")
|
||||
{
|
||||
HandleJobEvent(payload, sessionKey, isMain);
|
||||
}
|
||||
else if (stream == "tool")
|
||||
{
|
||||
HandleToolEvent(payload, sessionKey, isMain);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for notification content
|
||||
if (payload.TryGetProperty("content", out var content))
|
||||
{
|
||||
var text = content.GetString() ?? "";
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
EmitNotification(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleJobEvent(JsonElement payload, string sessionKey, bool isMain)
|
||||
{
|
||||
var state = "unknown";
|
||||
if (payload.TryGetProperty("data", out var data) &&
|
||||
data.TryGetProperty("state", out var stateProp))
|
||||
state = stateProp.GetString() ?? "unknown";
|
||||
|
||||
var activity = new AgentActivity
|
||||
{
|
||||
SessionKey = sessionKey,
|
||||
IsMain = isMain,
|
||||
Kind = ActivityKind.Job,
|
||||
State = state,
|
||||
Label = $"Job: {state}"
|
||||
};
|
||||
|
||||
if (state == "done" || state == "error")
|
||||
activity.Kind = ActivityKind.Idle;
|
||||
|
||||
_logger.Info($"Agent activity: {activity.Label} (session: {sessionKey})");
|
||||
ActivityChanged?.Invoke(this, activity);
|
||||
|
||||
// Update tracked session
|
||||
UpdateTrackedSession(sessionKey, isMain, state == "done" || state == "error" ? null : $"Job: {state}");
|
||||
}
|
||||
|
||||
private void HandleToolEvent(JsonElement payload, string sessionKey, bool isMain)
|
||||
{
|
||||
var phase = "";
|
||||
var toolName = "";
|
||||
var label = "";
|
||||
|
||||
if (payload.TryGetProperty("data", out var data))
|
||||
{
|
||||
if (data.TryGetProperty("phase", out var phaseProp))
|
||||
phase = phaseProp.GetString() ?? "";
|
||||
if (data.TryGetProperty("name", out var nameProp))
|
||||
toolName = nameProp.GetString() ?? "";
|
||||
|
||||
// Extract detail from args
|
||||
if (data.TryGetProperty("args", out var args))
|
||||
{
|
||||
if (args.TryGetProperty("command", out var cmd))
|
||||
label = TruncateLabel(cmd.GetString()?.Split('\n')[0] ?? "");
|
||||
else if (args.TryGetProperty("path", out var path))
|
||||
label = ShortenPath(path.GetString() ?? "");
|
||||
else if (args.TryGetProperty("file_path", out var filePath))
|
||||
label = ShortenPath(filePath.GetString() ?? "");
|
||||
else if (args.TryGetProperty("query", out var query))
|
||||
label = TruncateLabel(query.GetString() ?? "");
|
||||
else if (args.TryGetProperty("url", out var url))
|
||||
label = TruncateLabel(url.GetString() ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(label))
|
||||
label = toolName;
|
||||
|
||||
var kind = ClassifyTool(toolName);
|
||||
|
||||
// On tool result, briefly show then go idle
|
||||
if (phase == "result")
|
||||
kind = ActivityKind.Idle;
|
||||
|
||||
var activity = new AgentActivity
|
||||
{
|
||||
SessionKey = sessionKey,
|
||||
IsMain = isMain,
|
||||
Kind = kind,
|
||||
State = phase,
|
||||
ToolName = toolName,
|
||||
Label = label
|
||||
};
|
||||
|
||||
_logger.Info($"Tool: {toolName} ({phase}) — {label}");
|
||||
ActivityChanged?.Invoke(this, activity);
|
||||
|
||||
// Update tracked session
|
||||
if (kind != ActivityKind.Idle)
|
||||
{
|
||||
UpdateTrackedSession(sessionKey, isMain, $"{activity.Glyph} {label}");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleChatEvent(JsonElement root)
|
||||
{
|
||||
_logger.Info($"Chat event received: {root.GetRawText().Substring(0, Math.Min(200, root.GetRawText().Length))}");
|
||||
|
||||
if (!root.TryGetProperty("payload", out var payload)) return;
|
||||
|
||||
if (payload.TryGetProperty("text", out var textProp))
|
||||
{
|
||||
var text = textProp.GetString() ?? "";
|
||||
if (payload.TryGetProperty("role", out var role) &&
|
||||
role.GetString() == "assistant" &&
|
||||
!string.IsNullOrEmpty(text))
|
||||
{
|
||||
_logger.Info($"Assistant response: {text.Substring(0, Math.Min(100, text.Length))}");
|
||||
// Only notify for short assistant messages (likely alerts/responses)
|
||||
if (text.Length < 500)
|
||||
{
|
||||
EmitNotification(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleSessionEvent(JsonElement root)
|
||||
{
|
||||
// Re-request sessions list when session events come through
|
||||
_ = RequestSessionsAsync();
|
||||
}
|
||||
|
||||
// --- State tracking ---
|
||||
|
||||
private void UpdateTrackedSession(string sessionKey, bool isMain, string? currentActivity)
|
||||
{
|
||||
if (!_sessions.ContainsKey(sessionKey))
|
||||
{
|
||||
_sessions[sessionKey] = new SessionInfo
|
||||
{
|
||||
Key = sessionKey,
|
||||
IsMain = isMain,
|
||||
Status = "active"
|
||||
};
|
||||
}
|
||||
|
||||
_sessions[sessionKey].CurrentActivity = currentActivity;
|
||||
_sessions[sessionKey].LastSeen = DateTime.UtcNow;
|
||||
|
||||
SessionsUpdated?.Invoke(this, GetSessionList());
|
||||
}
|
||||
|
||||
public SessionInfo[] GetSessionList()
|
||||
{
|
||||
var list = new List<SessionInfo>(_sessions.Values);
|
||||
list.Sort((a, b) =>
|
||||
{
|
||||
// Main session first, then by last seen
|
||||
if (a.IsMain != b.IsMain) return a.IsMain ? -1 : 1;
|
||||
return b.LastSeen.CompareTo(a.LastSeen);
|
||||
});
|
||||
return list.ToArray();
|
||||
}
|
||||
|
||||
// --- Parsing helpers ---
|
||||
|
||||
private void ParseChannelHealth(JsonElement channels)
|
||||
{
|
||||
var healthList = new List<ChannelHealth>();
|
||||
|
||||
// Debug: log raw channel data
|
||||
_logger.Info($"Raw channel health JSON: {channels.GetRawText()}");
|
||||
|
||||
foreach (var prop in channels.EnumerateObject())
|
||||
{
|
||||
var ch = new ChannelHealth { Name = prop.Name };
|
||||
var val = prop.Value;
|
||||
|
||||
// Get running status
|
||||
bool isRunning = false;
|
||||
bool isConfigured = false;
|
||||
bool isLinked = false;
|
||||
bool probeOk = false;
|
||||
|
||||
if (val.TryGetProperty("running", out var running))
|
||||
isRunning = running.GetBoolean();
|
||||
if (val.TryGetProperty("configured", out var configured))
|
||||
isConfigured = configured.GetBoolean();
|
||||
if (val.TryGetProperty("linked", out var linked))
|
||||
{
|
||||
isLinked = linked.GetBoolean();
|
||||
ch.IsLinked = isLinked;
|
||||
}
|
||||
// Check probe status for webhook-based channels like Telegram
|
||||
if (val.TryGetProperty("probe", out var probe) && probe.TryGetProperty("ok", out var ok))
|
||||
probeOk = ok.GetBoolean();
|
||||
|
||||
// Determine status string
|
||||
if (val.TryGetProperty("status", out var status))
|
||||
ch.Status = status.GetString() ?? "unknown";
|
||||
else if (isRunning)
|
||||
ch.Status = "running";
|
||||
else if (probeOk && isConfigured)
|
||||
ch.Status = "ready"; // Webhook mode, bot is responding
|
||||
else if (isLinked)
|
||||
ch.Status = "linked"; // Authenticated but not running
|
||||
else if (isConfigured)
|
||||
ch.Status = "stopped";
|
||||
else
|
||||
ch.Status = "not configured";
|
||||
|
||||
if (val.TryGetProperty("error", out var error))
|
||||
ch.Error = error.GetString();
|
||||
if (val.TryGetProperty("authAge", out var authAge))
|
||||
ch.AuthAge = authAge.GetString();
|
||||
if (val.TryGetProperty("type", out var chType))
|
||||
ch.Type = chType.GetString();
|
||||
|
||||
healthList.Add(ch);
|
||||
}
|
||||
|
||||
if (healthList.Count > 0)
|
||||
{
|
||||
_logger.Info($"Channel health: {string.Join(", ", healthList.ConvertAll(c => $"{c.Name}={c.Status}"))}");
|
||||
ChannelHealthUpdated?.Invoke(this, healthList.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseSessions(JsonElement sessions)
|
||||
{
|
||||
try
|
||||
{
|
||||
_sessions.Clear();
|
||||
|
||||
// Handle both Array format and Object (dictionary) format
|
||||
if (sessions.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in sessions.EnumerateArray())
|
||||
{
|
||||
ParseSessionItem(item);
|
||||
}
|
||||
}
|
||||
else if (sessions.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// Object format: keys are session IDs, values could be session info objects or simple strings
|
||||
foreach (var prop in sessions.EnumerateObject())
|
||||
{
|
||||
var sessionKey = prop.Name;
|
||||
|
||||
// Skip metadata fields that aren't actual sessions
|
||||
if (sessionKey is "recent" or "count" or "path" or "defaults" or "ts")
|
||||
continue;
|
||||
|
||||
// Skip non-session keys (must look like a session key pattern)
|
||||
if (!sessionKey.Contains(':') && !sessionKey.Contains("agent") && !sessionKey.Contains("session"))
|
||||
continue;
|
||||
|
||||
var session = new SessionInfo { Key = sessionKey };
|
||||
var item = prop.Value;
|
||||
|
||||
// Detect main session from key pattern - "agent:main:main" ends with ":main"
|
||||
var endsWithMain = sessionKey.EndsWith(":main");
|
||||
session.IsMain = sessionKey == "main" || endsWithMain || sessionKey.Contains(":main:main");
|
||||
_logger.Info($"Session key={sessionKey}, endsWithMain={endsWithMain}, IsMain={session.IsMain}");
|
||||
|
||||
// Value might be an object with session details or just a string status
|
||||
if (item.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// Only override IsMain if the JSON explicitly says true
|
||||
if (item.TryGetProperty("isMain", out var isMain) && isMain.GetBoolean())
|
||||
session.IsMain = true;
|
||||
if (item.TryGetProperty("status", out var status))
|
||||
session.Status = status.GetString() ?? "active";
|
||||
if (item.TryGetProperty("model", out var model))
|
||||
session.Model = model.GetString();
|
||||
if (item.TryGetProperty("channel", out var channel))
|
||||
session.Channel = channel.GetString();
|
||||
if (item.TryGetProperty("startedAt", out var started))
|
||||
{
|
||||
if (DateTime.TryParse(started.GetString(), out var dt))
|
||||
session.StartedAt = dt;
|
||||
}
|
||||
}
|
||||
else if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
// Simple string value - skip if it looks like a path (metadata)
|
||||
var strVal = item.GetString() ?? "";
|
||||
if (strVal.StartsWith("/") || strVal.Contains("/."))
|
||||
continue;
|
||||
session.Status = strVal;
|
||||
}
|
||||
else if (item.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
// Skip numeric values (like count)
|
||||
continue;
|
||||
}
|
||||
|
||||
_sessions[session.Key] = session;
|
||||
}
|
||||
}
|
||||
|
||||
SessionsUpdated?.Invoke(this, GetSessionList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn($"Failed to parse sessions: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseSessionItem(JsonElement item)
|
||||
{
|
||||
var session = new SessionInfo();
|
||||
if (item.TryGetProperty("key", out var key))
|
||||
session.Key = key.GetString() ?? "unknown";
|
||||
|
||||
// Detect main from key pattern first
|
||||
session.IsMain = session.Key == "main" ||
|
||||
session.Key.EndsWith(":main") ||
|
||||
session.Key.Contains(":main:main");
|
||||
|
||||
// Only override if JSON explicitly says true
|
||||
if (item.TryGetProperty("isMain", out var isMain) && isMain.GetBoolean())
|
||||
session.IsMain = true;
|
||||
|
||||
if (item.TryGetProperty("status", out var status))
|
||||
session.Status = status.GetString() ?? "unknown";
|
||||
if (item.TryGetProperty("model", out var model))
|
||||
session.Model = model.GetString();
|
||||
if (item.TryGetProperty("channel", out var channel))
|
||||
session.Channel = channel.GetString();
|
||||
if (item.TryGetProperty("startedAt", out var started))
|
||||
{
|
||||
if (DateTime.TryParse(started.GetString(), out var dt))
|
||||
session.StartedAt = dt;
|
||||
}
|
||||
|
||||
_sessions[session.Key] = session;
|
||||
}
|
||||
|
||||
private void ParseUsage(JsonElement usage)
|
||||
{
|
||||
try
|
||||
{
|
||||
_usage = new GatewayUsageInfo();
|
||||
if (usage.TryGetProperty("inputTokens", out var inp))
|
||||
_usage.InputTokens = inp.GetInt64();
|
||||
if (usage.TryGetProperty("outputTokens", out var outp))
|
||||
_usage.OutputTokens = outp.GetInt64();
|
||||
if (usage.TryGetProperty("totalTokens", out var tot))
|
||||
_usage.TotalTokens = tot.GetInt64();
|
||||
if (usage.TryGetProperty("cost", out var cost))
|
||||
_usage.CostUsd = cost.GetDouble();
|
||||
if (usage.TryGetProperty("requestCount", out var req))
|
||||
_usage.RequestCount = req.GetInt32();
|
||||
if (usage.TryGetProperty("model", out var model))
|
||||
_usage.Model = model.GetString();
|
||||
|
||||
UsageUpdated?.Invoke(this, _usage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn($"Failed to parse usage: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Notification classification ---
|
||||
|
||||
private void EmitNotification(string text)
|
||||
{
|
||||
var (title, type) = ClassifyNotification(text);
|
||||
NotificationReceived?.Invoke(this, new MoltbotNotification
|
||||
{
|
||||
Title = title,
|
||||
Message = text.Length > 200 ? text[..200] + "…" : text,
|
||||
Type = type
|
||||
});
|
||||
}
|
||||
|
||||
private static (string title, string type) ClassifyNotification(string text)
|
||||
{
|
||||
var lower = text.ToLowerInvariant();
|
||||
if (lower.Contains("blood sugar") || lower.Contains("glucose") ||
|
||||
lower.Contains("cgm") || lower.Contains("mg/dl"))
|
||||
return ("🩸 Blood Sugar Alert", "health");
|
||||
if (lower.Contains("urgent") || lower.Contains("critical") ||
|
||||
lower.Contains("emergency"))
|
||||
return ("🚨 Urgent Alert", "urgent");
|
||||
if (lower.Contains("reminder"))
|
||||
return ("⏰ Reminder", "reminder");
|
||||
if (lower.Contains("stock") || lower.Contains("in stock") ||
|
||||
lower.Contains("available now"))
|
||||
return ("📦 Stock Alert", "stock");
|
||||
if (lower.Contains("email") || lower.Contains("inbox") ||
|
||||
lower.Contains("gmail"))
|
||||
return ("📧 Email", "email");
|
||||
if (lower.Contains("calendar") || lower.Contains("meeting") ||
|
||||
lower.Contains("event"))
|
||||
return ("📅 Calendar", "calendar");
|
||||
if (lower.Contains("error") || lower.Contains("failed") ||
|
||||
lower.Contains("exception"))
|
||||
return ("⚠️ Error", "error");
|
||||
if (lower.Contains("build") || lower.Contains("ci ") ||
|
||||
lower.Contains("deploy"))
|
||||
return ("🔨 Build", "build");
|
||||
return ("🤖 Clawdbot", "info");
|
||||
}
|
||||
|
||||
// --- Utility ---
|
||||
|
||||
private static ActivityKind ClassifyTool(string toolName)
|
||||
{
|
||||
return toolName.ToLowerInvariant() switch
|
||||
{
|
||||
"exec" => ActivityKind.Exec,
|
||||
"read" => ActivityKind.Read,
|
||||
"write" => ActivityKind.Write,
|
||||
"edit" => ActivityKind.Edit,
|
||||
"web_search" => ActivityKind.Search,
|
||||
"web_fetch" => ActivityKind.Search,
|
||||
"browser" => ActivityKind.Browser,
|
||||
"message" => ActivityKind.Message,
|
||||
"tts" => ActivityKind.Tool,
|
||||
"image" => ActivityKind.Tool,
|
||||
_ => ActivityKind.Tool
|
||||
};
|
||||
}
|
||||
|
||||
private static string ShortenPath(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return path;
|
||||
var parts = path.Replace('\\', '/').Split('/');
|
||||
return parts.Length > 2
|
||||
? $"…/{parts[^2]}/{parts[^1]}"
|
||||
: parts[^1];
|
||||
}
|
||||
|
||||
private static string TruncateLabel(string text, int maxLen = 60)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || text.Length <= maxLen) return text;
|
||||
return text[..(maxLen - 1)] + "…";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
_cts.Cancel();
|
||||
_webSocket?.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
345
src/Moltbot.Tray/.gitignore
vendored
Normal file
345
src/Moltbot.Tray/.gitignore
vendored
Normal file
@ -0,0 +1,345 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Oo]ut/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these files may be extracted
|
||||
*.azurePubxml
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment the next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
CrystalDecisions.ReportingServices.ViewerObjectModel.dll
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
59
src/Moltbot.Tray/AutoStartManager.cs
Normal file
59
src/Moltbot.Tray/AutoStartManager.cs
Normal file
@ -0,0 +1,59 @@
|
||||
using Microsoft.Win32;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace MoltbotTray;
|
||||
|
||||
public static class AutoStartManager
|
||||
{
|
||||
private const string RegistryKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run";
|
||||
private const string ApplicationName = "MoltbotTray";
|
||||
|
||||
public static void SetAutoStart(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, true);
|
||||
if (key != null)
|
||||
{
|
||||
if (enabled)
|
||||
{
|
||||
var exePath = GetExecutablePath();
|
||||
key.SetValue(ApplicationName, $"\"{exePath}\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
key.DeleteValue(ApplicationName, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"Failed to update auto-start setting: {ex.Message}",
|
||||
"Auto-start Error", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsAutoStartEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath);
|
||||
return key?.GetValue(ApplicationName) != null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetExecutablePath()
|
||||
{
|
||||
// Use ProcessPath for single-file deployments (Assembly.Location is empty)
|
||||
var location = Environment.ProcessPath ?? Application.ExecutablePath;
|
||||
|
||||
return location;
|
||||
}
|
||||
}
|
||||
167
src/Moltbot.Tray/DEVELOPMENT.md
Normal file
167
src/Moltbot.Tray/DEVELOPMENT.md
Normal file
@ -0,0 +1,167 @@
|
||||
# Development Notes
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This Windows system tray application is built with .NET 9 and Windows Forms, designed to be lightweight and efficient while providing seamless integration with the Clawdbot gateway. It mirrors the macOS menu bar app's functionality for Windows users.
|
||||
|
||||
### Component Architecture
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌─────────────────────────┐
|
||||
│ Program │────▶│ TrayApplication │
|
||||
│ (entry) │ │ - System tray icon │
|
||||
│ - Mutex │ │ - Context menu │
|
||||
│ - URI reg │ │ - Event dispatch (UI) │
|
||||
└──────────────┘ │ - Session awareness │
|
||||
└────────┬────────────────┘
|
||||
│ events
|
||||
┌────────▼────────────────┐
|
||||
│ ClawdbotGatewayClient │
|
||||
│ - WebSocket connection │
|
||||
│ - Protocol v3 handshake │
|
||||
│ - Event parsing │
|
||||
│ - Session/usage tracking│
|
||||
│ - Auto-reconnect │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| **Program** | `Program.cs` | Entry point, single-instance mutex, URI scheme registration |
|
||||
| **TrayApplication** | `TrayApplication.cs` | Main `ApplicationContext` managing the tray icon, context menu, and UI event dispatch |
|
||||
| **ClawdbotGatewayClient** | `ClawdbotGatewayClient.cs` | WebSocket client implementing gateway protocol v3 with event parsing, session tracking, and usage monitoring |
|
||||
| **SettingsManager** | `SettingsManager.cs` | JSON-based settings persistence in `%APPDATA%\ClawdbotTray\` |
|
||||
| **SettingsDialog** | `SettingsDialog.cs` | Settings UI with URL/token config, test connection (with timeout), and notification preferences |
|
||||
| **Logger** | `Logger.cs` | Thread-safe file + debug logger with automatic rotation (1MB), writes to `%LOCALAPPDATA%\ClawdbotTray\clawdbot-tray.log` |
|
||||
| **DeepLinkHandler** | `DeepLinkHandler.cs` | `clawdbot://` URI scheme registration and processing for cross-app integration |
|
||||
| **WebChatForm** | `WebChatForm.cs` | WebView2-based chat panel (singleton) with toolbar, fallback to browser |
|
||||
| **QuickSendDialog** | `QuickSendDialog.cs` | Lightweight dialog for sending messages (supports Ctrl+Enter) |
|
||||
| **StatusDetailForm** | `StatusDetailForm.cs` | Rich status view showing gateway connection, sessions, channels, usage, and app info |
|
||||
| **NotificationHistoryForm** | `NotificationHistoryForm.cs` | Scrollable history of received notifications |
|
||||
| **AutoStartManager** | `AutoStartManager.cs` | Windows startup integration via `HKCU\...\Run` registry key |
|
||||
| **GlobalHotkey** | `GlobalHotkey.cs` | System-wide hotkey registration for quick access |
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Gateway → Client**: WebSocket messages parsed into typed events (`agent`, `chat`, `health`, `session`, `usage`)
|
||||
2. **Client → TrayApp**: C# events marshaled to UI thread via `SynchronizationContext.Post`
|
||||
3. **TrayApp → UI**: Context menu items, tray icon, and toast notifications updated
|
||||
|
||||
### Event Types from Gateway
|
||||
|
||||
| Event | Handler | UI Result |
|
||||
|-------|---------|-----------|
|
||||
| `agent` (stream=job) | `HandleJobEvent` | Activity row update, icon badge |
|
||||
| `agent` (stream=tool) | `HandleToolEvent` | Activity row with tool name + args detail |
|
||||
| `chat` | `HandleChatEvent` | Toast notification for short assistant messages |
|
||||
| `health` | `ParseChannelHealth` | Channel health rows in context menu |
|
||||
| `session` | `HandleSessionEvent` | Session list refresh |
|
||||
| `usage` | `ParseUsage` | Usage row (tokens, model, cost) |
|
||||
|
||||
### Notification Classification
|
||||
|
||||
Notifications are classified two ways:
|
||||
|
||||
1. **Structured** (preferred): Events with explicit `type`, `category`, or `notificationType` fields
|
||||
2. **Text-based** (fallback): Keyword matching on notification content (glucose, reminder, stock, email, calendar, etc.)
|
||||
|
||||
### Session Awareness
|
||||
|
||||
The activity display uses a stable session selection algorithm:
|
||||
|
||||
1. Active main session always takes priority
|
||||
2. Currently displayed session is kept if still active (prevents flip-flop)
|
||||
3. Falls back to most recently active sub-session
|
||||
4. 3-second debounce window prevents jitter during rapid activity changes
|
||||
|
||||
### Tray Icon
|
||||
|
||||
The tray icon is a programmatically drawn 16×16 circle:
|
||||
|
||||
- **Teal**: Connected
|
||||
- **Amber**: Connecting
|
||||
- **Red**: Error
|
||||
- **Gray**: Disconnected
|
||||
|
||||
An activity badge (small corner dot) appears during tool execution:
|
||||
- **Orange**: exec (running commands)
|
||||
- **Green**: write/edit (file changes)
|
||||
- **Blue**: read (file access)
|
||||
- **Purple**: search/browser (web activity)
|
||||
|
||||
### Settings Storage
|
||||
|
||||
Settings are stored as JSON in `%APPDATA%\ClawdbotTray\settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"GatewayUrl": "ws://localhost:18789",
|
||||
"Token": "...",
|
||||
"AutoStart": false,
|
||||
"ShowNotifications": true,
|
||||
"NotificationSound": "Default"
|
||||
}
|
||||
```
|
||||
|
||||
### Deep Links
|
||||
|
||||
The app registers `clawdbot://` URI scheme for cross-app integration:
|
||||
|
||||
```
|
||||
clawdbot://agent?message=Hello&key=optional-auth-key
|
||||
```
|
||||
|
||||
Without a key, the user is prompted before sending. With a key, the message is sent directly.
|
||||
|
||||
## Build & Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- .NET 9 SDK
|
||||
- Windows 10 SDK (19041+) — cross-compilation from Linux supported via `EnableWindowsTargeting`
|
||||
- WebView2 Runtime (for chat panel, optional at runtime)
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
dotnet build
|
||||
```
|
||||
|
||||
### Publish (single-file, self-contained)
|
||||
|
||||
```bash
|
||||
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
|
||||
```
|
||||
|
||||
### CI
|
||||
|
||||
GitHub Actions builds on every push. Check status:
|
||||
|
||||
```bash
|
||||
gh run list --repo shanselman/clawdbot-windows-tray --limit 1
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `Microsoft.Toolkit.Uwp.Notifications` (7.1.3) | Toast notifications with rich content |
|
||||
| `Microsoft.Web.WebView2` (1.0.3124.44) | Embedded browser for chat panel |
|
||||
| `System.Text.Json` (9.0.0) | JSON serialization for settings and gateway protocol |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Token storage**: Plaintext in user AppData (future: Windows Credential Manager)
|
||||
- **Deep links**: Untrusted deep links prompt user confirmation
|
||||
- **WebSocket**: Supports both `ws://` (local) and `wss://` (remote)
|
||||
- **Auto-start**: Registry-based, current user only (no elevation needed)
|
||||
- **Logging**: Sensitive data (tokens) not logged
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Toast notifications require Windows 10 1903+
|
||||
- WebView2 Runtime must be installed separately for chat panel
|
||||
- Single-instance enforced via Mutex (deep link forwarding to running instance TODO)
|
||||
- Tray icon tooltip limited to 63 characters (full detail shown in context menu activity row)
|
||||
136
src/Moltbot.Tray/DeepLinkHandler.cs
Normal file
136
src/Moltbot.Tray/DeepLinkHandler.cs
Normal file
@ -0,0 +1,136 @@
|
||||
using Microsoft.Win32;
|
||||
using Moltbot.Shared;
|
||||
using System;
|
||||
using System.Collections.Specialized;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace MoltbotTray;
|
||||
|
||||
/// <summary>
|
||||
/// Handles clawdbot:// URI scheme registration and processing.
|
||||
/// Matches macOS deep link support (clawdbot://agent?message=...)
|
||||
/// </summary>
|
||||
public static class DeepLinkHandler
|
||||
{
|
||||
private const string UriScheme = "Moltbot";
|
||||
private const string FriendlyName = "Clawdbot Agent Command";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the clawdbot:// URI scheme in the Windows registry.
|
||||
/// Requires elevation for HKCR, falls back to HKCU.
|
||||
/// </summary>
|
||||
public static void RegisterUriScheme()
|
||||
{
|
||||
try
|
||||
{
|
||||
var exePath = Environment.ProcessPath ?? Application.ExecutablePath;
|
||||
|
||||
// Try HKCU\Software\Classes (no elevation needed)
|
||||
using var key = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{UriScheme}");
|
||||
if (key == null) return;
|
||||
|
||||
key.SetValue("", $"URL:{FriendlyName}");
|
||||
key.SetValue("URL Protocol", "");
|
||||
|
||||
using var iconKey = key.CreateSubKey("DefaultIcon");
|
||||
iconKey?.SetValue("", $"\"{exePath}\",1");
|
||||
|
||||
using var commandKey = key.CreateSubKey(@"shell\open\command");
|
||||
commandKey?.SetValue("", $"\"{exePath}\" \"%1\"");
|
||||
|
||||
Logger.Info($"Registered URI scheme: {UriScheme}://");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to register URI scheme", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the app was launched with a deep link argument.
|
||||
/// </summary>
|
||||
public static bool TryGetDeepLink(string[] args, out Uri? uri)
|
||||
{
|
||||
uri = null;
|
||||
if (args.Length == 0) return false;
|
||||
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (arg.StartsWith($"{UriScheme}://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
uri = new Uri(arg);
|
||||
return true;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a clawdbot:// deep link.
|
||||
/// Supports: clawdbot://agent?message=...&sessionKey=...&channel=...
|
||||
/// </summary>
|
||||
public static async Task ProcessDeepLinkAsync(Uri uri, MoltbotGatewayClient client)
|
||||
{
|
||||
Logger.Info($"Processing deep link: {uri}");
|
||||
|
||||
var host = uri.Host.ToLowerInvariant();
|
||||
var query = HttpUtility.ParseQueryString(uri.Query);
|
||||
|
||||
switch (host)
|
||||
{
|
||||
case "agent":
|
||||
await HandleAgentDeepLinkAsync(query, client);
|
||||
break;
|
||||
default:
|
||||
Logger.Warn($"Unknown deep link host: {host}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleAgentDeepLinkAsync(NameValueCollection query, MoltbotGatewayClient client)
|
||||
{
|
||||
var message = query["message"];
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
Logger.Warn("Deep link: missing message parameter");
|
||||
return;
|
||||
}
|
||||
|
||||
var key = query["key"];
|
||||
var hasKey = !string.IsNullOrEmpty(key);
|
||||
|
||||
// Without a key, prompt for confirmation (safety)
|
||||
if (!hasKey)
|
||||
{
|
||||
var preview = message.Length > 100 ? message[..100] + "…" : message;
|
||||
var result = MessageBox.Show(
|
||||
$"A deep link wants to send this message to Clawdbot:\n\n\"{preview}\"\n\nAllow?",
|
||||
"Clawdbot Deep Link",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Question);
|
||||
|
||||
if (result != DialogResult.Yes)
|
||||
{
|
||||
Logger.Info("Deep link: user declined");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await client.SendChatMessageAsync(message);
|
||||
Logger.Info($"Deep link: sent message ({message.Length} chars)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Deep link: failed to send", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
src/Moltbot.Tray/GlobalHotkey.cs
Normal file
93
src/Moltbot.Tray/GlobalHotkey.cs
Normal file
@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace MoltbotTray;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a system-wide hotkey that works even when the app is not focused.
|
||||
/// Default: Ctrl+Shift+Space to open Quick Send.
|
||||
/// </summary>
|
||||
public class GlobalHotkey : IDisposable
|
||||
{
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
|
||||
|
||||
private const int HotkeyId = 9001;
|
||||
private const uint MOD_CONTROL = 0x0002;
|
||||
private const uint MOD_SHIFT = 0x0004;
|
||||
private const uint VK_SPACE = 0x20;
|
||||
|
||||
private readonly HotkeyWindow _window;
|
||||
private bool _registered;
|
||||
|
||||
public event EventHandler? HotkeyPressed;
|
||||
|
||||
public GlobalHotkey()
|
||||
{
|
||||
_window = new HotkeyWindow(this);
|
||||
}
|
||||
|
||||
public bool Register()
|
||||
{
|
||||
try
|
||||
{
|
||||
_registered = RegisterHotKey(_window.Handle, HotkeyId, MOD_CONTROL | MOD_SHIFT, VK_SPACE);
|
||||
if (_registered)
|
||||
Logger.Info("Global hotkey registered: Ctrl+Shift+Space");
|
||||
else
|
||||
Logger.Warn("Failed to register global hotkey (may be in use by another app)");
|
||||
return _registered;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Hotkey registration error", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_registered)
|
||||
{
|
||||
UnregisterHotKey(_window.Handle, HotkeyId);
|
||||
_registered = false;
|
||||
}
|
||||
_window.Dispose();
|
||||
}
|
||||
|
||||
internal void OnHotkeyPressed()
|
||||
{
|
||||
HotkeyPressed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private class HotkeyWindow : NativeWindow, IDisposable
|
||||
{
|
||||
private const int WM_HOTKEY = 0x0312;
|
||||
private readonly GlobalHotkey _owner;
|
||||
|
||||
public HotkeyWindow(GlobalHotkey owner)
|
||||
{
|
||||
_owner = owner;
|
||||
CreateHandle(new CreateParams());
|
||||
}
|
||||
|
||||
protected override void WndProc(ref Message m)
|
||||
{
|
||||
if (m.Msg == WM_HOTKEY && m.WParam.ToInt32() == HotkeyId)
|
||||
{
|
||||
_owner.OnHotkeyPressed();
|
||||
}
|
||||
base.WndProc(ref m);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DestroyHandle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
45
src/Moltbot.Tray/ISSUE_TEMPLATE/bug_report.md
Normal file
45
src/Moltbot.Tray/ISSUE_TEMPLATE/bug_report.md
Normal file
@ -0,0 +1,45 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**System Information:**
|
||||
- Windows version: [e.g. Windows 10 21H2, Windows 11 22H2]
|
||||
- .NET version (if known): [e.g. .NET 10.0.1]
|
||||
- Clawdbot version: [e.g. 1.2.3]
|
||||
- Gateway URL: [e.g. ws://localhost:18789 or remote]
|
||||
|
||||
**Gateway Information:**
|
||||
- Is Clawdbot gateway running? [Yes/No]
|
||||
- Gateway location: [WSL2, remote server, etc.]
|
||||
- Can you connect to gateway from browser? [Yes/No]
|
||||
- Gateway logs (if available): [paste relevant logs]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Configuration**
|
||||
- Auto-start enabled: [Yes/No]
|
||||
- Notifications enabled: [Yes/No]
|
||||
- First time setup: [Yes/No]
|
||||
- Custom gateway URL: [Yes/No]
|
||||
32
src/Moltbot.Tray/ISSUE_TEMPLATE/feature_request.md
Normal file
32
src/Moltbot.Tray/ISSUE_TEMPLATE/feature_request.md
Normal file
@ -0,0 +1,32 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Use case**
|
||||
Describe how this feature would be used and who would benefit from it.
|
||||
|
||||
**Implementation ideas**
|
||||
If you have ideas about how this could be implemented, please share them.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
**Priority**
|
||||
How important is this feature to you?
|
||||
- [ ] Nice to have
|
||||
- [ ] Would be very useful
|
||||
- [ ] Critical for my workflow
|
||||
95
src/Moltbot.Tray/IconHelper.cs
Normal file
95
src/Moltbot.Tray/IconHelper.cs
Normal file
@ -0,0 +1,95 @@
|
||||
using System.Drawing;
|
||||
|
||||
namespace MoltbotTray;
|
||||
|
||||
/// <summary>
|
||||
/// Shared icon helper for creating the lobster icon used throughout the app.
|
||||
/// </summary>
|
||||
public static class IconHelper
|
||||
{
|
||||
private static Icon? _cachedLobsterIcon;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the lobster icon for use in forms and windows.
|
||||
/// </summary>
|
||||
public static Icon GetLobsterIcon()
|
||||
{
|
||||
if (_cachedLobsterIcon != null)
|
||||
return _cachedLobsterIcon;
|
||||
|
||||
var bitmap = new Bitmap(16, 16);
|
||||
using (var g = Graphics.FromImage(bitmap))
|
||||
{
|
||||
g.Clear(Color.Transparent);
|
||||
DrawPixelLobster(g);
|
||||
}
|
||||
|
||||
var hIcon = bitmap.GetHicon();
|
||||
_cachedLobsterIcon = Icon.FromHandle(hIcon);
|
||||
return _cachedLobsterIcon;
|
||||
}
|
||||
|
||||
private static void DrawPixelLobster(Graphics g)
|
||||
{
|
||||
// Pixel lobster from SVG - 16x16 pixel art
|
||||
var outline = Color.FromArgb(58, 10, 13); // #3a0a0d - dark outline
|
||||
var body = Color.FromArgb(255, 79, 64); // #ff4f40 - red body
|
||||
var claw = Color.FromArgb(255, 119, 95); // #ff775f - lighter claws
|
||||
var eyeDark = Color.FromArgb(8, 16, 22); // #081016 - pupils
|
||||
var eyeLight = Color.FromArgb(245, 251, 255); // #f5fbff - eye whites
|
||||
|
||||
// Outline (dark border)
|
||||
var outlinePixels = new[] {
|
||||
(1,5), (1,6), (1,7),
|
||||
(2,4), (2,8),
|
||||
(3,3), (3,9),
|
||||
(4,2), (4,10),
|
||||
(5,2), (6,2), (7,2), (8,2), (9,2), (10,2),
|
||||
(11,2), (12,3), (12,9),
|
||||
(13,4), (13,8),
|
||||
(14,5), (14,6), (14,7),
|
||||
(5,11), (6,11), (7,11), (8,11), (9,11), (10,11),
|
||||
(4,12), (11,12),
|
||||
(3,13), (12,13),
|
||||
(5,14), (6,14), (7,14), (8,14), (9,14), (10,14)
|
||||
};
|
||||
foreach (var (x, y) in outlinePixels)
|
||||
SetPixel(g, x, y, outline);
|
||||
|
||||
// Body (red)
|
||||
var bodyPixels = new[] {
|
||||
(5,3), (6,3), (7,3), (8,3), (9,3), (10,3),
|
||||
(4,4), (5,4), (7,4), (8,4), (10,4), (11,4),
|
||||
(3,5), (4,5), (5,5), (7,5), (8,5), (10,5), (11,5), (12,5),
|
||||
(3,6), (4,6), (5,6), (6,6), (7,6), (8,6), (9,6), (10,6), (11,6), (12,6),
|
||||
(3,7), (4,7), (5,7), (6,7), (7,7), (8,7), (9,7), (10,7), (11,7), (12,7),
|
||||
(4,8), (5,8), (6,8), (7,8), (8,8), (9,8), (10,8), (11,8),
|
||||
(5,9), (6,9), (7,9), (8,9), (9,9), (10,9),
|
||||
(5,12), (6,12), (7,12), (8,12), (9,12), (10,12),
|
||||
(6,13), (7,13), (8,13), (9,13)
|
||||
};
|
||||
foreach (var (x, y) in bodyPixels)
|
||||
SetPixel(g, x, y, body);
|
||||
|
||||
// Claws (lighter red)
|
||||
var clawPixels = new[] {
|
||||
(1,6), (2,5), (2,6), (2,7),
|
||||
(13,5), (13,6), (13,7), (14,6)
|
||||
};
|
||||
foreach (var (x, y) in clawPixels)
|
||||
SetPixel(g, x, y, claw);
|
||||
|
||||
// Eyes
|
||||
SetPixel(g, 6, 4, eyeLight);
|
||||
SetPixel(g, 9, 4, eyeLight);
|
||||
SetPixel(g, 6, 5, eyeDark);
|
||||
SetPixel(g, 9, 5, eyeDark);
|
||||
}
|
||||
|
||||
private static void SetPixel(Graphics g, int x, int y, Color c)
|
||||
{
|
||||
using var brush = new SolidBrush(c);
|
||||
g.FillRectangle(brush, x, y, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
21
src/Moltbot.Tray/LICENSE
Normal file
21
src/Moltbot.Tray/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Scott Hanselman
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
106
src/Moltbot.Tray/Logger.cs
Normal file
106
src/Moltbot.Tray/Logger.cs
Normal file
@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using Moltbot.Shared;
|
||||
|
||||
namespace MoltbotTray;
|
||||
|
||||
/// <summary>
|
||||
/// Simple file + debug logger for troubleshooting.
|
||||
/// Writes to %LOCALAPPDATA%\MoltbotTray\clawdbot-tray.log
|
||||
/// </summary>
|
||||
public static class Logger
|
||||
{
|
||||
private static readonly string LogDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"MoltbotTray");
|
||||
private static readonly string LogPath = Path.Combine(LogDir, "clawdbot-tray.log");
|
||||
private static readonly object Lock = new();
|
||||
private static bool _initialized;
|
||||
private static StreamWriter? _writer;
|
||||
|
||||
/// <summary>Get a logger instance that implements IMoltbotLogger for the shared library.</summary>
|
||||
public static IMoltbotLogger Instance { get; } = new LoggerAdapter();
|
||||
|
||||
public static void Info(string message) => Write("INFO", message);
|
||||
public static void Warn(string message) => Write("WARN", message);
|
||||
public static void Error(string message) => Write("ERROR", message);
|
||||
public static void Error(string message, Exception ex) => Write("ERROR", $"{message}: {ex.Message}\n Stack: {ex.StackTrace}");
|
||||
|
||||
/// <summary>Flush and close the log file (call on app exit).</summary>
|
||||
public static void Shutdown()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_writer?.Flush();
|
||||
_writer?.Dispose();
|
||||
_writer = null;
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureInitialized()
|
||||
{
|
||||
if (_initialized) return;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(LogDir);
|
||||
RotateIfNeeded();
|
||||
_writer = new StreamWriter(LogPath, append: true) { AutoFlush = true };
|
||||
_initialized = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Can't init — fall back to Debug.WriteLine only
|
||||
}
|
||||
}
|
||||
|
||||
private static void RotateIfNeeded()
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(LogPath);
|
||||
if (info.Exists && info.Length > 1_048_576)
|
||||
{
|
||||
var backup = LogPath + ".1";
|
||||
if (File.Exists(backup)) File.Delete(backup);
|
||||
File.Move(LogPath, backup);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void Write(string level, string message)
|
||||
{
|
||||
var line = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] [{level}] {message}";
|
||||
Debug.WriteLine(line);
|
||||
|
||||
try
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
EnsureInitialized();
|
||||
_writer?.WriteLine(line);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Don't crash if we can't write logs
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adapter to make the static Logger work with IMoltbotLogger interface.</summary>
|
||||
private class LoggerAdapter : IMoltbotLogger
|
||||
{
|
||||
public void Info(string message) => Logger.Info(message);
|
||||
public void Warn(string message) => Logger.Warn(message);
|
||||
public void Error(string message, Exception? ex = null)
|
||||
{
|
||||
if (ex != null)
|
||||
Logger.Error(message, ex);
|
||||
else
|
||||
Logger.Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
src/Moltbot.Tray/Moltbot.Tray.csproj
Normal file
39
src/Moltbot.Tray/Moltbot.Tray.csproj
Normal file
@ -0,0 +1,39 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<NoWarn>MSB3277</NoWarn>
|
||||
<ApplicationIcon>moltbot.ico</ApplicationIcon>
|
||||
<AssemblyTitle>Moltbot Windows Tray</AssemblyTitle>
|
||||
<AssemblyDescription>Windows system tray companion app for Moltbot</AssemblyDescription>
|
||||
<AssemblyCompany>Scott Hanselman</AssemblyCompany>
|
||||
<AssemblyProduct>Moltbot Tray</AssemblyProduct>
|
||||
<Copyright>Copyright © 2026 Scott Hanselman</Copyright>
|
||||
<Version>1.0.0</Version>
|
||||
<FileVersion>1.0.0</FileVersion>
|
||||
<AssemblyVersion>1.0.0</AssemblyVersion>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
<!-- RuntimeIdentifier set at publish time: win-x64 or win-arm64 -->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Moltbot.Shared\Moltbot.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
|
||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3124.44" />
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Icons\*.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
161
src/Moltbot.Tray/NotificationHistoryForm.cs
Normal file
161
src/Moltbot.Tray/NotificationHistoryForm.cs
Normal file
@ -0,0 +1,161 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace MoltbotTray;
|
||||
|
||||
/// <summary>
|
||||
/// Shows recent notification history in a simple list view.
|
||||
/// </summary>
|
||||
public class NotificationHistoryForm : Form
|
||||
{
|
||||
private ListView? _listView;
|
||||
private Button _clearButton = null!;
|
||||
private Button _closeButton = null!;
|
||||
private static NotificationHistoryForm? _instance;
|
||||
|
||||
private static readonly List<NotificationEntry> _history = new();
|
||||
private const int MaxHistory = 200;
|
||||
|
||||
public static void AddEntry(string title, string message, string type)
|
||||
{
|
||||
lock (_history)
|
||||
{
|
||||
_history.Add(new NotificationEntry
|
||||
{
|
||||
Timestamp = DateTime.Now,
|
||||
Title = title,
|
||||
Message = message,
|
||||
Type = type
|
||||
});
|
||||
|
||||
// Trim old entries
|
||||
while (_history.Count > MaxHistory)
|
||||
_history.RemoveAt(0);
|
||||
}
|
||||
|
||||
// If window is open, refresh it
|
||||
_instance?.RefreshList();
|
||||
}
|
||||
|
||||
public static void ShowOrFocus()
|
||||
{
|
||||
if (_instance != null && !_instance.IsDisposed)
|
||||
{
|
||||
_instance.BringToFront();
|
||||
_instance.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
_instance = new NotificationHistoryForm();
|
||||
_instance.Show();
|
||||
}
|
||||
|
||||
private NotificationHistoryForm()
|
||||
{
|
||||
InitializeComponent();
|
||||
RefreshList();
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
Text = "Notification History — Moltbot Tray";
|
||||
Size = new Size(600, 450);
|
||||
MinimumSize = new Size(400, 300);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
Icon = IconHelper.GetLobsterIcon();
|
||||
|
||||
_listView = new ListView
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
View = View.Details,
|
||||
FullRowSelect = true,
|
||||
GridLines = true,
|
||||
Font = new Font("Segoe UI", 9F)
|
||||
};
|
||||
_listView.Columns.Add("Time", 130);
|
||||
_listView.Columns.Add("Type", 80);
|
||||
_listView.Columns.Add("Title", 150);
|
||||
_listView.Columns.Add("Message", 300);
|
||||
|
||||
var buttonPanel = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Bottom,
|
||||
Height = 40,
|
||||
FlowDirection = FlowDirection.RightToLeft,
|
||||
Padding = new Padding(5)
|
||||
};
|
||||
|
||||
_closeButton = new Button
|
||||
{
|
||||
Text = "&Close",
|
||||
Size = new Size(75, 26),
|
||||
Font = new Font("Segoe UI", 9F)
|
||||
};
|
||||
_closeButton.Click += (_, _) => Close();
|
||||
|
||||
_clearButton = new Button
|
||||
{
|
||||
Text = "C&lear All",
|
||||
Size = new Size(85, 26),
|
||||
Font = new Font("Segoe UI", 9F)
|
||||
};
|
||||
_clearButton.Click += (_, _) =>
|
||||
{
|
||||
lock (_history) _history.Clear();
|
||||
RefreshList();
|
||||
};
|
||||
|
||||
buttonPanel.Controls.Add(_closeButton);
|
||||
buttonPanel.Controls.Add(_clearButton);
|
||||
|
||||
Controls.Add(_listView);
|
||||
Controls.Add(buttonPanel);
|
||||
}
|
||||
|
||||
private void RefreshList()
|
||||
{
|
||||
if (_listView == null || _listView.IsDisposed) return;
|
||||
|
||||
if (InvokeRequired)
|
||||
{
|
||||
Invoke(new Action(RefreshList));
|
||||
return;
|
||||
}
|
||||
|
||||
_listView.BeginUpdate();
|
||||
_listView.Items.Clear();
|
||||
|
||||
lock (_history)
|
||||
{
|
||||
// Show newest first
|
||||
for (int i = _history.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var entry = _history[i];
|
||||
var item = new ListViewItem(entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"));
|
||||
item.SubItems.Add(entry.Type);
|
||||
item.SubItems.Add(entry.Title);
|
||||
item.SubItems.Add(entry.Message.Replace('\n', ' '));
|
||||
_listView.Items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
_listView.EndUpdate();
|
||||
}
|
||||
|
||||
protected override void OnFormClosed(FormClosedEventArgs e)
|
||||
{
|
||||
_instance = null;
|
||||
base.OnFormClosed(e);
|
||||
}
|
||||
|
||||
private class NotificationEntry
|
||||
{
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string Title { get; set; } = "";
|
||||
public string Message { get; set; } = "";
|
||||
public string Type { get; set; } = "";
|
||||
}
|
||||
}
|
||||
|
||||
34
src/Moltbot.Tray/Program.cs
Normal file
34
src/Moltbot.Tray/Program.cs
Normal file
@ -0,0 +1,34 @@
|
||||
using MoltbotTray;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace MoltbotTray;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
static void Main(string[] args)
|
||||
{
|
||||
// Single instance check
|
||||
using var mutex = new Mutex(true, "MoltbotTray", out bool createdNew);
|
||||
if (!createdNew)
|
||||
{
|
||||
// TODO: Forward deep link args to running instance via named pipe
|
||||
MessageBox.Show("Moltbot Tray is already running.", "Moltbot Tray",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
// Register URI scheme on first run
|
||||
DeepLinkHandler.RegisterUriScheme();
|
||||
|
||||
Application.SetHighDpiMode(HighDpiMode.SystemAware);
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
|
||||
var trayApp = new TrayApplication(args);
|
||||
Application.Run(trayApp);
|
||||
}
|
||||
}
|
||||
|
||||
135
src/Moltbot.Tray/QuickSendDialog.cs
Normal file
135
src/Moltbot.Tray/QuickSendDialog.cs
Normal file
@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace MoltbotTray;
|
||||
|
||||
public partial class QuickSendDialog : Form
|
||||
{
|
||||
private TextBox _messageTextBox = null!;
|
||||
private Button _sendButton = null!;
|
||||
private Button _cancelButton = null!;
|
||||
private Label _hintLabel = null!;
|
||||
|
||||
public string Message => _messageTextBox.Text;
|
||||
|
||||
public QuickSendDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
// Form properties
|
||||
Text = "Quick Send — Clawdbot";
|
||||
Size = new Size(500, 220);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
ShowInTaskbar = true;
|
||||
TopMost = true; // Always on top when opened via hotkey
|
||||
Icon = IconHelper.GetLobsterIcon();
|
||||
|
||||
// Label
|
||||
var label = new Label
|
||||
{
|
||||
Text = "Send a message to Clawdbot:",
|
||||
Location = new Point(12, 12),
|
||||
Size = new Size(460, 20),
|
||||
Font = new Font("Segoe UI", 9.5F, FontStyle.Regular)
|
||||
};
|
||||
|
||||
// Message text box
|
||||
_messageTextBox = new TextBox
|
||||
{
|
||||
Location = new Point(12, 36),
|
||||
Size = new Size(460, 90),
|
||||
Multiline = true,
|
||||
ScrollBars = ScrollBars.Vertical,
|
||||
Font = new Font("Segoe UI", 10F, FontStyle.Regular),
|
||||
AcceptsReturn = false // Enter sends, Shift+Enter for newline
|
||||
};
|
||||
|
||||
// Hint label
|
||||
_hintLabel = new Label
|
||||
{
|
||||
Text = "Enter to send · Esc to cancel · Shift+Enter for new line",
|
||||
Location = new Point(12, 132),
|
||||
Size = new Size(300, 18),
|
||||
Font = new Font("Segoe UI", 8F, FontStyle.Regular),
|
||||
ForeColor = Color.Gray
|
||||
};
|
||||
|
||||
// Send button
|
||||
_sendButton = new Button
|
||||
{
|
||||
Text = "&Send",
|
||||
Location = new Point(316, 148),
|
||||
Size = new Size(75, 28),
|
||||
UseVisualStyleBackColor = true,
|
||||
Font = new Font("Segoe UI", 9F, FontStyle.Regular)
|
||||
};
|
||||
_sendButton.Click += OnSendClick;
|
||||
|
||||
// Cancel button
|
||||
_cancelButton = new Button
|
||||
{
|
||||
Text = "&Cancel",
|
||||
Location = new Point(397, 148),
|
||||
Size = new Size(75, 28),
|
||||
UseVisualStyleBackColor = true,
|
||||
Font = new Font("Segoe UI", 9F, FontStyle.Regular)
|
||||
};
|
||||
_cancelButton.Click += OnCancelClick;
|
||||
|
||||
// Set dialog buttons
|
||||
AcceptButton = _sendButton;
|
||||
CancelButton = _cancelButton;
|
||||
|
||||
// Add controls
|
||||
Controls.Add(label);
|
||||
Controls.Add(_messageTextBox);
|
||||
Controls.Add(_hintLabel);
|
||||
Controls.Add(_sendButton);
|
||||
Controls.Add(_cancelButton);
|
||||
|
||||
// Focus the text box on show
|
||||
Shown += (_, _) =>
|
||||
{
|
||||
_messageTextBox.Focus();
|
||||
Activate(); // Ensure window is focused when opened via hotkey
|
||||
};
|
||||
}
|
||||
|
||||
private void OnSendClick(object? sender, EventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_messageTextBox.Text))
|
||||
{
|
||||
_messageTextBox.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnCancelClick(object? sender, EventArgs e)
|
||||
{
|
||||
DialogResult = DialogResult.Cancel;
|
||||
Close();
|
||||
}
|
||||
|
||||
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
||||
{
|
||||
// Ctrl+Enter or Enter (without Shift) as send
|
||||
if (keyData == (Keys.Control | Keys.Enter) || keyData == Keys.Enter)
|
||||
{
|
||||
OnSendClick(null, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
return base.ProcessCmdKey(ref msg, keyData);
|
||||
}
|
||||
}
|
||||
|
||||
225
src/Moltbot.Tray/README.md
Normal file
225
src/Moltbot.Tray/README.md
Normal file
@ -0,0 +1,225 @@
|
||||
# Clawdbot Windows Tray
|
||||
|
||||
A Windows system tray companion for [Clawdbot](https://github.com/clawdbot/clawdbot) — the Windows equivalent of the macOS menu bar app. Provides desktop notifications, embedded chat, live agent activity monitoring, and gateway status tracking.
|
||||
|
||||
## Features
|
||||
|
||||
### System Tray
|
||||
- **Lobster icon** 🦞 when connected (pixel art), color-coded circles for other states
|
||||
- **Activity badge** showing what the agent is doing (exec, read, write, edit, search, browser, message, tool)
|
||||
- **Context menu** with status, sessions, channels, usage, quick send, settings, and auto-start
|
||||
- **Text status labels** `[ON]`/`[OFF]`/`[READY]`/`[LINKED]` for clarity
|
||||
- **Clickable status** opens detailed status view
|
||||
- **Double-click** opens embedded web chat
|
||||
- **Open Dashboard** opens browser with authenticated session
|
||||
|
||||
### Session Awareness
|
||||
- **Live session tracking** — see main and sub-sessions in real-time
|
||||
- **Session detail** — model, channel, current activity per session
|
||||
- **Activity display** — "Main · 💻 pnpm test" or "Sub · 📄 reading file"
|
||||
|
||||
### Usage & Context
|
||||
- **Token usage** display (input/output/total with human-readable formatting)
|
||||
- **Cost tracking** when available from gateway
|
||||
- **Request count** and active model
|
||||
|
||||
### WebChat Panel
|
||||
- **Embedded chat** via WebView2 — no browser needed
|
||||
- **Dark mode** background
|
||||
- Toolbar with home, refresh, pop-out to browser, and DevTools
|
||||
- Singleton window (double-click tray icon or "Open Web Chat" menu)
|
||||
|
||||
### Notifications
|
||||
- **Windows toast notifications** with per-type filtering:
|
||||
- 🩸 Health / CGM alerts
|
||||
- 🚨 Urgent / error alerts
|
||||
- ⏰ Reminders
|
||||
- 📧 Email notifications
|
||||
- 📅 Calendar events
|
||||
- 🔨 Build / CI
|
||||
- 📦 Stock availability
|
||||
- 🤖 General info
|
||||
- **Clickable toasts** — Quick Send toasts open dashboard when clicked
|
||||
- **Notification history** — scrollable list with timestamps, even for filtered-out notifications
|
||||
- Fallback to balloon tips if toast fails
|
||||
|
||||
### Channel Health
|
||||
- Live WhatsApp, Telegram, and other channel status
|
||||
- Smart status detection: `[READY]` (probe OK), `[LINKED]` (authenticated), `[ON]`/`[OFF]`
|
||||
- Shows linked state, auth age, errors, and stale warnings
|
||||
- On-demand health check button
|
||||
|
||||
### Keyboard Shortcuts
|
||||
- **Ctrl+Shift+Space** — Global hotkey to open Quick Send from anywhere
|
||||
- **Enter** — Send message in Quick Send dialog
|
||||
- **Shift+Enter** — New line in Quick Send
|
||||
- **Esc** — Cancel Quick Send
|
||||
|
||||
### Deep Links
|
||||
- Registers `clawdbot://` URI scheme
|
||||
- `clawdbot://agent?message=Hello` sends a message to the agent
|
||||
- Confirmation prompt for safety (bypass with `key` parameter)
|
||||
|
||||
### Quality of Life
|
||||
- **ARM64 support** — native builds for Windows on ARM
|
||||
- **Auto-start** via Windows Registry
|
||||
- **Exponential backoff** on reconnect (1s → 60s)
|
||||
- **File logging** to `%LOCALAPPDATA%\ClawdbotTray\clawdbot-tray.log` (with rotation at 1MB)
|
||||
- **Open Log File** menu item for quick debugging
|
||||
- **Single instance** enforcement (mutex)
|
||||
- **Proper GDI handle cleanup** (no icon leaks)
|
||||
- **Status detail view** — rich dark-themed status panel
|
||||
|
||||
## Requirements
|
||||
|
||||
- Windows 10 version 1903+ (for toast notifications)
|
||||
- .NET 9 Runtime (included in self-contained builds)
|
||||
- [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) (for chat panel)
|
||||
- Clawdbot gateway running (typically in WSL2)
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Download the latest release from [Releases](https://github.com/shanselman/clawdbot-windows-tray/releases)
|
||||
- **x64**: For Intel/AMD processors
|
||||
- **arm64**: For Windows on ARM (e.g., Surface Pro X, Snapdragon laptops)
|
||||
2. Run `ClawdbotTray.exe`
|
||||
3. Right-click tray icon → Settings
|
||||
4. Enter gateway URL (`ws://localhost:18789`) and your token
|
||||
5. Done — you'll see the icon turn green when connected
|
||||
|
||||
### Finding Your Gateway Token
|
||||
|
||||
```bash
|
||||
# In WSL2:
|
||||
cat ~/.clawdbot/clawdbot.json | grep token
|
||||
# Or:
|
||||
clawdbot config get gateway.auth.token
|
||||
```
|
||||
|
||||
## Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shanselman/clawdbot-windows-tray.git
|
||||
cd clawdbot-windows-tray
|
||||
|
||||
# Windows — auto-detects architecture
|
||||
build.bat
|
||||
|
||||
# Manual build
|
||||
dotnet build -c Release -r win-x64
|
||||
dotnet build -c Release -r win-arm64
|
||||
|
||||
# Self-contained single-file executable
|
||||
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -o publish
|
||||
dotnet publish -c Release -r win-arm64 --self-contained -p:PublishSingleFile=true -o publish-arm64
|
||||
|
||||
# Cross-compile from Linux (for CI)
|
||||
dotnet build -p:EnableWindowsTargeting=true -r win-x64
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── Program.cs # Entry point, single instance, deep link registration
|
||||
├── TrayApplication.cs # Tray icon, menu, event wiring, UI updates
|
||||
├── ClawdbotGatewayClient.cs # WebSocket client, protocol v3, event parsing, state tracking
|
||||
├── WebChatForm.cs # WebView2 chat panel (singleton, dark mode)
|
||||
├── QuickSendDialog.cs # Quick message input (Enter to send, TopMost)
|
||||
├── StatusDetailForm.cs # Rich status detail view (dark theme)
|
||||
├── NotificationHistoryForm.cs # Scrollable notification history
|
||||
├── GlobalHotkey.cs # Ctrl+Shift+Space system-wide hotkey
|
||||
├── DeepLinkHandler.cs # clawdbot:// URI scheme handler
|
||||
├── SettingsManager.cs # JSON config with notification filters
|
||||
├── SettingsDialog.cs # Settings UI (connection, startup, notification filters)
|
||||
├── AutoStartManager.cs # Windows Registry auto-start
|
||||
├── Logger.cs # File + debug logger with rotation
|
||||
└── ClawdbotTray.csproj # .NET 9, Windows Forms, WebView2
|
||||
```
|
||||
|
||||
## macOS Parity Status
|
||||
|
||||
This Windows tray app aims for feature parity with the [Clawdbot macOS menu bar app](https://github.com/clawdbot/clawdbot-macos).
|
||||
|
||||
| Feature | macOS | Windows | Notes |
|
||||
|---------|:-----:|:-------:|-------|
|
||||
| System tray/menu bar icon | ✅ | ✅ | Lobster 🦞 when connected |
|
||||
| Status colors/indicators | ✅ | ✅ | Text labels `[ON]/[OFF]` for clarity |
|
||||
| Activity badges | ✅ | ✅ | exec/read/write/search/browser |
|
||||
| Toast/native notifications | ✅ | ✅ | Windows toast + fallback |
|
||||
| Per-type notification filters | ✅ | ✅ | Health, urgent, email, etc. |
|
||||
| Clickable notifications | ✅ | ✅ | Opens dashboard with auth |
|
||||
| Notification history | — | ✅ | Windows-only feature |
|
||||
| Embedded chat (WebView) | ✅ | ✅ | WebView2 |
|
||||
| Open Dashboard in browser | ✅ | ✅ | Token auto-included |
|
||||
| Channel health display | ✅ | ✅ | Telegram, WhatsApp status |
|
||||
| Session awareness (main/sub) | ✅ | ✅ | Live session tracking |
|
||||
| Usage/token display | ✅ | ✅ | Input/output/total |
|
||||
| Deep link URI scheme | ✅ | ✅ | `clawdbot://` |
|
||||
| Global hotkey | — | ✅ | Ctrl+Shift+Space |
|
||||
| Auto-start | ✅ | ✅ | Registry-based |
|
||||
| Quick send | ✅ | ✅ | Fire-and-forget to main session |
|
||||
| Health check (on-demand) | ✅ | ✅ | |
|
||||
| Status detail view | — | ✅ | Windows-only feature |
|
||||
| File logging | ✅ | ✅ | With rotation |
|
||||
| ARM64 support | ✅ | ✅ | Apple Silicon / Windows ARM |
|
||||
| Canvas panel | ✅ | 🔜 | Planned |
|
||||
| Voice wake / push-to-talk | ✅ | 🔜 | Planned |
|
||||
| Skills settings UI | ✅ | 🔜 | Planned |
|
||||
| TCC permissions management | ✅ | N/A | macOS-specific |
|
||||
| PeekabooBridge (UI automation) | ✅ | N/A | macOS-specific |
|
||||
| XPC / node host service | ✅ | N/A | macOS-specific |
|
||||
|
||||
## Settings
|
||||
|
||||
Settings are stored in `%APPDATA%\ClawdbotTray\settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"GatewayUrl": "ws://localhost:18789",
|
||||
"Token": "your-token",
|
||||
"AutoStart": false,
|
||||
"ShowNotifications": true,
|
||||
"NotificationSound": "Default",
|
||||
"NotifyHealth": true,
|
||||
"NotifyUrgent": true,
|
||||
"NotifyReminder": true,
|
||||
"NotifyEmail": true,
|
||||
"NotifyCalendar": true,
|
||||
"NotifyBuild": true,
|
||||
"NotifyStock": true,
|
||||
"NotifyInfo": true,
|
||||
"ShowGlobalHotkey": true
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Can't connect?**
|
||||
- Check gateway: `clawdbot gateway status` in WSL2
|
||||
- Verify token matches `~/.clawdbot/clawdbot.json`
|
||||
- Try WSL2 IP directly: `ws://<wsl-ip>:18789` (`wsl hostname -I`)
|
||||
|
||||
**No notifications?**
|
||||
- Check Windows Settings → Notifications
|
||||
- Check Focus Assist / Do Not Disturb
|
||||
- Check notification filter settings in the app
|
||||
|
||||
**WebChat blank?**
|
||||
- Install [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)
|
||||
- Check logs: `%LOCALAPPDATA%\ClawdbotTray\clawdbot-tray.log`
|
||||
- Right-click tray → Open Log File
|
||||
|
||||
**Global hotkey not working?**
|
||||
- Another app may have registered Ctrl+Shift+Space
|
||||
- Check Settings → Global hotkey is enabled
|
||||
- Check the log file for "Failed to register global hotkey"
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Credits
|
||||
|
||||
- Built with .NET 9, Windows Forms, and [WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)
|
||||
- Toast notifications via [Microsoft.Toolkit.Uwp.Notifications](https://github.com/CommunityToolkit/WindowsCommunityToolkit)
|
||||
- Part of the [Clawdbot](https://github.com/clawdbot/clawdbot) ecosystem
|
||||
377
src/Moltbot.Tray/SettingsDialog.cs
Normal file
377
src/Moltbot.Tray/SettingsDialog.cs
Normal file
@ -0,0 +1,377 @@
|
||||
using Moltbot.Shared;
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace MoltbotTray;
|
||||
|
||||
public partial class SettingsDialog : Form
|
||||
{
|
||||
private readonly SettingsManager _settings;
|
||||
|
||||
private TextBox _gatewayUrlTextBox = null!;
|
||||
private TextBox _tokenTextBox = null!;
|
||||
private CheckBox _autoStartCheckBox = null!;
|
||||
private CheckBox _showNotificationsCheckBox = null!;
|
||||
private CheckBox _globalHotkeyCheckBox = null!;
|
||||
private ComboBox _notificationSoundComboBox = null!;
|
||||
private Button _testConnectionButton = null!;
|
||||
private Button _okButton = null!;
|
||||
private Button _cancelButton = null!;
|
||||
private Label _statusLabel = null!;
|
||||
|
||||
// Notification filter checkboxes
|
||||
private CheckBox _notifyHealthCb = null!;
|
||||
private CheckBox _notifyUrgentCb = null!;
|
||||
private CheckBox _notifyReminderCb = null!;
|
||||
private CheckBox _notifyEmailCb = null!;
|
||||
private CheckBox _notifyCalendarCb = null!;
|
||||
private CheckBox _notifyBuildCb = null!;
|
||||
private CheckBox _notifyStockCb = null!;
|
||||
private CheckBox _notifyInfoCb = null!;
|
||||
private Panel _notifyFilterPanel = null!;
|
||||
|
||||
public SettingsDialog(SettingsManager settings)
|
||||
{
|
||||
_settings = settings;
|
||||
InitializeComponent();
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
Text = "Settings — Moltbot Tray";
|
||||
Size = new Size(480, 560);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
ShowInTaskbar = false;
|
||||
AutoScroll = true;
|
||||
Icon = IconHelper.GetLobsterIcon();
|
||||
|
||||
var y = 12;
|
||||
var labelFont = new Font("Segoe UI", 9F);
|
||||
var headerFont = new Font("Segoe UI", 9F, FontStyle.Bold);
|
||||
|
||||
// --- Connection Section ---
|
||||
var connHeader = new Label
|
||||
{
|
||||
Text = "CONNECTION",
|
||||
Location = new Point(12, y),
|
||||
Size = new Size(200, 20),
|
||||
Font = headerFont,
|
||||
ForeColor = Color.FromArgb(0, 120, 215)
|
||||
};
|
||||
y += 22;
|
||||
|
||||
var gatewayUrlLabel = new Label
|
||||
{
|
||||
Text = "Gateway URL:",
|
||||
Location = new Point(12, y),
|
||||
Size = new Size(100, 20),
|
||||
Font = labelFont
|
||||
};
|
||||
y += 22;
|
||||
|
||||
_gatewayUrlTextBox = new TextBox
|
||||
{
|
||||
Location = new Point(12, y),
|
||||
Size = new Size(310, 23),
|
||||
Font = labelFont
|
||||
};
|
||||
|
||||
_testConnectionButton = new Button
|
||||
{
|
||||
Text = "Test",
|
||||
Location = new Point(330, y - 1),
|
||||
Size = new Size(65, 25),
|
||||
Font = labelFont
|
||||
};
|
||||
_testConnectionButton.Click += OnTestConnection;
|
||||
y += 30;
|
||||
|
||||
var tokenLabel = new Label
|
||||
{
|
||||
Text = "Token:",
|
||||
Location = new Point(12, y),
|
||||
Size = new Size(100, 20),
|
||||
Font = labelFont
|
||||
};
|
||||
y += 22;
|
||||
|
||||
_tokenTextBox = new TextBox
|
||||
{
|
||||
Location = new Point(12, y),
|
||||
Size = new Size(310, 23),
|
||||
Font = labelFont,
|
||||
UseSystemPasswordChar = true
|
||||
};
|
||||
|
||||
_statusLabel = new Label
|
||||
{
|
||||
Text = "",
|
||||
Location = new Point(330, y + 2),
|
||||
Size = new Size(130, 20),
|
||||
Font = new Font("Segoe UI", 8F),
|
||||
ForeColor = Color.DarkGreen
|
||||
};
|
||||
y += 35;
|
||||
|
||||
// --- Startup Section ---
|
||||
var startupHeader = new Label
|
||||
{
|
||||
Text = "STARTUP",
|
||||
Location = new Point(12, y),
|
||||
Size = new Size(200, 20),
|
||||
Font = headerFont,
|
||||
ForeColor = Color.FromArgb(0, 120, 215)
|
||||
};
|
||||
y += 22;
|
||||
|
||||
_autoStartCheckBox = new CheckBox
|
||||
{
|
||||
Text = "Start automatically with Windows",
|
||||
Location = new Point(12, y),
|
||||
Size = new Size(280, 22),
|
||||
Font = labelFont
|
||||
};
|
||||
y += 26;
|
||||
|
||||
_globalHotkeyCheckBox = new CheckBox
|
||||
{
|
||||
Text = "Global hotkey (Ctrl+Shift+Space → Quick Send)",
|
||||
Location = new Point(12, y),
|
||||
Size = new Size(340, 22),
|
||||
Font = labelFont
|
||||
};
|
||||
y += 35;
|
||||
|
||||
// --- Notifications Section ---
|
||||
var notifyHeader = new Label
|
||||
{
|
||||
Text = "NOTIFICATIONS",
|
||||
Location = new Point(12, y),
|
||||
Size = new Size(200, 20),
|
||||
Font = headerFont,
|
||||
ForeColor = Color.FromArgb(0, 120, 215)
|
||||
};
|
||||
y += 22;
|
||||
|
||||
_showNotificationsCheckBox = new CheckBox
|
||||
{
|
||||
Text = "Show desktop notifications",
|
||||
Location = new Point(12, y),
|
||||
Size = new Size(250, 22),
|
||||
Font = labelFont
|
||||
};
|
||||
_showNotificationsCheckBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
_notifyFilterPanel.Enabled = _showNotificationsCheckBox.Checked;
|
||||
};
|
||||
y += 26;
|
||||
|
||||
var soundLabel = new Label
|
||||
{
|
||||
Text = "Sound:",
|
||||
Location = new Point(12, y),
|
||||
Size = new Size(50, 20),
|
||||
Font = labelFont
|
||||
};
|
||||
|
||||
_notificationSoundComboBox = new ComboBox
|
||||
{
|
||||
Location = new Point(65, y - 2),
|
||||
Size = new Size(140, 23),
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
Font = labelFont
|
||||
};
|
||||
_notificationSoundComboBox.Items.AddRange(new[] { "Default", "None", "Critical", "Information" });
|
||||
y += 30;
|
||||
|
||||
// Filter panel
|
||||
var filterLabel = new Label
|
||||
{
|
||||
Text = "Show toasts for:",
|
||||
Location = new Point(12, y),
|
||||
Size = new Size(120, 20),
|
||||
Font = labelFont,
|
||||
ForeColor = Color.Gray
|
||||
};
|
||||
y += 22;
|
||||
|
||||
_notifyFilterPanel = new Panel
|
||||
{
|
||||
Location = new Point(12, y),
|
||||
Size = new Size(440, 72),
|
||||
BorderStyle = BorderStyle.None
|
||||
};
|
||||
|
||||
// Two columns of filter checkboxes
|
||||
var cbFont = new Font("Segoe UI", 8.5F);
|
||||
_notifyHealthCb = MakeFilterCb("🩸 Health", 0, 0, cbFont);
|
||||
_notifyUrgentCb = MakeFilterCb("🚨 Urgent", 0, 24, cbFont);
|
||||
_notifyReminderCb = MakeFilterCb("⏰ Reminders", 0, 48, cbFont);
|
||||
_notifyEmailCb = MakeFilterCb("📧 Email", 150, 0, cbFont);
|
||||
_notifyCalendarCb = MakeFilterCb("📅 Calendar", 150, 24, cbFont);
|
||||
_notifyBuildCb = MakeFilterCb("🔨 Build/CI", 150, 48, cbFont);
|
||||
_notifyStockCb = MakeFilterCb("📦 Stock", 300, 0, cbFont);
|
||||
_notifyInfoCb = MakeFilterCb("🤖 General", 300, 24, cbFont);
|
||||
|
||||
_notifyFilterPanel.Controls.AddRange(new Control[]
|
||||
{
|
||||
_notifyHealthCb, _notifyUrgentCb, _notifyReminderCb,
|
||||
_notifyEmailCb, _notifyCalendarCb, _notifyBuildCb,
|
||||
_notifyStockCb, _notifyInfoCb
|
||||
});
|
||||
|
||||
y += 80;
|
||||
|
||||
// --- Buttons ---
|
||||
y += 10;
|
||||
_okButton = new Button
|
||||
{
|
||||
Text = "&OK",
|
||||
Location = new Point(300, y),
|
||||
Size = new Size(75, 28),
|
||||
Font = labelFont
|
||||
};
|
||||
_okButton.Click += OnOkClick;
|
||||
|
||||
_cancelButton = new Button
|
||||
{
|
||||
Text = "&Cancel",
|
||||
Location = new Point(382, y),
|
||||
Size = new Size(75, 28),
|
||||
Font = labelFont
|
||||
};
|
||||
_cancelButton.Click += OnCancelClick;
|
||||
|
||||
AcceptButton = _okButton;
|
||||
CancelButton = _cancelButton;
|
||||
|
||||
// Add all controls
|
||||
Controls.AddRange(new Control[]
|
||||
{
|
||||
connHeader, gatewayUrlLabel, _gatewayUrlTextBox, _testConnectionButton,
|
||||
tokenLabel, _tokenTextBox, _statusLabel,
|
||||
startupHeader, _autoStartCheckBox, _globalHotkeyCheckBox,
|
||||
notifyHeader, _showNotificationsCheckBox, soundLabel, _notificationSoundComboBox,
|
||||
filterLabel, _notifyFilterPanel,
|
||||
_okButton, _cancelButton
|
||||
});
|
||||
}
|
||||
|
||||
private static CheckBox MakeFilterCb(string text, int x, int y, Font font)
|
||||
{
|
||||
return new CheckBox
|
||||
{
|
||||
Text = text,
|
||||
Location = new Point(x, y),
|
||||
Size = new Size(140, 22),
|
||||
Font = font,
|
||||
Checked = true
|
||||
};
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
_gatewayUrlTextBox.Text = _settings.GatewayUrl;
|
||||
_tokenTextBox.Text = _settings.Token;
|
||||
_autoStartCheckBox.Checked = _settings.AutoStart;
|
||||
_globalHotkeyCheckBox.Checked = _settings.ShowGlobalHotkey;
|
||||
_showNotificationsCheckBox.Checked = _settings.ShowNotifications;
|
||||
_notifyFilterPanel.Enabled = _settings.ShowNotifications;
|
||||
|
||||
var soundIndex = _notificationSoundComboBox.Items.IndexOf(_settings.NotificationSound);
|
||||
_notificationSoundComboBox.SelectedIndex = soundIndex >= 0 ? soundIndex : 0;
|
||||
|
||||
_notifyHealthCb.Checked = _settings.NotifyHealth;
|
||||
_notifyUrgentCb.Checked = _settings.NotifyUrgent;
|
||||
_notifyReminderCb.Checked = _settings.NotifyReminder;
|
||||
_notifyEmailCb.Checked = _settings.NotifyEmail;
|
||||
_notifyCalendarCb.Checked = _settings.NotifyCalendar;
|
||||
_notifyBuildCb.Checked = _settings.NotifyBuild;
|
||||
_notifyStockCb.Checked = _settings.NotifyStock;
|
||||
_notifyInfoCb.Checked = _settings.NotifyInfo;
|
||||
}
|
||||
|
||||
private void SaveSettings()
|
||||
{
|
||||
_settings.GatewayUrl = _gatewayUrlTextBox.Text.Trim();
|
||||
_settings.Token = _tokenTextBox.Text.Trim();
|
||||
_settings.AutoStart = _autoStartCheckBox.Checked;
|
||||
_settings.ShowGlobalHotkey = _globalHotkeyCheckBox.Checked;
|
||||
_settings.ShowNotifications = _showNotificationsCheckBox.Checked;
|
||||
_settings.NotificationSound = _notificationSoundComboBox.SelectedItem?.ToString() ?? "Default";
|
||||
_settings.NotifyHealth = _notifyHealthCb.Checked;
|
||||
_settings.NotifyUrgent = _notifyUrgentCb.Checked;
|
||||
_settings.NotifyReminder = _notifyReminderCb.Checked;
|
||||
_settings.NotifyEmail = _notifyEmailCb.Checked;
|
||||
_settings.NotifyCalendar = _notifyCalendarCb.Checked;
|
||||
_settings.NotifyBuild = _notifyBuildCb.Checked;
|
||||
_settings.NotifyStock = _notifyStockCb.Checked;
|
||||
_settings.NotifyInfo = _notifyInfoCb.Checked;
|
||||
}
|
||||
|
||||
private async void OnTestConnection(object? sender, EventArgs e)
|
||||
{
|
||||
_testConnectionButton.Enabled = false;
|
||||
_statusLabel.Text = "Testing...";
|
||||
_statusLabel.ForeColor = Color.Blue;
|
||||
|
||||
try
|
||||
{
|
||||
var testClient = new MoltbotGatewayClient(
|
||||
_gatewayUrlTextBox.Text.Trim(),
|
||||
_tokenTextBox.Text.Trim());
|
||||
|
||||
await testClient.ConnectAsync();
|
||||
await testClient.DisconnectAsync();
|
||||
testClient.Dispose();
|
||||
|
||||
_statusLabel.Text = "✅ Connected";
|
||||
_statusLabel.ForeColor = Color.DarkGreen;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_statusLabel.Text = $"❌ {ex.Message}";
|
||||
_statusLabel.ForeColor = Color.Red;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_testConnectionButton.Enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOkClick(object? sender, EventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_gatewayUrlTextBox.Text))
|
||||
{
|
||||
MessageBox.Show("Gateway URL is required.", "Settings",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
_gatewayUrlTextBox.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(_gatewayUrlTextBox.Text.Trim(), UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != "ws" && uri.Scheme != "wss"))
|
||||
{
|
||||
MessageBox.Show("Gateway URL must be a valid WebSocket URL (ws:// or wss://).", "Settings",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
_gatewayUrlTextBox.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
SaveSettings();
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnCancelClick(object? sender, EventArgs e)
|
||||
{
|
||||
DialogResult = DialogResult.Cancel;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
153
src/Moltbot.Tray/SettingsManager.cs
Normal file
153
src/Moltbot.Tray/SettingsManager.cs
Normal file
@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MoltbotTray;
|
||||
|
||||
public class SettingsManager
|
||||
{
|
||||
private static readonly string SettingsDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"MoltbotTray");
|
||||
|
||||
private static readonly string SettingsFile = Path.Combine(SettingsDirectory, "settings.json");
|
||||
|
||||
public string GatewayUrl { get; set; } = "ws://localhost:18789";
|
||||
public string Token { get; set; } = "";
|
||||
public bool AutoStart { get; set; } = false;
|
||||
public bool ShowNotifications { get; set; } = true;
|
||||
public string NotificationSound { get; set; } = "Default";
|
||||
|
||||
// Notification filters — which types to show toasts for
|
||||
public bool NotifyHealth { get; set; } = true;
|
||||
public bool NotifyUrgent { get; set; } = true;
|
||||
public bool NotifyReminder { get; set; } = true;
|
||||
public bool NotifyEmail { get; set; } = true;
|
||||
public bool NotifyCalendar { get; set; } = true;
|
||||
public bool NotifyBuild { get; set; } = true;
|
||||
public bool NotifyStock { get; set; } = true;
|
||||
public bool NotifyInfo { get; set; } = true;
|
||||
|
||||
// UI preferences
|
||||
public bool ShowGlobalHotkey { get; set; } = true;
|
||||
public bool MinimizeToTray { get; set; } = true;
|
||||
|
||||
public SettingsManager()
|
||||
{
|
||||
Load();
|
||||
}
|
||||
|
||||
/// <summary>Check if a notification type should produce a toast.</summary>
|
||||
public bool ShouldNotify(string type)
|
||||
{
|
||||
if (!ShowNotifications) return false;
|
||||
return type switch
|
||||
{
|
||||
"health" => NotifyHealth,
|
||||
"urgent" => NotifyUrgent,
|
||||
"reminder" => NotifyReminder,
|
||||
"email" => NotifyEmail,
|
||||
"calendar" => NotifyCalendar,
|
||||
"build" => NotifyBuild,
|
||||
"stock" => NotifyStock,
|
||||
"error" => NotifyUrgent, // Errors use urgent setting
|
||||
"info" => NotifyInfo,
|
||||
_ => NotifyInfo
|
||||
};
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(SettingsFile))
|
||||
{
|
||||
var json = File.ReadAllText(SettingsFile);
|
||||
var settings = JsonSerializer.Deserialize<SettingsData>(json);
|
||||
|
||||
if (settings != null)
|
||||
{
|
||||
GatewayUrl = settings.GatewayUrl ?? "ws://localhost:18789";
|
||||
Token = settings.Token ?? "";
|
||||
AutoStart = settings.AutoStart;
|
||||
ShowNotifications = settings.ShowNotifications;
|
||||
NotificationSound = settings.NotificationSound ?? "Default";
|
||||
NotifyHealth = settings.NotifyHealth ?? true;
|
||||
NotifyUrgent = settings.NotifyUrgent ?? true;
|
||||
NotifyReminder = settings.NotifyReminder ?? true;
|
||||
NotifyEmail = settings.NotifyEmail ?? true;
|
||||
NotifyCalendar = settings.NotifyCalendar ?? true;
|
||||
NotifyBuild = settings.NotifyBuild ?? true;
|
||||
NotifyStock = settings.NotifyStock ?? true;
|
||||
NotifyInfo = settings.NotifyInfo ?? true;
|
||||
ShowGlobalHotkey = settings.ShowGlobalHotkey ?? true;
|
||||
MinimizeToTray = settings.MinimizeToTray ?? true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Use defaults if loading fails
|
||||
}
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(SettingsDirectory);
|
||||
|
||||
var settings = new SettingsData
|
||||
{
|
||||
GatewayUrl = GatewayUrl,
|
||||
Token = Token,
|
||||
AutoStart = AutoStart,
|
||||
ShowNotifications = ShowNotifications,
|
||||
NotificationSound = NotificationSound,
|
||||
NotifyHealth = NotifyHealth,
|
||||
NotifyUrgent = NotifyUrgent,
|
||||
NotifyReminder = NotifyReminder,
|
||||
NotifyEmail = NotifyEmail,
|
||||
NotifyCalendar = NotifyCalendar,
|
||||
NotifyBuild = NotifyBuild,
|
||||
NotifyStock = NotifyStock,
|
||||
NotifyInfo = NotifyInfo,
|
||||
ShowGlobalHotkey = ShowGlobalHotkey,
|
||||
MinimizeToTray = MinimizeToTray
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(SettingsFile, json);
|
||||
Logger.Info("Settings saved");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Failed to save settings", ex);
|
||||
throw new Exception($"Failed to save settings: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetSettingsDirectory() => SettingsDirectory;
|
||||
|
||||
public static string GetSettingsFile() => SettingsFile;
|
||||
|
||||
private class SettingsData
|
||||
{
|
||||
public string? GatewayUrl { get; set; }
|
||||
public string? Token { get; set; }
|
||||
public bool AutoStart { get; set; }
|
||||
public bool ShowNotifications { get; set; }
|
||||
public string? NotificationSound { get; set; }
|
||||
public bool? NotifyHealth { get; set; }
|
||||
public bool? NotifyUrgent { get; set; }
|
||||
public bool? NotifyReminder { get; set; }
|
||||
public bool? NotifyEmail { get; set; }
|
||||
public bool? NotifyCalendar { get; set; }
|
||||
public bool? NotifyBuild { get; set; }
|
||||
public bool? NotifyStock { get; set; }
|
||||
public bool? NotifyInfo { get; set; }
|
||||
public bool? ShowGlobalHotkey { get; set; }
|
||||
public bool? MinimizeToTray { get; set; }
|
||||
}
|
||||
}
|
||||
183
src/Moltbot.Tray/StatusDetailForm.cs
Normal file
183
src/Moltbot.Tray/StatusDetailForm.cs
Normal file
@ -0,0 +1,183 @@
|
||||
using Moltbot.Shared;
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Text;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace MoltbotTray;
|
||||
|
||||
/// <summary>
|
||||
/// Shows detailed gateway status, sessions, channels, and usage in a rich view.
|
||||
/// </summary>
|
||||
public class StatusDetailForm : Form
|
||||
{
|
||||
private RichTextBox _textBox = null!;
|
||||
private Button _refreshButton = null!;
|
||||
private Button _closeButton = null!;
|
||||
private readonly MoltbotGatewayClient? _client;
|
||||
private readonly SettingsManager? _settings;
|
||||
private readonly ConnectionStatus _status;
|
||||
|
||||
private static StatusDetailForm? _instance;
|
||||
|
||||
public static void ShowOrFocus(MoltbotGatewayClient? client, SettingsManager? settings, ConnectionStatus status)
|
||||
{
|
||||
if (_instance != null && !_instance.IsDisposed)
|
||||
{
|
||||
_instance.BringToFront();
|
||||
_instance.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
_instance = new StatusDetailForm(client, settings, status);
|
||||
_instance.Show();
|
||||
}
|
||||
|
||||
private StatusDetailForm(MoltbotGatewayClient? client, SettingsManager? settings, ConnectionStatus status)
|
||||
{
|
||||
_client = client;
|
||||
_settings = settings;
|
||||
_status = status;
|
||||
InitializeComponent();
|
||||
RefreshStatus();
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
Text = "Clawdbot Status";
|
||||
Size = new Size(520, 500);
|
||||
MinimumSize = new Size(400, 350);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
Icon = IconHelper.GetLobsterIcon();
|
||||
|
||||
_textBox = new RichTextBox
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ReadOnly = true,
|
||||
Font = new Font("Cascadia Code", 10F, FontStyle.Regular, GraphicsUnit.Point),
|
||||
BackColor = Color.FromArgb(30, 30, 30),
|
||||
ForeColor = Color.FromArgb(220, 220, 220),
|
||||
BorderStyle = BorderStyle.None,
|
||||
WordWrap = true
|
||||
};
|
||||
|
||||
var buttonPanel = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Bottom,
|
||||
Height = 40,
|
||||
FlowDirection = FlowDirection.RightToLeft,
|
||||
Padding = new Padding(5)
|
||||
};
|
||||
|
||||
_closeButton = new Button
|
||||
{
|
||||
Text = "&Close",
|
||||
Size = new Size(75, 26),
|
||||
Font = new Font("Segoe UI", 9F)
|
||||
};
|
||||
_closeButton.Click += (_, _) => Close();
|
||||
|
||||
_refreshButton = new Button
|
||||
{
|
||||
Text = "&Refresh",
|
||||
Size = new Size(75, 26),
|
||||
Font = new Font("Segoe UI", 9F)
|
||||
};
|
||||
_refreshButton.Click += async (_, _) =>
|
||||
{
|
||||
if (_client != null)
|
||||
{
|
||||
await _client.CheckHealthAsync();
|
||||
await _client.RequestSessionsAsync();
|
||||
await _client.RequestUsageAsync();
|
||||
}
|
||||
RefreshStatus();
|
||||
};
|
||||
|
||||
buttonPanel.Controls.Add(_closeButton);
|
||||
buttonPanel.Controls.Add(_refreshButton);
|
||||
|
||||
Controls.Add(_textBox);
|
||||
Controls.Add(buttonPanel);
|
||||
}
|
||||
|
||||
private void RefreshStatus()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine("⚡ CLAWDBOT STATUS");
|
||||
sb.AppendLine(new string('─', 40));
|
||||
sb.AppendLine();
|
||||
|
||||
// Connection
|
||||
var statusIcon = _status switch
|
||||
{
|
||||
ConnectionStatus.Connected => "🟢",
|
||||
ConnectionStatus.Connecting => "🟡",
|
||||
ConnectionStatus.Error => "🔴",
|
||||
_ => "⚪"
|
||||
};
|
||||
sb.AppendLine($" Gateway: {statusIcon} {_status}");
|
||||
sb.AppendLine($" URL: {_settings?.GatewayUrl ?? "not configured"}");
|
||||
sb.AppendLine($" Token: {(_settings?.Token?.Length > 0 ? "••••••••" : "not set")}");
|
||||
sb.AppendLine();
|
||||
|
||||
// Sessions
|
||||
if (_client != null)
|
||||
{
|
||||
var sessions = _client.GetSessionList();
|
||||
if (sessions.Length > 0)
|
||||
{
|
||||
sb.AppendLine("🧠 SESSIONS");
|
||||
sb.AppendLine(new string('─', 40));
|
||||
foreach (var s in sessions)
|
||||
{
|
||||
sb.AppendLine($" {s.DisplayText}");
|
||||
if (s.Model != null)
|
||||
sb.AppendLine($" Model: {s.Model}");
|
||||
if (s.StartedAt != null)
|
||||
sb.AppendLine($" Started: {s.StartedAt:HH:mm:ss}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
// App info
|
||||
sb.AppendLine("ℹ️ APP INFO");
|
||||
sb.AppendLine(new string('─', 40));
|
||||
sb.AppendLine($" Version: 1.0.0");
|
||||
sb.AppendLine($" Runtime: {Environment.Version}");
|
||||
sb.AppendLine($" OS: {Environment.OSVersion}");
|
||||
sb.AppendLine($" Machine: {Environment.MachineName}");
|
||||
sb.AppendLine($" PID: {Environment.ProcessId}");
|
||||
sb.AppendLine($" Uptime: {GetUptime()}");
|
||||
sb.AppendLine();
|
||||
|
||||
// Auto-start
|
||||
sb.AppendLine("⚙️ SETTINGS");
|
||||
sb.AppendLine(new string('─', 40));
|
||||
sb.AppendLine($" Auto-start: {(_settings?.AutoStart == true ? "✅" : "❌")}");
|
||||
sb.AppendLine($" Notifications: {(_settings?.ShowNotifications == true ? "✅" : "❌")}");
|
||||
sb.AppendLine($" Sound: {_settings?.NotificationSound ?? "Default"}");
|
||||
|
||||
_textBox.Text = sb.ToString();
|
||||
}
|
||||
|
||||
private static string GetUptime()
|
||||
{
|
||||
var elapsed = DateTime.Now - System.Diagnostics.Process.GetCurrentProcess().StartTime;
|
||||
if (elapsed.TotalHours >= 1)
|
||||
return $"{elapsed.Hours}h {elapsed.Minutes}m";
|
||||
if (elapsed.TotalMinutes >= 1)
|
||||
return $"{elapsed.Minutes}m {elapsed.Seconds}s";
|
||||
return $"{elapsed.Seconds}s";
|
||||
}
|
||||
|
||||
protected override void OnFormClosed(FormClosedEventArgs e)
|
||||
{
|
||||
_instance = null;
|
||||
base.OnFormClosed(e);
|
||||
}
|
||||
}
|
||||
|
||||
805
src/Moltbot.Tray/TrayApplication.cs
Normal file
805
src/Moltbot.Tray/TrayApplication.cs
Normal file
@ -0,0 +1,805 @@
|
||||
using Microsoft.Toolkit.Uwp.Notifications;
|
||||
using Moltbot.Shared;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using ActivityKind = Moltbot.Shared.ActivityKind;
|
||||
|
||||
namespace MoltbotTray;
|
||||
|
||||
public class TrayApplication : ApplicationContext
|
||||
{
|
||||
private NotifyIcon? _notifyIcon;
|
||||
private ContextMenuStrip? _contextMenu;
|
||||
private MoltbotGatewayClient? _gatewayClient;
|
||||
private SettingsManager? _settings;
|
||||
private System.Windows.Forms.Timer? _healthCheckTimer;
|
||||
private System.Windows.Forms.Timer? _sessionPollTimer;
|
||||
private GlobalHotkey? _globalHotkey;
|
||||
private ConnectionStatus _currentStatus = ConnectionStatus.Disconnected;
|
||||
private AgentActivity? _currentActivity;
|
||||
private readonly SynchronizationContext? _syncContext;
|
||||
|
||||
// Session-aware activity: track per-session state to avoid flip-flopping
|
||||
private readonly Dictionary<string, AgentActivity> _sessionActivities = new();
|
||||
private string? _displayedSessionKey;
|
||||
private DateTime _lastSessionSwitch = DateTime.MinValue;
|
||||
private static readonly TimeSpan SessionSwitchDebounce = TimeSpan.FromSeconds(3);
|
||||
|
||||
// Menu items for dynamic updates
|
||||
private ToolStripMenuItem? _statusItem;
|
||||
private ToolStripMenuItem? _activityItem;
|
||||
private ToolStripMenuItem? _usageItem;
|
||||
private ToolStripSeparator? _channelSeparator;
|
||||
private ToolStripSeparator? _sessionSeparator;
|
||||
private readonly List<ToolStripItem> _channelItems = new();
|
||||
private readonly List<ToolStripItem> _sessionItems = new();
|
||||
|
||||
private readonly string[] _startupArgs;
|
||||
|
||||
// P/Invoke for proper icon cleanup
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto)]
|
||||
private static extern bool DestroyIcon(IntPtr handle);
|
||||
|
||||
public TrayApplication(string[]? args = null)
|
||||
{
|
||||
_startupArgs = args ?? Array.Empty<string>();
|
||||
_syncContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
|
||||
Logger.Info("Application starting");
|
||||
InitializeComponent();
|
||||
InitializeAsync();
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
_settings = new SettingsManager();
|
||||
|
||||
// Register toast activation handler
|
||||
ToastNotificationManagerCompat.OnActivated += OnToastActivated;
|
||||
|
||||
_contextMenu = new ContextMenuStrip();
|
||||
|
||||
// Title
|
||||
var titleItem = new ToolStripMenuItem("⚡ Moltbot Tray") { Enabled = false };
|
||||
_contextMenu.Items.Add(titleItem);
|
||||
_contextMenu.Items.Add(new ToolStripSeparator());
|
||||
|
||||
// Status (clickable — opens detail view)
|
||||
_statusItem = new ToolStripMenuItem("Status: Disconnected");
|
||||
_statusItem.Click += OnShowStatusDetail;
|
||||
_contextMenu.Items.Add(_statusItem);
|
||||
|
||||
// Activity (hidden when idle)
|
||||
_activityItem = new ToolStripMenuItem("") { Enabled = false, Visible = false };
|
||||
_contextMenu.Items.Add(_activityItem);
|
||||
|
||||
// Usage (hidden until data available)
|
||||
_usageItem = new ToolStripMenuItem("") { Enabled = false, Visible = false };
|
||||
_contextMenu.Items.Add(_usageItem);
|
||||
|
||||
// Session separator + placeholder
|
||||
_sessionSeparator = new ToolStripSeparator { Visible = false };
|
||||
_contextMenu.Items.Add(_sessionSeparator);
|
||||
|
||||
// Channel health separator + placeholder
|
||||
_channelSeparator = new ToolStripSeparator { Visible = false };
|
||||
_contextMenu.Items.Add(_channelSeparator);
|
||||
|
||||
_contextMenu.Items.Add(new ToolStripSeparator());
|
||||
|
||||
// Actions
|
||||
_contextMenu.Items.Add("Open Dashboard", null, OnOpenDashboard);
|
||||
_contextMenu.Items.Add("Open Web Chat", null, OnOpenWebUI);
|
||||
_contextMenu.Items.Add("Quick Send...", null, OnQuickSend);
|
||||
_contextMenu.Items.Add("Notification History...", null, OnNotificationHistory);
|
||||
_contextMenu.Items.Add("Run Health Check", null, OnManualHealthCheck);
|
||||
_contextMenu.Items.Add(new ToolStripSeparator());
|
||||
|
||||
// Settings
|
||||
_contextMenu.Items.Add("Settings...", null, OnSettings);
|
||||
var autoStartMenuItem = new ToolStripMenuItem("Auto-start", null, OnToggleAutoStart)
|
||||
{
|
||||
Checked = _settings.AutoStart
|
||||
};
|
||||
_contextMenu.Items.Add(autoStartMenuItem);
|
||||
_contextMenu.Items.Add(new ToolStripSeparator());
|
||||
|
||||
// Log file access
|
||||
_contextMenu.Items.Add("Open Log File", null, OnOpenLogFile);
|
||||
_contextMenu.Items.Add("Exit", null, OnExit);
|
||||
|
||||
// Tray icon
|
||||
_notifyIcon = new NotifyIcon
|
||||
{
|
||||
Icon = CreateStatusIcon(ConnectionStatus.Disconnected),
|
||||
ContextMenuStrip = _contextMenu,
|
||||
Text = "Moltbot Tray — Disconnected",
|
||||
Visible = true
|
||||
};
|
||||
_notifyIcon.DoubleClick += OnDoubleClick;
|
||||
|
||||
// Health check timer (30s)
|
||||
_healthCheckTimer = new System.Windows.Forms.Timer { Interval = 30000, Enabled = true };
|
||||
_healthCheckTimer.Tick += OnHealthCheck;
|
||||
|
||||
// Session/usage poll timer (60s) — less frequent
|
||||
_sessionPollTimer = new System.Windows.Forms.Timer { Interval = 60000, Enabled = true };
|
||||
_sessionPollTimer.Tick += OnSessionPoll;
|
||||
|
||||
// Global hotkey: Ctrl+Shift+Space → Quick Send
|
||||
_globalHotkey = new GlobalHotkey();
|
||||
_globalHotkey.HotkeyPressed += (_, _) => OnQuickSend(null, EventArgs.Empty);
|
||||
_globalHotkey.Register();
|
||||
}
|
||||
|
||||
private async void InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_gatewayClient = new MoltbotGatewayClient(_settings!.GatewayUrl, _settings.Token, Logger.Instance);
|
||||
_gatewayClient.StatusChanged += OnStatusChanged;
|
||||
_gatewayClient.NotificationReceived += OnNotificationReceived;
|
||||
_gatewayClient.ActivityChanged += OnActivityChanged;
|
||||
_gatewayClient.ChannelHealthUpdated += OnChannelHealthUpdated;
|
||||
_gatewayClient.SessionsUpdated += OnSessionsUpdated;
|
||||
_gatewayClient.UsageUpdated += OnUsageUpdated;
|
||||
|
||||
await _gatewayClient.ConnectAsync();
|
||||
|
||||
// Process deep link if launched via URI
|
||||
if (DeepLinkHandler.TryGetDeepLink(_startupArgs, out var uri) && uri != null)
|
||||
{
|
||||
await DeepLinkHandler.ProcessDeepLinkAsync(uri, _gatewayClient);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Initial connection failed", ex);
|
||||
ShowErrorToast("Connection Failed", $"Failed to connect: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event Handlers (marshal to UI thread) ---
|
||||
|
||||
private void OnStatusChanged(object? sender, ConnectionStatus status)
|
||||
{
|
||||
_syncContext?.Post(_ => UpdateStatus(status), null);
|
||||
}
|
||||
|
||||
private void OnNotificationReceived(object? sender, MoltbotNotification n)
|
||||
{
|
||||
_syncContext?.Post(_ => ShowNotificationToast(n.Title, n.Message, n.Type), null);
|
||||
}
|
||||
|
||||
private void OnActivityChanged(object? sender, AgentActivity activity)
|
||||
{
|
||||
_syncContext?.Post(_ => UpdateActivity(activity), null);
|
||||
}
|
||||
|
||||
private void OnChannelHealthUpdated(object? sender, ChannelHealth[] channels)
|
||||
{
|
||||
_syncContext?.Post(_ => UpdateChannelHealth(channels), null);
|
||||
}
|
||||
|
||||
private void OnSessionsUpdated(object? sender, SessionInfo[] sessions)
|
||||
{
|
||||
_syncContext?.Post(_ => UpdateSessions(sessions), null);
|
||||
}
|
||||
|
||||
private void OnUsageUpdated(object? sender, GatewayUsageInfo usage)
|
||||
{
|
||||
_syncContext?.Post(_ => UpdateUsage(usage), null);
|
||||
}
|
||||
|
||||
// --- UI Updates ---
|
||||
|
||||
private void UpdateStatus(ConnectionStatus status)
|
||||
{
|
||||
_currentStatus = status;
|
||||
|
||||
if (_notifyIcon != null)
|
||||
{
|
||||
var oldIcon = _notifyIcon.Icon;
|
||||
_notifyIcon.Icon = CreateStatusIcon(status, _currentActivity?.Kind);
|
||||
SafeDestroyIcon(oldIcon);
|
||||
|
||||
var tooltip = _currentActivity?.Kind != ActivityKind.Idle && !string.IsNullOrEmpty(_currentActivity?.DisplayText)
|
||||
? $"Clawdbot — {_currentActivity.DisplayText}"
|
||||
: $"Clawdbot — {status}";
|
||||
_notifyIcon.Text = tooltip.Length > 63 ? tooltip[..63] : tooltip;
|
||||
}
|
||||
|
||||
if (_statusItem != null)
|
||||
{
|
||||
var label = status switch
|
||||
{
|
||||
ConnectionStatus.Connected => "[ON]",
|
||||
ConnectionStatus.Connecting => "[...]",
|
||||
ConnectionStatus.Error => "[ERR]",
|
||||
_ => "[OFF]"
|
||||
};
|
||||
_statusItem.Text = $"{label} Gateway: {status}";
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateActivity(AgentActivity activity)
|
||||
{
|
||||
// Track per-session activity for stable display
|
||||
_sessionActivities[activity.SessionKey] = activity;
|
||||
|
||||
// Resolve which session to display using stable selection:
|
||||
// 1. Active main session always wins
|
||||
// 2. Keep current session if still active (prevents flip-flop)
|
||||
// 3. Fall back to most recently active non-main session
|
||||
var displayActivity = ResolveDisplayActivity(activity);
|
||||
_currentActivity = displayActivity;
|
||||
|
||||
if (_activityItem != null)
|
||||
{
|
||||
if (displayActivity.Kind != ActivityKind.Idle && !string.IsNullOrEmpty(displayActivity.DisplayText))
|
||||
{
|
||||
_activityItem.Text = displayActivity.DisplayText;
|
||||
_activityItem.Visible = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_activityItem.Visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Also update the tray icon to reflect activity
|
||||
UpdateStatus(_currentStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the best session to display in the activity row.
|
||||
/// Avoids rapid switching between sessions by applying a debounce window.
|
||||
/// </summary>
|
||||
private AgentActivity ResolveDisplayActivity(AgentActivity incoming)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// If main session is active, always prefer it
|
||||
if (incoming.IsMain && incoming.Kind != ActivityKind.Idle)
|
||||
{
|
||||
_displayedSessionKey = incoming.SessionKey;
|
||||
_lastSessionSwitch = now;
|
||||
return incoming;
|
||||
}
|
||||
|
||||
// If the currently displayed session is still active, keep it (no flip-flop)
|
||||
if (_displayedSessionKey != null &&
|
||||
_sessionActivities.TryGetValue(_displayedSessionKey, out var current) &&
|
||||
current.Kind != ActivityKind.Idle)
|
||||
{
|
||||
// Only allow switching away if debounce period has passed
|
||||
if (now - _lastSessionSwitch < SessionSwitchDebounce)
|
||||
return current;
|
||||
}
|
||||
|
||||
// Check if any main session is active
|
||||
foreach (var kvp in _sessionActivities)
|
||||
{
|
||||
if (kvp.Value.IsMain && kvp.Value.Kind != ActivityKind.Idle)
|
||||
{
|
||||
_displayedSessionKey = kvp.Key;
|
||||
_lastSessionSwitch = now;
|
||||
return kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
// No main active — show the incoming active session if it has work
|
||||
if (incoming.Kind != ActivityKind.Idle)
|
||||
{
|
||||
_displayedSessionKey = incoming.SessionKey;
|
||||
_lastSessionSwitch = now;
|
||||
return incoming;
|
||||
}
|
||||
|
||||
// Everything is idle
|
||||
_displayedSessionKey = null;
|
||||
return incoming;
|
||||
}
|
||||
|
||||
private void UpdateChannelHealth(ChannelHealth[] channels)
|
||||
{
|
||||
// Remove old channel items
|
||||
foreach (var item in _channelItems)
|
||||
_contextMenu?.Items.Remove(item);
|
||||
_channelItems.Clear();
|
||||
|
||||
if (channels.Length == 0)
|
||||
{
|
||||
if (_channelSeparator != null) _channelSeparator.Visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_channelSeparator != null) _channelSeparator.Visible = true;
|
||||
|
||||
var insertIndex = _contextMenu?.Items.IndexOf(_channelSeparator!) ?? -1;
|
||||
if (insertIndex < 0) return;
|
||||
|
||||
// Add header
|
||||
insertIndex++;
|
||||
var header = new ToolStripMenuItem("📡 Channels") { Enabled = false };
|
||||
_contextMenu!.Items.Insert(insertIndex, header);
|
||||
_channelItems.Add(header);
|
||||
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
insertIndex++;
|
||||
var item = new ToolStripMenuItem($" {ch.DisplayText}") { Enabled = false };
|
||||
_contextMenu.Items.Insert(insertIndex, item);
|
||||
_channelItems.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSessions(SessionInfo[] sessions)
|
||||
{
|
||||
// Log session data for debugging
|
||||
Logger.Info($"UpdateSessions: {sessions.Length} sessions");
|
||||
foreach (var s in sessions)
|
||||
Logger.Info($" Session: key={s.Key}, isMain={s.IsMain}, status={s.Status}, channel={s.Channel}");
|
||||
|
||||
// Remove old session items
|
||||
foreach (var item in _sessionItems)
|
||||
_contextMenu?.Items.Remove(item);
|
||||
_sessionItems.Clear();
|
||||
|
||||
if (sessions.Length == 0)
|
||||
{
|
||||
if (_sessionSeparator != null) _sessionSeparator.Visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_sessionSeparator != null) _sessionSeparator.Visible = true;
|
||||
|
||||
var insertIndex = _contextMenu?.Items.IndexOf(_sessionSeparator!) ?? -1;
|
||||
if (insertIndex < 0) return;
|
||||
|
||||
// Add header
|
||||
insertIndex++;
|
||||
var header = new ToolStripMenuItem("🧠 Sessions") { Enabled = false };
|
||||
_contextMenu!.Items.Insert(insertIndex, header);
|
||||
_sessionItems.Add(header);
|
||||
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
insertIndex++;
|
||||
// Use ShortKey if DisplayText is too minimal
|
||||
var displayText = session.DisplayText;
|
||||
if (displayText == "⚡ Main" || displayText == "🔹 Sub")
|
||||
displayText = $"{displayText} · {session.ShortKey}";
|
||||
var item = new ToolStripMenuItem($" {displayText}") { Enabled = false };
|
||||
_contextMenu.Items.Insert(insertIndex, item);
|
||||
_sessionItems.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateUsage(GatewayUsageInfo usage)
|
||||
{
|
||||
if (_usageItem != null)
|
||||
{
|
||||
_usageItem.Text = $"📊 {usage.DisplayText}";
|
||||
_usageItem.Visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Icon Creation (with proper cleanup) ---
|
||||
|
||||
private Icon CreateStatusIcon(ConnectionStatus status, ActivityKind? activity = null)
|
||||
{
|
||||
var bitmap = new Bitmap(16, 16);
|
||||
using (var g = Graphics.FromImage(bitmap))
|
||||
{
|
||||
g.Clear(Color.Transparent);
|
||||
|
||||
if (status == ConnectionStatus.Connected)
|
||||
{
|
||||
// Draw pixel lobster when connected
|
||||
DrawPixelLobster(g);
|
||||
}
|
||||
else
|
||||
{
|
||||
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
|
||||
|
||||
// Base color from status
|
||||
var baseColor = status switch
|
||||
{
|
||||
ConnectionStatus.Connecting => Color.FromArgb(255, 180, 0), // Amber
|
||||
ConnectionStatus.Error => Color.FromArgb(220, 50, 50), // Red
|
||||
_ => Color.FromArgb(128, 128, 128) // Gray
|
||||
};
|
||||
|
||||
// Main circle for non-connected states
|
||||
using var brush = new SolidBrush(baseColor);
|
||||
g.FillEllipse(brush, 1, 1, 13, 13);
|
||||
}
|
||||
|
||||
// Activity badge (small dot in corner when working)
|
||||
if (activity is not null and not ActivityKind.Idle && status == ConnectionStatus.Connected)
|
||||
{
|
||||
var badgeColor = activity switch
|
||||
{
|
||||
ActivityKind.Exec => Color.FromArgb(255, 100, 0), // Orange
|
||||
ActivityKind.Write or ActivityKind.Edit => Color.FromArgb(100, 200, 50), // Green
|
||||
ActivityKind.Read => Color.FromArgb(80, 150, 255), // Blue
|
||||
ActivityKind.Search or ActivityKind.Browser => Color.FromArgb(180, 80, 255), // Purple
|
||||
ActivityKind.Message => Color.FromArgb(50, 200, 100), // Bright green
|
||||
_ => Color.White
|
||||
};
|
||||
using var badgeBrush = new SolidBrush(badgeColor);
|
||||
g.FillEllipse(badgeBrush, 10, 0, 6, 6);
|
||||
using var borderPen = new Pen(Color.Black, 1);
|
||||
g.DrawEllipse(borderPen, 10, 0, 6, 6);
|
||||
}
|
||||
}
|
||||
|
||||
var hIcon = bitmap.GetHicon();
|
||||
var icon = Icon.FromHandle(hIcon);
|
||||
bitmap.Dispose();
|
||||
return icon;
|
||||
}
|
||||
|
||||
private void DrawPixelLobster(Graphics g)
|
||||
{
|
||||
// Pixel lobster from SVG - 16x16 pixel art
|
||||
var outline = Color.FromArgb(58, 10, 13); // #3a0a0d - dark outline
|
||||
var body = Color.FromArgb(255, 79, 64); // #ff4f40 - red body
|
||||
var claw = Color.FromArgb(255, 119, 95); // #ff775f - lighter claws
|
||||
var eyeDark = Color.FromArgb(8, 16, 22); // #081016 - pupils
|
||||
var eyeLight = Color.FromArgb(245, 251, 255); // #f5fbff - eye whites
|
||||
|
||||
// Outline (dark border)
|
||||
var outlinePixels = new[] {
|
||||
(1,5), (1,6), (1,7),
|
||||
(2,4), (2,8),
|
||||
(3,3), (3,9),
|
||||
(4,2), (4,10),
|
||||
(5,2), (6,2), (7,2), (8,2), (9,2), (10,2),
|
||||
(11,2), (12,3), (12,9),
|
||||
(13,4), (13,8),
|
||||
(14,5), (14,6), (14,7),
|
||||
(5,11), (6,11), (7,11), (8,11), (9,11), (10,11),
|
||||
(4,12), (11,12),
|
||||
(3,13), (12,13),
|
||||
(5,14), (6,14), (7,14), (8,14), (9,14), (10,14)
|
||||
};
|
||||
foreach (var (x, y) in outlinePixels)
|
||||
bitmap_SetPixel(g, x, y, outline);
|
||||
|
||||
// Body (red)
|
||||
var bodyPixels = new[] {
|
||||
(5,3), (6,3), (7,3), (8,3), (9,3), (10,3),
|
||||
(4,4), (5,4), (7,4), (8,4), (10,4), (11,4),
|
||||
(3,5), (4,5), (5,5), (7,5), (8,5), (10,5), (11,5), (12,5),
|
||||
(3,6), (4,6), (5,6), (6,6), (7,6), (8,6), (9,6), (10,6), (11,6), (12,6),
|
||||
(3,7), (4,7), (5,7), (6,7), (7,7), (8,7), (9,7), (10,7), (11,7), (12,7),
|
||||
(4,8), (5,8), (6,8), (7,8), (8,8), (9,8), (10,8), (11,8),
|
||||
(5,9), (6,9), (7,9), (8,9), (9,9), (10,9),
|
||||
(5,12), (6,12), (7,12), (8,12), (9,12), (10,12),
|
||||
(6,13), (7,13), (8,13), (9,13)
|
||||
};
|
||||
foreach (var (x, y) in bodyPixels)
|
||||
bitmap_SetPixel(g, x, y, body);
|
||||
|
||||
// Claws (lighter red)
|
||||
var clawPixels = new[] {
|
||||
(1,6), (2,5), (2,6), (2,7),
|
||||
(13,5), (13,6), (13,7), (14,6)
|
||||
};
|
||||
foreach (var (x, y) in clawPixels)
|
||||
bitmap_SetPixel(g, x, y, claw);
|
||||
|
||||
// Eyes
|
||||
bitmap_SetPixel(g, 6, 4, eyeLight);
|
||||
bitmap_SetPixel(g, 9, 4, eyeLight);
|
||||
bitmap_SetPixel(g, 6, 5, eyeDark);
|
||||
bitmap_SetPixel(g, 9, 5, eyeDark);
|
||||
}
|
||||
|
||||
private void bitmap_SetPixel(Graphics g, int x, int y, Color c)
|
||||
{
|
||||
using var brush = new SolidBrush(c);
|
||||
g.FillRectangle(brush, x, y, 1, 1);
|
||||
}
|
||||
|
||||
private static void SafeDestroyIcon(Icon? icon)
|
||||
{
|
||||
if (icon == null) return;
|
||||
try
|
||||
{
|
||||
DestroyIcon(icon.Handle);
|
||||
icon.Dispose();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// --- Toast Notifications ---
|
||||
|
||||
private void ShowNotificationToast(string title, string message, string type = "info")
|
||||
{
|
||||
// Always log to history regardless of filter
|
||||
NotificationHistoryForm.AddEntry(title, message, type);
|
||||
|
||||
// Check per-type filter
|
||||
if (_settings?.ShouldNotify(type) != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
new ToastContentBuilder()
|
||||
.AddText(title)
|
||||
.AddText(message)
|
||||
.Show();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_notifyIcon?.ShowBalloonTip(3000, title, message, ToolTipIcon.Info);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowErrorToast(string title, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
new ToastContentBuilder()
|
||||
.AddText(title)
|
||||
.AddText(message)
|
||||
.Show();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_notifyIcon?.ShowBalloonTip(3000, title, message, ToolTipIcon.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnToastActivated(ToastNotificationActivatedEventArgsCompat e)
|
||||
{
|
||||
// Parse arguments from toast
|
||||
var args = ToastArguments.Parse(e.Argument);
|
||||
|
||||
if (args.TryGetValue("action", out var action) && action == "openDashboard")
|
||||
{
|
||||
if (args.TryGetValue("url", out var url))
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Failed to open dashboard from toast: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Menu Actions ---
|
||||
|
||||
private async void OnHealthCheck(object? sender, EventArgs e)
|
||||
{
|
||||
if (_gatewayClient != null && _currentStatus != ConnectionStatus.Connecting)
|
||||
await _gatewayClient.CheckHealthAsync();
|
||||
}
|
||||
|
||||
private async void OnSessionPoll(object? sender, EventArgs e)
|
||||
{
|
||||
if (_gatewayClient != null && _currentStatus == ConnectionStatus.Connected)
|
||||
{
|
||||
await _gatewayClient.RequestSessionsAsync();
|
||||
await _gatewayClient.RequestUsageAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnManualHealthCheck(object? sender, EventArgs e)
|
||||
{
|
||||
Logger.Info("Manual health check triggered");
|
||||
if (_gatewayClient != null)
|
||||
{
|
||||
await _gatewayClient.CheckHealthAsync();
|
||||
await _gatewayClient.RequestSessionsAsync();
|
||||
await _gatewayClient.RequestUsageAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOpenWebUI(object? sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
WebChatForm.ShowOrFocus(_settings!.GatewayUrl, _settings.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fallback to browser if WebView2 fails
|
||||
Logger.Warn($"WebView2 failed, falling back to browser: {ex.Message}");
|
||||
var url = _settings!.GatewayUrl
|
||||
.Replace("ws://", "http://")
|
||||
.Replace("wss://", "https://");
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex2)
|
||||
{
|
||||
ShowErrorToast("Failed to open Web UI", ex2.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GetDashboardUrl()
|
||||
{
|
||||
var baseUrl = _settings!.GatewayUrl
|
||||
.Replace("ws://", "http://")
|
||||
.Replace("wss://", "https://");
|
||||
|
||||
// Add token if available
|
||||
if (!string.IsNullOrEmpty(_settings.Token))
|
||||
{
|
||||
var separator = baseUrl.Contains("?") ? "&" : "?";
|
||||
return $"{baseUrl}{separator}token={Uri.EscapeDataString(_settings.Token)}";
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
private void OnOpenDashboard(object? sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(GetDashboardUrl()) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowErrorToast("Failed to open Dashboard", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnQuickSend(object? sender, EventArgs e)
|
||||
{
|
||||
using var dialog = new QuickSendDialog();
|
||||
if (dialog.ShowDialog() == DialogResult.OK)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _gatewayClient!.SendChatMessageAsync(dialog.Message);
|
||||
ShowClickableToast("Message Sent", "Click to continue chat in dashboard");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowErrorToast("Failed to Send", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowClickableToast(string title, string message)
|
||||
{
|
||||
NotificationHistoryForm.AddEntry(title, message, "info");
|
||||
|
||||
try
|
||||
{
|
||||
new ToastContentBuilder()
|
||||
.AddText(title)
|
||||
.AddText(message)
|
||||
.AddArgument("action", "openDashboard")
|
||||
.AddArgument("url", GetDashboardUrl())
|
||||
.Show();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_notifyIcon?.ShowBalloonTip(3000, title, message, ToolTipIcon.Info);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSettings(object? sender, EventArgs e)
|
||||
{
|
||||
using var dialog = new SettingsDialog(_settings!);
|
||||
if (dialog.ShowDialog() == DialogResult.OK)
|
||||
{
|
||||
_settings!.Save();
|
||||
Task.Run(async () => await ReconnectAsync());
|
||||
}
|
||||
}
|
||||
|
||||
private void OnToggleAutoStart(object? sender, EventArgs e)
|
||||
{
|
||||
var menuItem = (ToolStripMenuItem)sender!;
|
||||
_settings!.AutoStart = !_settings.AutoStart;
|
||||
menuItem.Checked = _settings.AutoStart;
|
||||
_settings.Save();
|
||||
AutoStartManager.SetAutoStart(_settings.AutoStart);
|
||||
Logger.Info($"Auto-start: {_settings.AutoStart}");
|
||||
}
|
||||
|
||||
private void OnShowStatusDetail(object? sender, EventArgs e)
|
||||
{
|
||||
StatusDetailForm.ShowOrFocus(_gatewayClient, _settings, _currentStatus);
|
||||
}
|
||||
|
||||
private void OnNotificationHistory(object? sender, EventArgs e)
|
||||
{
|
||||
NotificationHistoryForm.ShowOrFocus();
|
||||
}
|
||||
|
||||
private void OnOpenLogFile(object? sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logDir = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"MoltbotTray");
|
||||
var logPath = System.IO.Path.Combine(logDir, "clawdbot-tray.log");
|
||||
|
||||
if (System.IO.File.Exists(logPath))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(logPath) { UseShellExecute = true });
|
||||
}
|
||||
else
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(logDir) { UseShellExecute = true });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowErrorToast("Failed to Open Log", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_gatewayClient != null)
|
||||
{
|
||||
await _gatewayClient.DisconnectAsync();
|
||||
_gatewayClient.Dispose();
|
||||
}
|
||||
|
||||
_gatewayClient = new MoltbotGatewayClient(_settings!.GatewayUrl, _settings.Token, Logger.Instance);
|
||||
_gatewayClient.StatusChanged += OnStatusChanged;
|
||||
_gatewayClient.NotificationReceived += OnNotificationReceived;
|
||||
_gatewayClient.ActivityChanged += OnActivityChanged;
|
||||
_gatewayClient.ChannelHealthUpdated += OnChannelHealthUpdated;
|
||||
_gatewayClient.SessionsUpdated += OnSessionsUpdated;
|
||||
_gatewayClient.UsageUpdated += OnUsageUpdated;
|
||||
|
||||
await _gatewayClient.ConnectAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Reconnection failed", ex);
|
||||
ShowErrorToast("Reconnection Failed", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDoubleClick(object? sender, EventArgs e) => OnOpenWebUI(sender, e);
|
||||
private void OnExit(object? sender, EventArgs e) => ExitThread();
|
||||
|
||||
// --- Cleanup ---
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
Logger.Info("Application shutting down");
|
||||
_globalHotkey?.Dispose();
|
||||
_healthCheckTimer?.Dispose();
|
||||
_sessionPollTimer?.Dispose();
|
||||
_gatewayClient?.Dispose();
|
||||
_notifyIcon?.Dispose();
|
||||
_contextMenu?.Dispose();
|
||||
Logger.Shutdown();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
protected override void ExitThreadCore()
|
||||
{
|
||||
if (_notifyIcon != null) _notifyIcon.Visible = false;
|
||||
base.ExitThreadCore();
|
||||
}
|
||||
}
|
||||
|
||||
176
src/Moltbot.Tray/WebChatForm.cs
Normal file
176
src/Moltbot.Tray/WebChatForm.cs
Normal file
@ -0,0 +1,176 @@
|
||||
using Microsoft.Web.WebView2.WinForms;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace MoltbotTray;
|
||||
|
||||
/// <summary>
|
||||
/// Embeds the Clawdbot WebChat UI via WebView2, matching the macOS native chat panel.
|
||||
/// </summary>
|
||||
public class WebChatForm : Form
|
||||
{
|
||||
private WebView2? _webView;
|
||||
private readonly string _gatewayUrl;
|
||||
private readonly string _token;
|
||||
private ToolStrip? _toolbar;
|
||||
private bool _initialized;
|
||||
|
||||
private static WebChatForm? _instance;
|
||||
|
||||
/// <summary>
|
||||
/// Show or focus the singleton WebChat window.
|
||||
/// </summary>
|
||||
public static void ShowOrFocus(string gatewayUrl, string token)
|
||||
{
|
||||
if (_instance != null && !_instance.IsDisposed)
|
||||
{
|
||||
_instance.BringToFront();
|
||||
_instance.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
_instance = new WebChatForm(gatewayUrl, token);
|
||||
_instance.Show();
|
||||
}
|
||||
|
||||
private WebChatForm(string gatewayUrl, string token)
|
||||
{
|
||||
_gatewayUrl = gatewayUrl;
|
||||
_token = token;
|
||||
InitializeComponent();
|
||||
_ = InitializeWebViewAsync();
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
Text = "Clawdbot Chat";
|
||||
Size = new Size(520, 750);
|
||||
MinimumSize = new Size(380, 450);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
Icon = IconHelper.GetLobsterIcon();
|
||||
BackColor = Color.FromArgb(30, 30, 30);
|
||||
|
||||
// Toolbar
|
||||
_toolbar = new ToolStrip
|
||||
{
|
||||
GripStyle = ToolStripGripStyle.Hidden,
|
||||
RenderMode = ToolStripRenderMode.System,
|
||||
BackColor = Color.FromArgb(45, 45, 45),
|
||||
ForeColor = Color.White
|
||||
};
|
||||
|
||||
var homeBtn = new ToolStripButton("🏠 Home") { ForeColor = Color.White };
|
||||
homeBtn.Click += (_, _) => NavigateToChat();
|
||||
|
||||
var refreshBtn = new ToolStripButton("↻ Refresh") { ForeColor = Color.White };
|
||||
refreshBtn.Click += (_, _) => _webView?.Reload();
|
||||
|
||||
var popoutBtn = new ToolStripButton("↗ Browser") { ForeColor = Color.White };
|
||||
popoutBtn.Click += (_, _) =>
|
||||
{
|
||||
var url = _gatewayUrl.Replace("ws://", "http://").Replace("wss://", "https://");
|
||||
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo($"{url}?token={Uri.EscapeDataString(_token)}") { UseShellExecute = true }); }
|
||||
catch { }
|
||||
};
|
||||
|
||||
var devToolsBtn = new ToolStripButton("🔧 DevTools") { ForeColor = Color.White };
|
||||
devToolsBtn.Click += (_, _) => _webView?.CoreWebView2?.OpenDevToolsWindow();
|
||||
|
||||
_toolbar.Items.Add(homeBtn);
|
||||
_toolbar.Items.Add(refreshBtn);
|
||||
_toolbar.Items.Add(popoutBtn);
|
||||
_toolbar.Items.Add(new ToolStripSeparator());
|
||||
_toolbar.Items.Add(devToolsBtn);
|
||||
|
||||
// WebView2 fills remaining space
|
||||
_webView = new WebView2
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
DefaultBackgroundColor = Color.FromArgb(30, 30, 30)
|
||||
};
|
||||
|
||||
// Controls layout — toolbar on top, webview fills rest
|
||||
Controls.Add(_webView);
|
||||
Controls.Add(_toolbar);
|
||||
_toolbar.Dock = DockStyle.Top;
|
||||
}
|
||||
|
||||
private async Task InitializeWebViewAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use a dedicated user data folder
|
||||
var userDataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"MoltbotTray", "WebView2");
|
||||
|
||||
var env = await CoreWebView2Environment.CreateAsync(
|
||||
userDataFolder: userDataDir);
|
||||
|
||||
await _webView!.EnsureCoreWebView2Async(env);
|
||||
|
||||
// Configure WebView2
|
||||
var settings = _webView.CoreWebView2.Settings;
|
||||
settings.IsStatusBarEnabled = false;
|
||||
settings.AreDefaultContextMenusEnabled = true;
|
||||
settings.IsZoomControlEnabled = true;
|
||||
|
||||
_initialized = true;
|
||||
Logger.Info("WebView2 initialized");
|
||||
|
||||
NavigateToChat();
|
||||
}
|
||||
catch (WebView2RuntimeNotFoundException)
|
||||
{
|
||||
Logger.Error("WebView2 runtime not found");
|
||||
var result = MessageBox.Show(
|
||||
"The Microsoft WebView2 Runtime is required for the chat panel.\n\n" +
|
||||
"Would you like to download it?",
|
||||
"WebView2 Required",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Warning);
|
||||
|
||||
if (result == DialogResult.Yes)
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(
|
||||
"https://developer.microsoft.com/en-us/microsoft-edge/webview2/")
|
||||
{ UseShellExecute = true });
|
||||
}
|
||||
Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("WebView2 init failed", ex);
|
||||
MessageBox.Show($"Failed to initialize chat panel:\n{ex.Message}",
|
||||
"Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
private void NavigateToChat()
|
||||
{
|
||||
if (!_initialized || _webView?.CoreWebView2 == null) return;
|
||||
|
||||
// Convert ws:// to http:// for the web UI
|
||||
var httpUrl = _gatewayUrl
|
||||
.Replace("ws://", "http://")
|
||||
.Replace("wss://", "https://");
|
||||
|
||||
// The gateway serves WebChat at the root with token auth
|
||||
var chatUrl = $"{httpUrl}?token={Uri.EscapeDataString(_token)}";
|
||||
_webView.CoreWebView2.Navigate(chatUrl);
|
||||
Logger.Info($"Navigating to WebChat: {httpUrl}");
|
||||
}
|
||||
|
||||
protected override void OnFormClosed(FormClosedEventArgs e)
|
||||
{
|
||||
_webView?.Dispose();
|
||||
_instance = null;
|
||||
base.OnFormClosed(e);
|
||||
}
|
||||
}
|
||||
|
||||
48
src/Moltbot.Tray/build.bat
Normal file
48
src/Moltbot.Tray/build.bat
Normal file
@ -0,0 +1,48 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
echo ===================================
|
||||
echo Clawdbot Windows Tray - Build
|
||||
echo ===================================
|
||||
echo.
|
||||
|
||||
:: Detect architecture
|
||||
if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
|
||||
set RID=win-arm64
|
||||
) else (
|
||||
set RID=win-x64
|
||||
)
|
||||
|
||||
echo Architecture: %RID%
|
||||
echo.
|
||||
|
||||
:: Build
|
||||
echo [1/3] Building Debug...
|
||||
dotnet build -c Debug -r %RID%
|
||||
if errorlevel 1 goto :error
|
||||
|
||||
echo.
|
||||
echo [2/3] Building Release...
|
||||
dotnet build -c Release -r %RID%
|
||||
if errorlevel 1 goto :error
|
||||
|
||||
echo.
|
||||
echo [3/3] Publishing self-contained...
|
||||
dotnet publish -c Release -r %RID% --self-contained -p:PublishSingleFile=true -o publish
|
||||
if errorlevel 1 goto :error
|
||||
|
||||
echo.
|
||||
echo ===================================
|
||||
echo Build complete!
|
||||
echo Output: publish\ClawdbotTray.exe
|
||||
echo Architecture: %RID%
|
||||
echo ===================================
|
||||
goto :end
|
||||
|
||||
:error
|
||||
echo.
|
||||
echo BUILD FAILED
|
||||
exit /b 1
|
||||
|
||||
:end
|
||||
endlocal
|
||||
BIN
src/Moltbot.Tray/moltbot.ico
Normal file
BIN
src/Moltbot.Tray/moltbot.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 488 B |
26
src/Moltbot.Tray/screenshots/README.md
Normal file
26
src/Moltbot.Tray/screenshots/README.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Screenshots
|
||||
|
||||
This directory contains screenshots for the README and documentation.
|
||||
|
||||
## Required Screenshots
|
||||
|
||||
1. **tray-menu.png** - System tray icon with context menu open
|
||||
2. **settings-dialog.png** - Settings configuration dialog
|
||||
3. **quick-send-dialog.png** - Quick send message dialog
|
||||
4. **notification-example.png** - Example of Windows toast notification
|
||||
|
||||
## Taking Screenshots
|
||||
|
||||
1. Run the application
|
||||
2. Right-click the tray icon to show the menu
|
||||
3. Open various dialogs and take screenshots
|
||||
4. Use tools like Snipping Tool or Windows+Shift+S
|
||||
5. Save as PNG files with descriptive names
|
||||
|
||||
## Image Guidelines
|
||||
|
||||
- Use PNG format for transparency support
|
||||
- Keep file sizes reasonable (< 500KB each)
|
||||
- Show the application in a clean Windows environment
|
||||
- Include relevant context (Windows version, other tray icons, etc.)
|
||||
- Ensure text is readable at normal viewing sizes
|
||||
86
src/Moltbot.Tray/workflows/build.yml
Normal file
86
src/Moltbot.Tray/workflows/build.yml
Normal file
@ -0,0 +1,86 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
tags: [ 'v*' ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
rid: [win-x64, win-arm64]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore -r ${{ matrix.rid }}
|
||||
|
||||
- name: Build Debug
|
||||
run: dotnet build --no-restore -c Debug -r ${{ matrix.rid }}
|
||||
|
||||
- name: Build Release
|
||||
run: dotnet build --no-restore -c Release -r ${{ matrix.rid }}
|
||||
|
||||
- name: Publish Self-Contained
|
||||
run: dotnet publish -c Release -r ${{ matrix.rid }} --self-contained -p:PublishSingleFile=true -o publish-${{ matrix.rid }}
|
||||
|
||||
- name: Upload Build Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: clawdbot-windows-tray-${{ matrix.rid }}
|
||||
path: publish-${{ matrix.rid }}/
|
||||
|
||||
release:
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download x64 artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: clawdbot-windows-tray-win-x64
|
||||
path: artifacts/win-x64
|
||||
|
||||
- name: Download arm64 artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: clawdbot-windows-tray-win-arm64
|
||||
path: artifacts/win-arm64
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/win-x64/ClawdbotTray.exe
|
||||
artifacts/win-arm64/ClawdbotTray.exe
|
||||
body: |
|
||||
## Clawdbot Windows Tray ${{ github.ref_name }}
|
||||
|
||||
### Downloads
|
||||
- **x64**: `ClawdbotTray.exe` (Intel/AMD 64-bit)
|
||||
- **arm64**: `ClawdbotTray.exe` (ARM64 — Windows on ARM)
|
||||
|
||||
### Requirements
|
||||
- Windows 10 version 1903 or later
|
||||
- [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) (for embedded chat)
|
||||
- Clawdbot gateway running on your network
|
||||
|
||||
### Quick Start
|
||||
1. Download the executable for your architecture
|
||||
2. Run `ClawdbotTray.exe`
|
||||
3. Right-click tray icon → Settings
|
||||
4. Enter your gateway URL and token
|
||||
Loading…
Reference in New Issue
Block a user