diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7407bf2a45..2d2c65c563 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,3 +119,99 @@ jobs: fi "$BIN" usage --provider codex --web 2>&1 | tee /tmp/codexbarcli-stderr.txt >/dev/null || true grep -q "macOS" /tmp/codexbarcli-stderr.txt + + build-windows-tray: + name: Windows tray (${{ matrix.rid }}) + timeout-minutes: 35 + runs-on: ${{ matrix.rid == 'win-arm64' && 'windows-11-arm' || 'windows-latest' }} + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_SIGNING_ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT || 'https://wus2.codesigning.azure.net/' }} + AZURE_SIGNING_ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME || 'hanselman' }} + AZURE_CERTIFICATE_PROFILE_NAME: ${{ vars.AZURE_CERTIFICATE_PROFILE_NAME || 'WindowsEdgeLight' }} + AZURE_TIMESTAMP_RFC3161: ${{ vars.AZURE_TIMESTAMP_RFC3161 || 'https://timestamp.acs.microsoft.com' }} + strategy: + fail-fast: false + matrix: + rid: [win-x64, win-arm64] + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 8.0.x + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ matrix.rid }}-${{ hashFiles('Windows/**/*.csproj') }} + restore-keys: nuget-${{ runner.os }}-${{ matrix.rid }}- + + - name: Test Windows core + shell: pwsh + run: ./Scripts/build_windows.ps1 test + + - name: Publish native tray + shell: pwsh + run: ./Scripts/build_windows.ps1 publish -Runtime ${{ matrix.rid }} + + - name: Azure Login for Signing + if: startsWith(github.ref, 'refs/tags/v') && env.AZURE_TENANT_ID != '' && env.AZURE_CLIENT_ID != '' && env.AZURE_CLIENT_SECRET != '' && env.AZURE_SUBSCRIPTION_ID != '' + uses: azure/login@v3 + with: + creds: '{"clientId":"${{ env.AZURE_CLIENT_ID }}","clientSecret":"${{ env.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ env.AZURE_SUBSCRIPTION_ID }}","tenantId":"${{ env.AZURE_TENANT_ID }}"}' + + - name: Sign tray executable + if: startsWith(github.ref, 'refs/tags/v') && env.AZURE_TENANT_ID != '' && env.AZURE_CLIENT_ID != '' && env.AZURE_CLIENT_SECRET != '' && env.AZURE_SUBSCRIPTION_ID != '' + uses: azure/trusted-signing-action@v2 + with: + azure-tenant-id: ${{ env.AZURE_TENANT_ID }} + azure-client-id: ${{ env.AZURE_CLIENT_ID }} + azure-client-secret: ${{ env.AZURE_CLIENT_SECRET }} + endpoint: ${{ env.AZURE_SIGNING_ENDPOINT }} + signing-account-name: ${{ env.AZURE_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ env.AZURE_CERTIFICATE_PROFILE_NAME }} + files-folder: publish/windows/${{ matrix.rid }} + files-folder-filter: exe + files-folder-recurse: true + file-digest: SHA256 + timestamp-rfc3161: ${{ env.AZURE_TIMESTAMP_RFC3161 }} + timestamp-digest: SHA256 + + - name: Install Inno Setup + shell: pwsh + run: choco install innosetup --no-progress -y + + - name: Build installer + shell: pwsh + run: ./Scripts/build_windows.ps1 installer -Runtime ${{ matrix.rid }} -NoPublish + + - name: Sign installer + if: startsWith(github.ref, 'refs/tags/v') && env.AZURE_TENANT_ID != '' && env.AZURE_CLIENT_ID != '' && env.AZURE_CLIENT_SECRET != '' && env.AZURE_SUBSCRIPTION_ID != '' + uses: azure/trusted-signing-action@v2 + with: + azure-tenant-id: ${{ env.AZURE_TENANT_ID }} + azure-client-id: ${{ env.AZURE_CLIENT_ID }} + azure-client-secret: ${{ env.AZURE_CLIENT_SECRET }} + endpoint: ${{ env.AZURE_SIGNING_ENDPOINT }} + signing-account-name: ${{ env.AZURE_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ env.AZURE_CERTIFICATE_PROFILE_NAME }} + files-folder: Output + files-folder-filter: exe + files-folder-recurse: false + file-digest: SHA256 + timestamp-rfc3161: ${{ env.AZURE_TIMESTAMP_RFC3161 }} + timestamp-digest: SHA256 + + - name: Upload Windows tray artifacts + uses: actions/upload-artifact@v7 + with: + name: codexbar-windows-${{ matrix.rid }} + path: | + publish/windows/${{ matrix.rid }}/ + Output/CodexBar-Setup-*.exe diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index b9ad64bdc5..ed4167da8a 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -245,6 +245,166 @@ jobs: ${{ steps.pkg.outputs.out_dir }}/${{ steps.pkg.outputs.asset }} ${{ steps.pkg.outputs.out_dir }}/${{ steps.pkg.outputs.asset }}.sha256 + build-windows: + strategy: + fail-fast: false + matrix: + include: + - rid: win-x64 + arch: x64 + runs-on: windows-latest + - rid: win-arm64 + arch: arm64 + runs-on: windows-11-arm + runs-on: ${{ matrix.runs-on }} + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_SIGNING_ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT || 'https://wus2.codesigning.azure.net/' }} + AZURE_SIGNING_ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME || 'hanselman' }} + AZURE_CERTIFICATE_PROFILE_NAME: ${{ vars.AZURE_CERTIFICATE_PROFILE_NAME || 'WindowsEdgeLight' }} + AZURE_TIMESTAMP_RFC3161: ${{ vars.AZURE_TIMESTAMP_RFC3161 || 'https://timestamp.acs.microsoft.com' }} + steps: + - uses: actions/checkout@v6 + + - name: Validate release tag + if: github.event_name == 'release' || inputs.tag != '' + shell: pwsh + run: | + if ([string]::IsNullOrWhiteSpace($env:RELEASE_TAG)) { + throw "Missing release tag." + } + if ($env:RELEASE_TAG -notmatch '^v[0-9A-Za-z._-]+$') { + throw "Invalid release tag: $env:RELEASE_TAG" + } + + - name: Resolve Windows build version + id: winver + shell: pwsh + run: | + if ($env:RELEASE_TAG -match '^v[0-9A-Za-z._-]+$') { + $version = $env:RELEASE_TAG.TrimStart('v') + $assetSuffix = $env:RELEASE_TAG + } else { + $version = "0.0.0-dev" + $assetSuffix = ($env:RELEASE_TAG -replace '[^0-9A-Za-z._-]', '-') + } + "version=$version" >> $env:GITHUB_OUTPUT + "asset_suffix=$assetSuffix" >> $env:GITHUB_OUTPUT + + - name: Require signing secrets for release assets + if: github.event_name == 'release' && (env.AZURE_TENANT_ID == '' || env.AZURE_CLIENT_ID == '' || env.AZURE_CLIENT_SECRET == '' || env.AZURE_SUBSCRIPTION_ID == '') + shell: pwsh + run: throw "Windows release assets require Azure Trusted Signing secrets." + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 8.0.x + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ matrix.rid }}-${{ hashFiles('Windows/**/*.csproj') }} + restore-keys: nuget-${{ runner.os }}-${{ matrix.rid }}- + + - name: Test Windows core + shell: pwsh + run: ./Scripts/build_windows.ps1 test + + - name: Publish native tray + shell: pwsh + run: ./Scripts/build_windows.ps1 publish -Runtime ${{ matrix.rid }} -Version ${{ steps.winver.outputs.version }} + + - name: Azure Login for Signing + if: env.AZURE_TENANT_ID != '' && env.AZURE_CLIENT_ID != '' && env.AZURE_CLIENT_SECRET != '' && env.AZURE_SUBSCRIPTION_ID != '' + uses: azure/login@v3 + with: + creds: '{"clientId":"${{ env.AZURE_CLIENT_ID }}","clientSecret":"${{ env.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ env.AZURE_SUBSCRIPTION_ID }}","tenantId":"${{ env.AZURE_TENANT_ID }}"}' + + - name: Sign tray executable + if: env.AZURE_TENANT_ID != '' && env.AZURE_CLIENT_ID != '' && env.AZURE_CLIENT_SECRET != '' && env.AZURE_SUBSCRIPTION_ID != '' + uses: azure/trusted-signing-action@v2 + with: + azure-tenant-id: ${{ env.AZURE_TENANT_ID }} + azure-client-id: ${{ env.AZURE_CLIENT_ID }} + azure-client-secret: ${{ env.AZURE_CLIENT_SECRET }} + endpoint: ${{ env.AZURE_SIGNING_ENDPOINT }} + signing-account-name: ${{ env.AZURE_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ env.AZURE_CERTIFICATE_PROFILE_NAME }} + files-folder: publish/windows/${{ matrix.rid }} + files-folder-filter: exe + files-folder-recurse: true + file-digest: SHA256 + timestamp-rfc3161: ${{ env.AZURE_TIMESTAMP_RFC3161 }} + timestamp-digest: SHA256 + + - name: Install Inno Setup + shell: pwsh + run: choco install innosetup --no-progress -y + + - name: Build installer + shell: pwsh + run: ./Scripts/build_windows.ps1 installer -Runtime ${{ matrix.rid }} -Version ${{ steps.winver.outputs.version }} -NoPublish + + - name: Sign installer + if: env.AZURE_TENANT_ID != '' && env.AZURE_CLIENT_ID != '' && env.AZURE_CLIENT_SECRET != '' && env.AZURE_SUBSCRIPTION_ID != '' + uses: azure/trusted-signing-action@v2 + with: + azure-tenant-id: ${{ env.AZURE_TENANT_ID }} + azure-client-id: ${{ env.AZURE_CLIENT_ID }} + azure-client-secret: ${{ env.AZURE_CLIENT_SECRET }} + endpoint: ${{ env.AZURE_SIGNING_ENDPOINT }} + signing-account-name: ${{ env.AZURE_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ env.AZURE_CERTIFICATE_PROFILE_NAME }} + files-folder: Output + files-folder-filter: exe + files-folder-recurse: false + file-digest: SHA256 + timestamp-rfc3161: ${{ env.AZURE_TIMESTAMP_RFC3161 }} + timestamp-digest: SHA256 + + - name: Package release assets + id: pkg + shell: pwsh + run: | + $zip = "CodexBarWindows-${{ steps.winver.outputs.asset_suffix }}-${{ matrix.rid }}.zip" + $installer = "CodexBar-Setup-${{ matrix.arch }}.exe" + Compress-Archive -Path "publish/windows/${{ matrix.rid }}/*" -DestinationPath $zip -Force + Get-FileHash -Algorithm SHA256 $zip | ForEach-Object { "$($_.Hash.ToLowerInvariant()) $zip" } | Set-Content "$zip.sha256" + Get-FileHash -Algorithm SHA256 "Output/$installer" | ForEach-Object { "$($_.Hash.ToLowerInvariant()) $installer" } | Set-Content "$installer.sha256" + "zip=$zip" >> $env:GITHUB_OUTPUT + "installer=Output/$installer" >> $env:GITHUB_OUTPUT + "installer_sha=$installer.sha256" >> $env:GITHUB_OUTPUT + + - name: Upload release assets + if: github.event_name == 'release' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + gh release upload $env:RELEASE_TAG ` + "${{ steps.pkg.outputs.zip }}" ` + "${{ steps.pkg.outputs.zip }}.sha256" ` + "${{ steps.pkg.outputs.installer }}" ` + "${{ steps.pkg.outputs.installer_sha }}" ` + --clobber + + - name: Upload workflow artifact (manual runs) + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v6 + with: + name: codexbar-windows-${{ matrix.rid }} + path: | + ${{ steps.pkg.outputs.zip }} + ${{ steps.pkg.outputs.zip }}.sha256 + ${{ steps.pkg.outputs.installer }} + ${{ steps.pkg.outputs.installer_sha }} + update-homebrew-tap: runs-on: ubuntu-latest needs: build-cli diff --git a/.gitignore b/.gitignore index 8bb2ec1f8f..b01dc800d3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ xcuserdata/ # Build products .build/ DerivedData +Windows/**/bin/ +Windows/**/obj/ +publish/ +Output/ # Bundles / artifacts # Main app bundle (any variation) diff --git a/CHANGELOG.md b/CHANGELOG.md index edd747a84c..a50844bfe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.32.5 — Unreleased +### Added +- Windows: add a native notification-area companion with provider snapshot/command probes, self-contained publish targets, installer packaging, CI artifacts, and Azure Trusted Signing hooks. + ### Fixed - Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286, #1314). Thanks @hhh2210! - Menu bar: observe a compact icon-state signature so merged status icons no longer redraw for provider snapshot changes that cannot affect the visible icon (#1297). Thanks @hhh2210! diff --git a/README.md b/README.md index 4d8af8a2d1..d399fb0680 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,11 @@ Or download release tarballs from GitHub Releases: - macOS: `CodexBarCLI-v-macos-arm64.tar.gz`, `CodexBarCLI-v-macos-x86_64.tar.gz` - Linux: `CodexBarCLI-v-linux-aarch64.tar.gz`, `CodexBarCLI-v-linux-x86_64.tar.gz` +### Windows native tray +CodexBar now has a native Windows notification-area companion with self-contained `win-x64` and `win-arm64` builds. It reads provider quota snapshots or command probes from `%APPDATA%\CodexBar\windows-settings.json`. + +See [CodexBar for Windows](docs/windows.md) for build, installer, and probe configuration. + ### First run - Open Settings → Providers and enable what you use. - Install/sign in to the provider sources you rely on: CLIs, browser sessions, OAuth/device flow, API keys, local app files, or provider apps depending on the provider. @@ -214,8 +219,9 @@ CLI install: - 🧳 [MCPorter](https://mcporter.dev) — TypeScript toolkit + CLI for Model Context Protocol servers. - 🧿 [oracle](https://askoracle.dev) — Ask the oracle when you're stuck. Invoke GPT-5 Pro with a custom context and files. -## Looking for a Windows version? -- [Win-CodexBar](https://github.com/Finesssee/Win-CodexBar) +## Windows desktop integration +- Native CodexBar Windows tray companion: [docs/windows.md](docs/windows.md). +- Community app: [Win-CodexBar](https://github.com/Finesssee/Win-CodexBar). ## Linux desktop integration? - [codexbar-waybar](https://github.com/Marouan-chak/codexbar-waybar) — Waybar custom module + GTK4 popover for Hyprland / Sway / other Wayland compositors, built on top of the bundled Linux CLI. diff --git a/Scripts/build_windows.ps1 b/Scripts/build_windows.ps1 new file mode 100644 index 0000000000..3077cee015 --- /dev/null +++ b/Scripts/build_windows.ps1 @@ -0,0 +1,117 @@ +param( + [ValidateSet("build", "test", "publish", "installer", "run")] + [string]$Command = "build", + + [ValidateSet("win-x64", "win-arm64")] + [string]$Runtime = "win-x64", + + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release", + + [string]$Version = "", + + [switch]$NoPublish, + + [switch]$InstallInno +) + +$ErrorActionPreference = "Stop" + +$root = Resolve-Path (Join-Path $PSScriptRoot "..") +$appProject = Join-Path $root "Windows/CodexBar.Windows/CodexBar.Windows.csproj" +$testProject = Join-Path $root "Windows/CodexBar.Windows.Tests/CodexBar.Windows.Tests.csproj" +$publishDir = Join-Path $root "publish/windows/$Runtime" + +function Resolve-InnoCompiler { + $candidates = @( + "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe", + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "$env:ProgramFiles\Inno Setup 6\ISCC.exe" + ) + + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path -LiteralPath $candidate)) { + return (Resolve-Path -LiteralPath $candidate).Path + } + } + + $command = Get-Command ISCC.exe -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + if ($InstallInno) { + winget install --id JRSoftware.InnoSetup -e --accept-source-agreements --accept-package-agreements --disable-interactivity + if ($LASTEXITCODE -ne 0) { + throw "winget failed to install Inno Setup." + } + return (Resolve-InnoCompiler) + } + + throw "Inno Setup compiler (ISCC.exe) was not found. Install it, or rerun with -InstallInno." +} + +function Convert-RuntimeToArch { + if ($Runtime -eq "win-arm64") { + return "arm64" + } + + return "x64" +} + +function Resolve-BuildVersion { + if (-not [string]::IsNullOrWhiteSpace($Version)) { + return $Version + } + + [xml]$project = Get-Content -LiteralPath $appProject + $projectVersion = @($project.Project.PropertyGroup.Version | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) | Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($projectVersion)) { + throw "Missing in $appProject. Pass -Version explicitly." + } + + return $projectVersion +} + +$resolvedVersion = Resolve-BuildVersion + +switch ($Command) { + "build" { + dotnet build $appProject -c $Configuration -r $Runtime -p:Version=$resolvedVersion + } + "test" { + dotnet test $testProject -c $Configuration --verbosity normal + } + "publish" { + Remove-Item -LiteralPath $publishDir -Recurse -Force -ErrorAction SilentlyContinue + dotnet publish $appProject -c $Configuration -r $Runtime --self-contained true ` + -p:Version=$resolvedVersion ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:PublishReadyToRun=true ` + -o $publishDir + } + "installer" { + if (-not $NoPublish) { + & $PSCommandPath publish -Runtime $Runtime -Configuration $Configuration -Version $resolvedVersion + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + } + + $exePath = Join-Path $publishDir "CodexBar.Windows.exe" + if (-not (Test-Path -LiteralPath $exePath)) { + throw "Missing published tray executable at $exePath. Rerun without -NoPublish." + } + + $iscc = Resolve-InnoCompiler + $arch = Convert-RuntimeToArch + & $iscc "/DMyAppVersion=$resolvedVersion" "/DMyAppArch=$arch" "/Dpublish=$publishDir" (Join-Path $root "installer.iss") + if ($LASTEXITCODE -ne 0) { + throw "ISCC failed for $Runtime." + } + } + "run" { + dotnet run --project $appProject -c Debug + } +} diff --git a/Windows/CodexBar.Windows.Core/CodexBar.Windows.Core.csproj b/Windows/CodexBar.Windows.Core/CodexBar.Windows.Core.csproj new file mode 100644 index 0000000000..e8cd599232 --- /dev/null +++ b/Windows/CodexBar.Windows.Core/CodexBar.Windows.Core.csproj @@ -0,0 +1,7 @@ + + + net8.0 + enable + enable + + diff --git a/Windows/CodexBar.Windows.Core/ProviderProbeRunner.cs b/Windows/CodexBar.Windows.Core/ProviderProbeRunner.cs new file mode 100644 index 0000000000..a0a146d0ff --- /dev/null +++ b/Windows/CodexBar.Windows.Core/ProviderProbeRunner.cs @@ -0,0 +1,302 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace CodexBar.Windows.Core; + +public sealed class ProviderProbeRunner +{ + private const int MaxProbeStreamCharacters = 1024 * 1024; + + public async Task> LoadAsync( + IReadOnlyList providers, + CancellationToken cancellationToken) + { + var snapshots = new List(providers.Count); + foreach (var provider in providers.Where(provider => provider.Enabled)) + { + snapshots.Add(await LoadProviderAsync(provider, cancellationToken).ConfigureAwait(false)); + } + + return snapshots; + } + + public async Task LoadProviderAsync( + ProviderProbeSettings provider, + CancellationToken cancellationToken) + { + try + { + if (!string.IsNullOrWhiteSpace(provider.SnapshotPath)) + { + return await LoadSnapshotFileAsync(provider, cancellationToken).ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(provider.Command)) + { + return await LoadCommandAsync(provider, cancellationToken).ConfigureAwait(false); + } + + return ProviderSnapshot.Unknown(provider.Id, provider.Name, "No snapshot path or command configured."); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return ProviderSnapshot.Failed(provider.Id, provider.Name, exception.Message); + } + } + + private static async Task LoadSnapshotFileAsync( + ProviderProbeSettings provider, + CancellationToken cancellationToken) + { + var path = Environment.ExpandEnvironmentVariables(provider.SnapshotPath!); + if (!File.Exists(path)) + { + return ProviderSnapshot.Unknown(provider.Id, provider.Name, $"Snapshot not found: {path}"); + } + + var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false); + return ProviderSnapshotJson.ParseSnapshot(json, provider); + } + + private static async Task LoadCommandAsync( + ProviderProbeSettings provider, + CancellationToken cancellationToken) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(provider.TimeoutSeconds)); + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + + var startInfo = new ProcessStartInfo + { + FileName = Environment.ExpandEnvironmentVariables(provider.Command!), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + if (!string.IsNullOrWhiteSpace(provider.WorkingDirectory)) + { + startInfo.WorkingDirectory = Environment.ExpandEnvironmentVariables(provider.WorkingDirectory); + } + + foreach (var argument in provider.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo) ?? + throw new InvalidOperationException($"Unable to start {provider.Command}."); + + var stdoutTask = ReadBoundedAsync(process.StandardOutput, linkedCancellation.Token); + var stderrTask = ReadBoundedAsync(process.StandardError, linkedCancellation.Token); + try + { + var exitTask = process.WaitForExitAsync(linkedCancellation.Token); + var firstCompleted = await Task.WhenAny(exitTask, stdoutTask, stderrTask).ConfigureAwait(false); + await firstCompleted.ConfigureAwait(false); + await exitTask.ConfigureAwait(false); + + var stdout = await stdoutTask.ConfigureAwait(false); + var stderr = await stderrTask.ConfigureAwait(false); + if (process.ExitCode != 0) + { + var message = string.IsNullOrWhiteSpace(stderr) ? $"exit code {process.ExitCode}" : stderr.Trim(); + throw new InvalidOperationException($"{provider.Command} failed: {message}"); + } + + var payload = ExtractJsonPayload(stdout); + return ProviderSnapshotJson.ParseSnapshot(payload, provider); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + await KillTimedOutProcessAsync(process, stdoutTask, stderrTask).ConfigureAwait(false); + throw new TimeoutException($"{provider.Command} timed out after {provider.TimeoutSeconds} seconds."); + } + catch (ProbeOutputLimitExceededException exception) + { + await KillTimedOutProcessAsync(process, stdoutTask, stderrTask).ConfigureAwait(false); + throw new InvalidOperationException($"{provider.Command} {exception.Message}"); + } + } + + private static async Task ReadBoundedAsync(TextReader reader, CancellationToken cancellationToken) + { + var buffer = new char[4096]; + var builder = new StringBuilder(); + while (true) + { + var read = await reader.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) + { + return builder.ToString(); + } + + if (builder.Length + read > MaxProbeStreamCharacters) + { + throw new ProbeOutputLimitExceededException( + $"output exceeded {MaxProbeStreamCharacters} characters."); + } + + builder.Append(buffer, 0, read); + } + } + + private static async Task KillTimedOutProcessAsync( + Process process, + Task stdoutTask, + Task stderrTask) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch + { + } + + try + { + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + } + catch + { + } + + await IgnoreReadFailureAsync(stdoutTask).ConfigureAwait(false); + await IgnoreReadFailureAsync(stderrTask).ConfigureAwait(false); + } + + private static async Task IgnoreReadFailureAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch + { + } + } + + private static string ExtractJsonPayload(string stdout) + { + var trimmed = stdout.Trim(); + if (IsCompleteJsonPayload(trimmed) && CanParseJson(trimmed)) + { + return trimmed; + } + + for (var index = 0; index < trimmed.Length; index++) + { + var start = trimmed[index]; + if (start != '{' && start != '[') + { + continue; + } + + var candidate = ReadBalancedJson(trimmed, index); + if (candidate is not null && CanParseJson(candidate)) + { + return candidate; + } + } + + throw new InvalidOperationException( + $"Probe did not print a JSON object or array. Output preview: {FormatOutputPreview(trimmed)}"); + } + + private static bool IsCompleteJsonPayload(string candidate) + { + return candidate.StartsWith('{') && candidate.EndsWith('}') || + candidate.StartsWith('[') && candidate.EndsWith(']'); + } + + private static string? ReadBalancedJson(string text, int startIndex) + { + var stack = new Stack(); + var inString = false; + var escaped = false; + + for (var index = startIndex; index < text.Length; index++) + { + var character = text[index]; + if (inString) + { + if (escaped) + { + escaped = false; + } + else if (character == '\\') + { + escaped = true; + } + else if (character == '"') + { + inString = false; + } + + continue; + } + + if (character == '"') + { + inString = true; + continue; + } + + if (character == '{') + { + stack.Push('}'); + continue; + } + + if (character == '[') + { + stack.Push(']'); + continue; + } + + if ((character == '}' || character == ']') && (stack.Count == 0 || stack.Pop() != character)) + { + return null; + } + + if (stack.Count == 0) + { + return text[startIndex..(index + 1)]; + } + } + + return null; + } + + private static bool CanParseJson(string candidate) + { + try + { + using var document = JsonDocument.Parse(candidate); + return true; + } + catch (JsonException) + { + return false; + } + } + + private static string FormatOutputPreview(string output) + { + const int MaxPreviewLength = 512; + var compact = output.Replace('\r', ' ').Replace('\n', ' ').Trim(); + if (compact.Length <= MaxPreviewLength) + { + return compact; + } + + return $"{compact[..MaxPreviewLength]}..."; + } + + private sealed class ProbeOutputLimitExceededException(string message) : Exception(message); +} diff --git a/Windows/CodexBar.Windows.Core/ProviderSnapshot.cs b/Windows/CodexBar.Windows.Core/ProviderSnapshot.cs new file mode 100644 index 0000000000..3591bb9702 --- /dev/null +++ b/Windows/CodexBar.Windows.Core/ProviderSnapshot.cs @@ -0,0 +1,446 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodexBar.Windows.Core; + +public enum ProviderHealth +{ + Unknown, + Healthy, + Busy, + Warning, + Failing, +} + +public sealed record ProviderSnapshot( + string Id, + string Name, + ProviderHealth Health, + string? Window, + double? Remaining, + double? Limit, + string? Unit, + DateTimeOffset? ResetsAt, + DateTimeOffset? UpdatedAt, + string? Detail, + string? SourceUrl) +{ + public double? UsageFraction + { + get + { + if (Remaining is null || Limit is null || Limit <= 0) + { + return null; + } + + return Math.Clamp(1 - Remaining.Value / Limit.Value, 0, 1); + } + } + + public string Summary + { + get + { + var window = string.IsNullOrWhiteSpace(Window) ? "usage" : Window; + if (Remaining is not null && Limit is not null) + { + return $"{window}: {FormatNumber(Remaining.Value)} / {FormatNumber(Limit.Value)} {Unit ?? "left"}"; + } + + if (Remaining is not null) + { + return $"{window}: {FormatNumber(Remaining.Value)} {Unit ?? "left"}"; + } + + return Detail ?? "no usage snapshot"; + } + } + + public string ResetSummary + { + get + { + if (ResetsAt is null) + { + return "reset unknown"; + } + + var remaining = ResetsAt.Value - DateTimeOffset.Now; + if (remaining <= TimeSpan.Zero) + { + return $"reset {ResetsAt.Value.LocalDateTime:g}"; + } + + if (remaining.TotalHours >= 24) + { + return $"resets in {Math.Ceiling(remaining.TotalDays):0}d"; + } + + if (remaining.TotalHours >= 1) + { + return $"resets in {Math.Ceiling(remaining.TotalHours):0}h"; + } + + return $"resets in {Math.Max(1, Math.Ceiling(remaining.TotalMinutes)):0}m"; + } + } + + public string MenuLabel => $"{HealthPrefix(Health)} {Name} - {Summary} - {ResetSummary}"; + + public static ProviderSnapshot Unknown(string id, string name, string detail) + { + return new ProviderSnapshot( + id, + name, + ProviderHealth.Unknown, + Window: null, + Remaining: null, + Limit: null, + Unit: null, + ResetsAt: null, + UpdatedAt: DateTimeOffset.UtcNow, + Detail: detail, + SourceUrl: null); + } + + public static ProviderSnapshot Failed(string id, string name, string detail) + { + return new ProviderSnapshot( + id, + name, + ProviderHealth.Failing, + Window: null, + Remaining: null, + Limit: null, + Unit: null, + ResetsAt: null, + UpdatedAt: DateTimeOffset.UtcNow, + Detail: detail, + SourceUrl: null); + } + + private static string HealthPrefix(ProviderHealth health) + { + return health switch + { + ProviderHealth.Healthy => "[ok]", + ProviderHealth.Busy => "[..]", + ProviderHealth.Warning => "[!]", + ProviderHealth.Failing => "[x]", + _ => "[ ]", + }; + } + + private static string FormatNumber(double value) + { + return value % 1 == 0 + ? value.ToString("0", CultureInfo.InvariantCulture) + : value.ToString("0.##", CultureInfo.InvariantCulture); + } +} + +public sealed class ProviderSnapshotJson +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("health")] + public string? Health { get; set; } + + [JsonPropertyName("window")] + public string? Window { get; set; } + + [JsonPropertyName("remaining")] + public double? Remaining { get; set; } + + [JsonPropertyName("limit")] + public double? Limit { get; set; } + + [JsonPropertyName("unit")] + public string? Unit { get; set; } + + [JsonPropertyName("resetsAt")] + public DateTimeOffset? ResetsAt { get; set; } + + [JsonPropertyName("updatedAt")] + public DateTimeOffset? UpdatedAt { get; set; } + + [JsonPropertyName("detail")] + public string? Detail { get; set; } + + [JsonPropertyName("sourceUrl")] + public string? SourceUrl { get; set; } + + public ProviderSnapshot ToSnapshot(ProviderProbeSettings settings) + { + return new ProviderSnapshot( + Normalize(Id, settings.Id), + Normalize(Name, settings.Name), + ParseHealth(Health), + NormalizeOptional(Window), + Remaining, + Limit, + NormalizeOptional(Unit), + ResetsAt, + UpdatedAt ?? DateTimeOffset.UtcNow, + NormalizeOptional(Detail), + NormalizeOptional(SourceUrl)); + } + + public static ProviderSnapshotJson Parse(string json) + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + return JsonSerializer.Deserialize(json, options) ?? + throw new InvalidOperationException("Snapshot JSON was empty."); + } + + public static ProviderSnapshot ParseSnapshot(string json, ProviderProbeSettings settings) + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }); + + return document.RootElement.ValueKind switch + { + JsonValueKind.Object => Parse(json).ToSnapshot(settings), + JsonValueKind.Array => ParseCliPayloadArray(document.RootElement, settings), + _ => throw new InvalidOperationException("Snapshot JSON must be an object or array."), + }; + } + + private static ProviderSnapshot ParseCliPayloadArray(JsonElement payloads, ProviderProbeSettings settings) + { + var payload = SelectCliPayload(payloads, settings); + if (TryGetObject(payload, "error", out var error)) + { + return ProviderSnapshot.Failed( + Normalize(GetString(payload, "provider"), settings.Id), + settings.Name, + Normalize(GetString(error, "message"), "Provider command failed.")); + } + + var usage = TryGetObject(payload, "usage", out var usageElement) ? usageElement : default; + var credits = TryGetObject(payload, "credits", out var creditsElement) ? creditsElement : default; + var window = usage.ValueKind == JsonValueKind.Object && TryGetObject(usage, "primary", out var primary) + ? primary + : default; + var status = TryGetObject(payload, "status", out var statusElement) ? statusElement : default; + + var remaining = WindowRemainingPercent(window); + var resetsAt = GetDate(window, "resetsAt"); + var updatedAt = GetDate(usage, "updatedAt") ?? GetDate(credits, "updatedAt"); + var source = GetString(payload, "source"); + var account = GetString(payload, "account"); + var detail = JoinDetail( + account is null ? null : $"account: {account}", + source is null ? null : $"source: {source}", + GetString(status, "description"), + GetString(window, "resetDescription")); + + if (remaining is not null) + { + return new ProviderSnapshot( + Normalize(GetString(payload, "provider"), settings.Id), + settings.Name, + ParseStatusHealth(GetString(status, "indicator")), + WindowLabel(GetDouble(window, "windowMinutes")), + remaining, + Limit: 100, + Unit: "% left", + ResetsAt: resetsAt, + UpdatedAt: updatedAt ?? DateTimeOffset.UtcNow, + Detail: detail, + SourceUrl: GetString(status, "url")); + } + + var creditRemaining = GetDouble(credits, "remaining"); + return new ProviderSnapshot( + Normalize(GetString(payload, "provider"), settings.Id), + settings.Name, + ParseStatusHealth(GetString(status, "indicator")), + Window: creditRemaining is null ? null : "credits", + Remaining: creditRemaining, + Limit: null, + Unit: creditRemaining is null ? null : "credits left", + ResetsAt: resetsAt, + UpdatedAt: updatedAt ?? DateTimeOffset.UtcNow, + Detail: detail ?? "CodexBar CLI payload did not include usage limits.", + SourceUrl: GetString(status, "url")); + } + + private static JsonElement SelectCliPayload(JsonElement payloads, ProviderProbeSettings settings) + { + JsonElement? first = null; + foreach (var payload in payloads.EnumerateArray()) + { + if (payload.ValueKind != JsonValueKind.Object) + { + continue; + } + + first ??= payload; + var provider = GetString(payload, "provider"); + if (MatchesProvider(provider, settings)) + { + return payload; + } + } + + return first ?? throw new InvalidOperationException("CodexBar CLI JSON payload was empty."); + } + + private static bool MatchesProvider(string? provider, ProviderProbeSettings settings) + { + return string.Equals(provider, settings.Id, StringComparison.OrdinalIgnoreCase) || + string.Equals(provider, settings.Name, StringComparison.OrdinalIgnoreCase); + } + + private static double? WindowRemainingPercent(JsonElement window) + { + var usedPercent = GetDouble(window, "usedPercent"); + if (usedPercent is null) + { + return null; + } + + return Math.Clamp(100 - usedPercent.Value, 0, 100); + } + + private static string WindowLabel(double? windowMinutes) + { + return windowMinutes switch + { + 300 => "session", + 1440 => "daily", + 10080 => "weekly", + 43200 => "monthly", + null => "session", + _ => $"{windowMinutes:0}m", + }; + } + + private static ProviderHealth ParseStatusHealth(string? value) + { + return value?.Trim().ToLowerInvariant() switch + { + "none" => ProviderHealth.Healthy, + "minor" or "maintenance" => ProviderHealth.Warning, + "major" or "critical" => ProviderHealth.Failing, + _ => ProviderHealth.Unknown, + }; + } + + private static bool TryGetObject(JsonElement element, string propertyName, out JsonElement value) + { + if (TryGetProperty(element, propertyName, out value) && value.ValueKind == JsonValueKind.Object) + { + return true; + } + + value = default; + return false; + } + + private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + } + + value = default; + return false; + } + + private static string? GetString(JsonElement element, string propertyName) + { + if (!TryGetProperty(element, propertyName, out var value)) + { + return null; + } + + return value.ValueKind switch + { + JsonValueKind.String => NormalizeOptional(value.GetString()), + JsonValueKind.Number => value.GetRawText(), + _ => null, + }; + } + + private static double? GetDouble(JsonElement element, string propertyName) + { + return TryGetProperty(element, propertyName, out var value) && + value.ValueKind == JsonValueKind.Number && + value.TryGetDouble(out var number) + ? number + : null; + } + + private static DateTimeOffset? GetDate(JsonElement element, string propertyName) + { + if (!TryGetProperty(element, propertyName, out var value)) + { + return null; + } + + if (value.ValueKind == JsonValueKind.String && + DateTimeOffset.TryParse(value.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var date)) + { + return date; + } + + if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var seconds)) + { + return DateTimeOffset.FromUnixTimeMilliseconds((long)(seconds * 1000)); + } + + return null; + } + + private static string? JoinDetail(params string?[] parts) + { + var detail = string.Join("; ", parts.Where(part => !string.IsNullOrWhiteSpace(part))); + return string.IsNullOrWhiteSpace(detail) ? null : detail; + } + + private static ProviderHealth ParseHealth(string? value) + { + return value?.Trim().ToLowerInvariant() switch + { + "ok" or "healthy" or "green" => ProviderHealth.Healthy, + "busy" or "refreshing" or "running" => ProviderHealth.Busy, + "warn" or "warning" or "yellow" => ProviderHealth.Warning, + "fail" or "failed" or "failing" or "error" or "red" => ProviderHealth.Failing, + _ => ProviderHealth.Unknown, + }; + } + + private static string Normalize(string? value, string fallback) + { + return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + } + + private static string? NormalizeOptional(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/Windows/CodexBar.Windows.Core/WindowsSettings.cs b/Windows/CodexBar.Windows.Core/WindowsSettings.cs new file mode 100644 index 0000000000..dd5b21d626 --- /dev/null +++ b/Windows/CodexBar.Windows.Core/WindowsSettings.cs @@ -0,0 +1,143 @@ +using System.Text.Json; + +namespace CodexBar.Windows.Core; + +public sealed class WindowsSettings +{ + public int RefreshIntervalMinutes { get; set; } = 5; + public bool OpenMenuOnLeftClick { get; set; } = true; + public List Providers { get; set; } = []; +} + +public sealed class ProviderProbeSettings +{ + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public bool Enabled { get; set; } = true; + public string? SnapshotPath { get; set; } + public string? Command { get; set; } + public List Arguments { get; set; } = []; + public string? WorkingDirectory { get; set; } + public int TimeoutSeconds { get; set; } = 20; + + public bool IsValid => !string.IsNullOrWhiteSpace(Id) && !string.IsNullOrWhiteSpace(Name); +} + +public sealed class WindowsSettingsStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + + private WindowsSettingsStore(string settingsPath, WindowsSettings settings) + { + SettingsPath = settingsPath; + Settings = settings; + } + + public string SettingsPath { get; } + public WindowsSettings Settings { get; } + + public static string DefaultSettingsDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "CodexBar"); + + public static WindowsSettingsStore LoadOrCreate(string? settingsDirectory = null) + { + var directory = settingsDirectory ?? DefaultSettingsDirectory; + Directory.CreateDirectory(directory); + + var settingsPath = Path.Combine(directory, "windows-settings.json"); + if (!File.Exists(settingsPath)) + { + var sample = CreateDefault(directory); + File.WriteAllText(settingsPath, JsonSerializer.Serialize(sample, JsonOptions)); + WriteSampleSnapshot(directory); + return new WindowsSettingsStore(settingsPath, sample); + } + + var raw = File.ReadAllText(settingsPath); + var settings = JsonSerializer.Deserialize(raw, JsonOptions) ?? new WindowsSettings(); + settings.Providers ??= []; + settings.Providers = settings.Providers + .Where(provider => provider.IsValid) + .Select(NormalizeProvider) + .ToList(); + + return new WindowsSettingsStore(settingsPath, settings); + } + + public static WindowsSettings CreateDefault(string settingsDirectory) + { + return new WindowsSettings + { + Providers = + [ + new ProviderProbeSettings + { + Id = "codex", + Name = "Codex", + SnapshotPath = Path.Combine(settingsDirectory, "codex.sample.json"), + }, + new ProviderProbeSettings + { + Id = "claude", + Name = "Claude", + Enabled = false, + Command = "codexbar", + Arguments = ["usage", "--provider", "claude", "--json"], + }, + ], + }; + } + + private static ProviderProbeSettings NormalizeProvider(ProviderProbeSettings provider) + { + return new ProviderProbeSettings + { + Id = provider.Id.Trim(), + Name = provider.Name.Trim(), + Enabled = provider.Enabled, + SnapshotPath = NormalizeOptional(provider.SnapshotPath), + Command = NormalizeOptional(provider.Command), + Arguments = provider.Arguments? + .Where(argument => !string.IsNullOrWhiteSpace(argument)) + .ToList() ?? [], + WorkingDirectory = NormalizeOptional(provider.WorkingDirectory), + TimeoutSeconds = Math.Clamp(provider.TimeoutSeconds <= 0 ? 20 : provider.TimeoutSeconds, 1, 300), + }; + } + + private static void WriteSampleSnapshot(string settingsDirectory) + { + var snapshotPath = Path.Combine(settingsDirectory, "codex.sample.json"); + if (File.Exists(snapshotPath)) + { + return; + } + + var sample = new ProviderSnapshotJson + { + Id = "codex", + Name = "Codex", + Health = "healthy", + Window = "weekly", + Remaining = 42, + Limit = 100, + Unit = "credits left", + ResetsAt = DateTimeOffset.UtcNow.AddDays(2), + UpdatedAt = DateTimeOffset.UtcNow, + Detail = "Replace this sample with a real provider probe.", + SourceUrl = "https://codexbar.app", + }; + File.WriteAllText(snapshotPath, JsonSerializer.Serialize(sample, JsonOptions)); + } + + private static string? NormalizeOptional(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : Environment.ExpandEnvironmentVariables(value.Trim()); + } +} diff --git a/Windows/CodexBar.Windows.Tests/CodexBar.Windows.Tests.csproj b/Windows/CodexBar.Windows.Tests/CodexBar.Windows.Tests.csproj new file mode 100644 index 0000000000..53fdab1187 --- /dev/null +++ b/Windows/CodexBar.Windows.Tests/CodexBar.Windows.Tests.csproj @@ -0,0 +1,21 @@ + + + net8.0 + enable + enable + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/Windows/CodexBar.Windows.Tests/GlobalUsings.cs b/Windows/CodexBar.Windows.Tests/GlobalUsings.cs new file mode 100644 index 0000000000..c802f4480b --- /dev/null +++ b/Windows/CodexBar.Windows.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/Windows/CodexBar.Windows.Tests/PackagingContractTests.cs b/Windows/CodexBar.Windows.Tests/PackagingContractTests.cs new file mode 100644 index 0000000000..ad679bb049 --- /dev/null +++ b/Windows/CodexBar.Windows.Tests/PackagingContractTests.cs @@ -0,0 +1,37 @@ +namespace CodexBar.Windows.Tests; + +public sealed class PackagingContractTests +{ + [Fact] + public void InstallerPinsSingleInstanceMutexAndStartupEntry() + { + var root = RepositoryRoot.Find(); + var installer = File.ReadAllText(Path.Combine(root, "installer.iss")); + var program = File.ReadAllText(Path.Combine(root, "Windows", "CodexBar.Windows", "Program.cs")); + + Assert.Contains("AppMutex=CodexBar.Windows.Tray", installer); + Assert.Contains("\"CodexBar.Windows.Tray\"", program); + Assert.Contains("Name: \"startupicon\"", installer); + Assert.Contains("CodexBar-Setup-{#MyAppArch}", installer); + } + + [Fact] + public void CiBuildsSignsAndUploadsWindowsArtifacts() + { + var root = RepositoryRoot.Find(); + var ci = File.ReadAllText(Path.Combine(root, ".github", "workflows", "ci.yml")); + var release = File.ReadAllText(Path.Combine(root, ".github", "workflows", "release-cli.yml")); + + Assert.Contains("build-windows-tray", ci); + Assert.Contains("azure/trusted-signing-action@v2", ci); + Assert.Contains("AZURE_CERTIFICATE_PROFILE_NAME: ${{ vars.AZURE_CERTIFICATE_PROFILE_NAME || 'WindowsEdgeLight' }}", ci); + Assert.Contains("certificate-profile-name: ${{ env.AZURE_CERTIFICATE_PROFILE_NAME }}", ci); + Assert.Contains("codexbar-windows-${{ matrix.rid }}", ci); + + Assert.Contains("build-windows", release); + Assert.Contains("Require signing secrets for release assets", release); + Assert.Contains("if: github.event_name == 'release' || inputs.tag != ''", release); + Assert.Contains("0.0.0-dev", release); + Assert.Contains("gh release upload $env:RELEASE_TAG", release); + } +} diff --git a/Windows/CodexBar.Windows.Tests/ProviderProbeRunnerTests.cs b/Windows/CodexBar.Windows.Tests/ProviderProbeRunnerTests.cs new file mode 100644 index 0000000000..8b49dbe0d6 --- /dev/null +++ b/Windows/CodexBar.Windows.Tests/ProviderProbeRunnerTests.cs @@ -0,0 +1,319 @@ +using CodexBar.Windows.Core; + +namespace CodexBar.Windows.Tests; + +public sealed class ProviderProbeRunnerTests +{ + [Fact] + public async Task LoadProviderAsync_ReadsSnapshotFile() + { + using var temp = new TempDirectory(); + var snapshotPath = Path.Combine(temp.Path, "codex.json"); + await File.WriteAllTextAsync(snapshotPath, """{"health":"healthy","remaining":40,"limit":100}"""); + + var runner = new ProviderProbeRunner(); + var snapshot = await runner.LoadProviderAsync( + new ProviderProbeSettings + { + Id = "codex", + Name = "Codex", + SnapshotPath = snapshotPath, + }, + CancellationToken.None); + + Assert.Equal(ProviderHealth.Healthy, snapshot.Health); + Assert.Equal(40, snapshot.Remaining); + Assert.Equal(100, snapshot.Limit); + } + + [Fact] + public async Task LoadProviderAsync_ReturnsUnknownForMissingSnapshot() + { + using var temp = new TempDirectory(); + var runner = new ProviderProbeRunner(); + + var snapshot = await runner.LoadProviderAsync( + new ProviderProbeSettings + { + Id = "codex", + Name = "Codex", + SnapshotPath = Path.Combine(temp.Path, "missing.json"), + }, + CancellationToken.None); + + Assert.Equal(ProviderHealth.Unknown, snapshot.Health); + Assert.Contains("Snapshot not found", snapshot.Detail); + } + + [WindowsFact] + public async Task LoadProviderAsync_ParsesCommandJsonLine() + { + using var temp = new TempDirectory(); + var scriptPath = Path.Combine(temp.Path, "probe.cmd"); + await File.WriteAllLinesAsync(scriptPath, + [ + "@echo off", + "echo ignored", + "echo {\"health\":\"ok\",\"remaining\":7,\"limit\":9}", + ]); + + var runner = new ProviderProbeRunner(); + + var snapshot = await runner.LoadProviderAsync( + new ProviderProbeSettings + { + Id = "codex", + Name = "Codex", + Command = "cmd.exe", + Arguments = ["/c", scriptPath], + }, + CancellationToken.None); + + Assert.Equal(ProviderHealth.Healthy, snapshot.Health); + Assert.Equal(7, snapshot.Remaining); + Assert.Equal(9, snapshot.Limit); + } + + [WindowsFact] + public async Task LoadProviderAsync_ExpandsCommandAndWorkingDirectoryEnvironmentVariables() + { + using var temp = new TempDirectory(); + var variableName = $"CODEXBAR_TEST_PROBE_DIR_{Guid.NewGuid():N}"; + var scriptPath = Path.Combine(temp.Path, "probe.cmd"); + await File.WriteAllLinesAsync(scriptPath, + [ + "@echo off", + "echo {\"health\":\"ok\",\"remaining\":5,\"limit\":8}", + ]); + + Environment.SetEnvironmentVariable(variableName, temp.Path); + try + { + var runner = new ProviderProbeRunner(); + var snapshot = await runner.LoadProviderAsync( + new ProviderProbeSettings + { + Id = "codex", + Name = "Codex", + Command = "%ComSpec%", + Arguments = ["/c", "probe.cmd"], + WorkingDirectory = $"%{variableName}%", + }, + CancellationToken.None); + + Assert.Equal(ProviderHealth.Healthy, snapshot.Health); + Assert.Equal(5, snapshot.Remaining); + Assert.Equal(8, snapshot.Limit); + } + finally + { + Environment.SetEnvironmentVariable(variableName, null); + } + } + + [WindowsFact] + public async Task LoadProviderAsync_ParsesCodexBarCliJsonArray() + { + using var temp = new TempDirectory(); + var scriptPath = Path.Combine(temp.Path, "probe.cmd"); + await File.WriteAllLinesAsync(scriptPath, + [ + "@echo off", + "echo [{\"provider\":\"claude\",\"source\":\"claude-cli\",\"status\":{\"indicator\":\"none\",\"url\":\"https://status.example.com\"},\"usage\":{\"primary\":{\"usedPercent\":25,\"windowMinutes\":300,\"resetsAt\":\"2026-06-10T10:00:00Z\"},\"updatedAt\":\"2026-06-06T10:00:00Z\"},\"credits\":null,\"error\":null}]", + ]); + + var runner = new ProviderProbeRunner(); + + var snapshot = await runner.LoadProviderAsync( + new ProviderProbeSettings + { + Id = "claude", + Name = "Claude", + Command = "cmd.exe", + Arguments = ["/c", scriptPath], + }, + CancellationToken.None); + + Assert.Equal(ProviderHealth.Healthy, snapshot.Health); + Assert.Equal("session", snapshot.Window); + Assert.Equal(75, snapshot.Remaining); + Assert.Equal(100, snapshot.Limit); + Assert.Equal("% left", snapshot.Unit); + Assert.Equal(new DateTimeOffset(2026, 6, 10, 10, 0, 0, TimeSpan.Zero), snapshot.ResetsAt); + Assert.Equal("https://status.example.com", snapshot.SourceUrl); + } + + [WindowsFact] + public async Task LoadProviderAsync_ParsesPrettyJsonArrayBetweenLogLines() + { + using var temp = new TempDirectory(); + var scriptPath = Path.Combine(temp.Path, "pretty-probe.cmd"); + await File.WriteAllLinesAsync(scriptPath, + [ + "@echo off", + "echo warning: refreshing cache", + "echo [", + "echo {\"provider\":\"claude\",\"source\":\"claude-cli\",\"status\":{\"indicator\":\"none\",\"url\":\"https://status.example.com\"},\"usage\":{\"primary\":{\"usedPercent\":40,\"windowMinutes\":10080},\"updatedAt\":\"2026-06-06T10:00:00Z\"},\"credits\":null,\"error\":null}", + "echo ]", + "echo done", + ]); + + var runner = new ProviderProbeRunner(); + + var snapshot = await runner.LoadProviderAsync( + new ProviderProbeSettings + { + Id = "claude", + Name = "Claude", + Command = "cmd.exe", + Arguments = ["/c", scriptPath], + }, + CancellationToken.None); + + Assert.Equal(ProviderHealth.Healthy, snapshot.Health); + Assert.Equal("weekly", snapshot.Window); + Assert.Equal(60, snapshot.Remaining); + } + + [WindowsFact] + public async Task LoadProviderAsync_SkipsMalformedJsonLikeLogText() + { + using var temp = new TempDirectory(); + var scriptPath = Path.Combine(temp.Path, "logged-probe.cmd"); + await File.WriteAllLinesAsync(scriptPath, + [ + "@echo off", + "echo [warn] ignored", + "echo {\"health\":\"ok\",\"remaining\":7,\"limit\":9}", + ]); + + var runner = new ProviderProbeRunner(); + + var snapshot = await runner.LoadProviderAsync( + new ProviderProbeSettings + { + Id = "codex", + Name = "Codex", + Command = "cmd.exe", + Arguments = ["/c", scriptPath], + }, + CancellationToken.None); + + Assert.Equal(ProviderHealth.Healthy, snapshot.Health); + Assert.Equal(7, snapshot.Remaining); + Assert.Equal(9, snapshot.Limit); + } + + [WindowsFact] + public async Task LoadProviderAsync_TruncatesInvalidProbeOutput() + { + using var temp = new TempDirectory(); + var scriptPath = Path.Combine(temp.Path, "invalid-probe.cmd"); + await File.WriteAllLinesAsync(scriptPath, + [ + "@echo off", + $"echo {new string('x', 700)}", + ]); + + var runner = new ProviderProbeRunner(); + + var snapshot = await runner.LoadProviderAsync( + new ProviderProbeSettings + { + Id = "codex", + Name = "Codex", + Command = "cmd.exe", + Arguments = ["/c", scriptPath], + }, + CancellationToken.None); + + Assert.Equal(ProviderHealth.Failing, snapshot.Health); + Assert.Contains("Output preview", snapshot.Detail); + Assert.True(snapshot.Detail!.Length < 620); + } + + [WindowsFact] + public async Task LoadProviderAsync_FailsWhenProbeOutputExceedsLimit() + { + using var temp = new TempDirectory(); + var scriptPath = Path.Combine(temp.Path, "large-probe.cmd"); + await File.WriteAllLinesAsync(scriptPath, + [ + "@echo off", + $"for /L %%i in (1,1,600) do echo {new string('x', 2048)}", + ]); + + var runner = new ProviderProbeRunner(); + + var snapshot = await runner.LoadProviderAsync( + new ProviderProbeSettings + { + Id = "codex", + Name = "Codex", + Command = "cmd.exe", + Arguments = ["/c", scriptPath], + TimeoutSeconds = 5, + }, + CancellationToken.None); + + Assert.Equal(ProviderHealth.Failing, snapshot.Health); + Assert.Contains("output exceeded", snapshot.Detail); + } + + [WindowsFact] + public async Task LoadProviderAsync_ReturnsFailedSnapshotWhenCommandTimesOut() + { + using var temp = new TempDirectory(); + var scriptPath = Path.Combine(temp.Path, "hang.cmd"); + await File.WriteAllLinesAsync(scriptPath, + [ + "@echo off", + "ping -n 30 127.0.0.1 >nul", + ]); + + var runner = new ProviderProbeRunner(); + + var snapshot = await runner.LoadProviderAsync( + new ProviderProbeSettings + { + Id = "codex", + Name = "Codex", + Command = "cmd.exe", + Arguments = ["/c", scriptPath], + TimeoutSeconds = 1, + }, + CancellationToken.None); + + Assert.Equal(ProviderHealth.Failing, snapshot.Health); + Assert.Contains("timed out", snapshot.Detail); + } + + [WindowsFact] + public async Task LoadProviderAsync_ReturnsFailedSnapshotWhenOutputDrainTimesOut() + { + using var temp = new TempDirectory(); + var scriptPath = Path.Combine(temp.Path, "pipe-hang.cmd"); + await File.WriteAllLinesAsync(scriptPath, + [ + "@echo off", + "start /b cmd /c \"ping -n 30 127.0.0.1 >nul\"", + "echo {\"health\":\"ok\",\"remaining\":7,\"limit\":9}", + ]); + + var runner = new ProviderProbeRunner(); + + var snapshot = await runner.LoadProviderAsync( + new ProviderProbeSettings + { + Id = "codex", + Name = "Codex", + Command = "cmd.exe", + Arguments = ["/c", scriptPath], + TimeoutSeconds = 1, + }, + CancellationToken.None); + + Assert.Equal(ProviderHealth.Failing, snapshot.Health); + Assert.Contains("timed out", snapshot.Detail); + } +} diff --git a/Windows/CodexBar.Windows.Tests/ProviderSnapshotTests.cs b/Windows/CodexBar.Windows.Tests/ProviderSnapshotTests.cs new file mode 100644 index 0000000000..bc93b575a6 --- /dev/null +++ b/Windows/CodexBar.Windows.Tests/ProviderSnapshotTests.cs @@ -0,0 +1,51 @@ +using CodexBar.Windows.Core; + +namespace CodexBar.Windows.Tests; + +public sealed class ProviderSnapshotTests +{ + [Fact] + public void Parse_AcceptsCodexBarProbeShape() + { + var json = """ + { + "id": "codex", + "name": "Codex", + "health": "warning", + "window": "weekly", + "remaining": 12.5, + "limit": 100, + "unit": "credits", + "resetsAt": "2026-06-08T12:00:00Z", + "updatedAt": "2026-06-06T12:00:00Z", + "detail": "near weekly limit", + "sourceUrl": "https://codexbar.app" + } + """; + + var settings = new ProviderProbeSettings { Id = "fallback", Name = "Fallback" }; + var snapshot = ProviderSnapshotJson.Parse(json).ToSnapshot(settings); + + Assert.Equal("codex", snapshot.Id); + Assert.Equal("Codex", snapshot.Name); + Assert.Equal(ProviderHealth.Warning, snapshot.Health); + Assert.Equal("weekly", snapshot.Window); + Assert.Equal(12.5, snapshot.Remaining); + Assert.Equal(100, snapshot.Limit); + Assert.Equal(0.875, snapshot.UsageFraction); + Assert.Contains("near weekly limit", snapshot.Detail); + } + + [Fact] + public void Summary_UsesProviderFallbacksWhenProbeOmitsIdentity() + { + var settings = new ProviderProbeSettings { Id = "claude", Name = "Claude" }; + var snapshot = ProviderSnapshotJson.Parse("""{"health":"ok","remaining":8,"limit":10}""").ToSnapshot(settings); + + Assert.Equal("claude", snapshot.Id); + Assert.Equal("Claude", snapshot.Name); + Assert.Equal(ProviderHealth.Healthy, snapshot.Health); + Assert.Contains("8 / 10", snapshot.Summary); + Assert.Contains("[ok] Claude", snapshot.MenuLabel); + } +} diff --git a/Windows/CodexBar.Windows.Tests/RepositoryRoot.cs b/Windows/CodexBar.Windows.Tests/RepositoryRoot.cs new file mode 100644 index 0000000000..6165809099 --- /dev/null +++ b/Windows/CodexBar.Windows.Tests/RepositoryRoot.cs @@ -0,0 +1,22 @@ +namespace CodexBar.Windows.Tests; + +internal static class RepositoryRoot +{ + public static string Find() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if ((Directory.Exists(Path.Combine(directory.FullName, ".git")) || + File.Exists(Path.Combine(directory.FullName, ".git"))) && + File.Exists(Path.Combine(directory.FullName, "README.md"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not find repository root from test output directory."); + } +} diff --git a/Windows/CodexBar.Windows.Tests/TempDirectory.cs b/Windows/CodexBar.Windows.Tests/TempDirectory.cs new file mode 100644 index 0000000000..b8c2f107c7 --- /dev/null +++ b/Windows/CodexBar.Windows.Tests/TempDirectory.cs @@ -0,0 +1,23 @@ +namespace CodexBar.Windows.Tests; + +internal sealed class TempDirectory : IDisposable +{ + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"codexbar-windows-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch + { + } + } +} diff --git a/Windows/CodexBar.Windows.Tests/WindowsBuildScriptTests.cs b/Windows/CodexBar.Windows.Tests/WindowsBuildScriptTests.cs new file mode 100644 index 0000000000..5dc6afe541 --- /dev/null +++ b/Windows/CodexBar.Windows.Tests/WindowsBuildScriptTests.cs @@ -0,0 +1,18 @@ +namespace CodexBar.Windows.Tests; + +public sealed class WindowsBuildScriptTests +{ + [Fact] + public void BuildScript_InvokesInnoResolverAfterWingetInstall() + { + var root = RepositoryRoot.Find(); + var scriptPath = Path.Combine(root, "Scripts", "build_windows.ps1"); + var script = File.ReadAllText(scriptPath); + + Assert.Contains("[string]$Version = \"\"", script); + Assert.Contains("function Resolve-BuildVersion", script); + Assert.Contains("", script); + Assert.Contains("return (Resolve-InnoCompiler)", script); + Assert.DoesNotContain("return Resolve-InnoCompiler", script); + } +} diff --git a/Windows/CodexBar.Windows.Tests/WindowsFactAttribute.cs b/Windows/CodexBar.Windows.Tests/WindowsFactAttribute.cs new file mode 100644 index 0000000000..821c4beea5 --- /dev/null +++ b/Windows/CodexBar.Windows.Tests/WindowsFactAttribute.cs @@ -0,0 +1,12 @@ +namespace CodexBar.Windows.Tests; + +public sealed class WindowsFactAttribute : FactAttribute +{ + public WindowsFactAttribute() + { + if (!OperatingSystem.IsWindows()) + { + Skip = "Windows-only test."; + } + } +} diff --git a/Windows/CodexBar.Windows.Tests/WindowsSettingsStoreTests.cs b/Windows/CodexBar.Windows.Tests/WindowsSettingsStoreTests.cs new file mode 100644 index 0000000000..d0057cb670 --- /dev/null +++ b/Windows/CodexBar.Windows.Tests/WindowsSettingsStoreTests.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using CodexBar.Windows.Core; + +namespace CodexBar.Windows.Tests; + +public sealed class WindowsSettingsStoreTests +{ + [Fact] + public void LoadOrCreate_CreatesDefaultSettingsAndSampleSnapshot() + { + using var temp = new TempDirectory(); + + var store = WindowsSettingsStore.LoadOrCreate(temp.Path); + + Assert.True(File.Exists(store.SettingsPath)); + Assert.True(File.Exists(Path.Combine(temp.Path, "codex.sample.json"))); + Assert.Contains(store.Settings.Providers, provider => provider.Id == "codex"); + Assert.Contains(store.Settings.Providers, provider => provider.Id == "claude" && provider.Enabled == false); + } + + [Fact] + public void LoadOrCreate_NormalizesInvalidProvidersAndTimeouts() + { + using var temp = new TempDirectory(); + var settingsPath = Path.Combine(temp.Path, "windows-settings.json"); + var raw = new + { + refreshIntervalMinutes = 5, + providers = new object[] + { + new + { + id = " codex ", + name = " Codex ", + arguments = new string?[] { "usage", "", " ", null, "--json" }, + timeoutSeconds = 999, + }, + new { id = "", name = "Broken" }, + }, + }; + File.WriteAllText(settingsPath, JsonSerializer.Serialize(raw)); + + var store = WindowsSettingsStore.LoadOrCreate(temp.Path); + + var provider = Assert.Single(store.Settings.Providers); + Assert.Equal("codex", provider.Id); + Assert.Equal("Codex", provider.Name); + Assert.Equal(new[] { "usage", "--json" }, provider.Arguments); + Assert.Equal(300, provider.TimeoutSeconds); + } +} diff --git a/Windows/CodexBar.Windows/CodexBar.Windows.csproj b/Windows/CodexBar.Windows/CodexBar.Windows.csproj new file mode 100644 index 0000000000..caa83bbc76 --- /dev/null +++ b/Windows/CodexBar.Windows/CodexBar.Windows.csproj @@ -0,0 +1,17 @@ + + + WinExe + net8.0-windows + true + enable + enable + CodexBar.Windows + CodexBar.Windows + 0.32.5 + win-x64;win-arm64 + + + + + + diff --git a/Windows/CodexBar.Windows/CodexBarTrayContext.cs b/Windows/CodexBar.Windows/CodexBarTrayContext.cs new file mode 100644 index 0000000000..7c406dc2d6 --- /dev/null +++ b/Windows/CodexBar.Windows/CodexBarTrayContext.cs @@ -0,0 +1,240 @@ +using System.Diagnostics; +using CodexBar.Windows.Core; + +namespace CodexBar.Windows; + +internal sealed class CodexBarTrayContext : ApplicationContext +{ + private readonly WindowsSettingsStore _settingsStore; + private readonly ProviderProbeRunner _probeRunner; + private readonly NotifyIcon _notifyIcon; + private readonly ContextMenuStrip _menu = new(); + private readonly System.Windows.Forms.Timer _refreshTimer = new(); + private readonly CancellationTokenSource _shutdown = new(); + private IReadOnlyList _snapshots = []; + private bool _isRefreshing; + private string? _lastError; + + public CodexBarTrayContext(WindowsSettingsStore settingsStore, ProviderProbeRunner probeRunner) + { + _settingsStore = settingsStore; + _probeRunner = probeRunner; + _notifyIcon = new NotifyIcon + { + Icon = TrayIconFactory.Create(ProviderHealth.Unknown), + Text = "CodexBar", + ContextMenuStrip = _menu, + Visible = true, + }; + + _notifyIcon.MouseUp += OnNotifyIconMouseUp; + + _refreshTimer.Interval = Math.Clamp(settingsStore.Settings.RefreshIntervalMinutes, 1, 60) * 60 * 1000; + _refreshTimer.Tick += (_, _) => BeginRefresh(); + _refreshTimer.Start(); + + BuildMenu(); + BeginRefresh(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _shutdown.Cancel(); + _refreshTimer.Stop(); + _refreshTimer.Dispose(); + _notifyIcon.Visible = false; + _notifyIcon.Dispose(); + _shutdown.Dispose(); + _menu.Dispose(); + } + + base.Dispose(disposing); + } + + private void BeginRefresh() + { + if (_isRefreshing) + { + return; + } + + _ = RefreshAsync(); + } + + private async Task RefreshAsync() + { + _isRefreshing = true; + _lastError = null; + BuildMenu(); + + try + { + _snapshots = await _probeRunner.LoadAsync(_settingsStore.Settings.Providers, _shutdown.Token); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _lastError = exception.Message; + } + finally + { + _isRefreshing = false; + if (!_shutdown.IsCancellationRequested) + { + UpdateTrayIcon(); + BuildMenu(); + } + } + } + + private void BuildMenu() + { + _menu.Items.Clear(); + _menu.Items.Add(new ToolStripMenuItem(BuildHeaderText()) { Enabled = false }); + _menu.Items.Add(new ToolStripSeparator()); + + if (_settingsStore.Settings.Providers.Count == 0) + { + _menu.Items.Add(new ToolStripMenuItem("No providers configured") { Enabled = false }); + _menu.Items.Add(new ToolStripMenuItem("Open settings file", null, (_, _) => OpenFile(_settingsStore.SettingsPath))); + _menu.Items.Add(new ToolStripMenuItem("Open Windows setup doc", null, (_, _) => OpenUrl("https://github.com/steipete/CodexBar/blob/main/docs/windows.md"))); + } + else if (_snapshots.Count == 0) + { + foreach (var provider in _settingsStore.Settings.Providers.Where(provider => provider.Enabled)) + { + _menu.Items.Add(new ToolStripMenuItem($"[ ] {provider.Name}") { Enabled = false }); + } + } + else + { + foreach (var snapshot in _snapshots) + { + _menu.Items.Add(BuildProviderMenu(snapshot)); + } + } + + if (!string.IsNullOrWhiteSpace(_lastError)) + { + _menu.Items.Add(new ToolStripSeparator()); + _menu.Items.Add(new ToolStripMenuItem($"Error: {_lastError}") { Enabled = false }); + } + + _menu.Items.Add(new ToolStripSeparator()); + _menu.Items.Add(new ToolStripMenuItem(_isRefreshing ? "Refreshing..." : "Refresh now", null, (_, _) => BeginRefresh()) { Enabled = !_isRefreshing }); + _menu.Items.Add(new ToolStripMenuItem("Open settings file", null, (_, _) => OpenFile(_settingsStore.SettingsPath))); + _menu.Items.Add(new ToolStripMenuItem("Open Windows setup doc", null, (_, _) => OpenUrl("https://github.com/steipete/CodexBar/blob/main/docs/windows.md"))); + _menu.Items.Add(new ToolStripMenuItem("Quit CodexBar", null, (_, _) => ExitThread())); + } + + private static ToolStripMenuItem BuildProviderMenu(ProviderSnapshot snapshot) + { + var item = new ToolStripMenuItem(snapshot.MenuLabel); + + item.DropDownItems.Add(new ToolStripMenuItem(snapshot.Summary) { Enabled = false }); + item.DropDownItems.Add(new ToolStripMenuItem(snapshot.ResetSummary) { Enabled = false }); + + if (snapshot.UpdatedAt != null) + { + item.DropDownItems.Add(new ToolStripMenuItem($"Updated: {snapshot.UpdatedAt.Value.LocalDateTime:g}") { Enabled = false }); + } + + if (!string.IsNullOrWhiteSpace(snapshot.Detail)) + { + item.DropDownItems.Add(new ToolStripMenuItem(snapshot.Detail) { Enabled = false }); + } + + if (!string.IsNullOrWhiteSpace(snapshot.SourceUrl)) + { + item.DropDownItems.Add(new ToolStripSeparator()); + item.DropDownItems.Add(new ToolStripMenuItem("Open source", null, (_, _) => OpenUrl(snapshot.SourceUrl))); + } + + return item; + } + + private string BuildHeaderText() + { + var enabledCount = _settingsStore.Settings.Providers.Count(provider => provider.Enabled); + var refreshState = _isRefreshing ? "refreshing" : "ready"; + return $"CodexBar Windows - {enabledCount} providers - {refreshState}"; + } + + private void UpdateTrayIcon() + { + var health = WorstHealth(); + var oldIcon = _notifyIcon.Icon; + _notifyIcon.Icon = TrayIconFactory.Create(health); + oldIcon?.Dispose(); + _notifyIcon.Text = BuildTooltip(health); + } + + private ProviderHealth WorstHealth() + { + if (_lastError != null || _snapshots.Any(snapshot => snapshot.Health == ProviderHealth.Failing)) + { + return ProviderHealth.Failing; + } + + if (_snapshots.Any(snapshot => snapshot.Health == ProviderHealth.Warning)) + { + return ProviderHealth.Warning; + } + + if (_snapshots.Any(snapshot => snapshot.Health == ProviderHealth.Busy)) + { + return ProviderHealth.Busy; + } + + if (_snapshots.Count > 0 && _snapshots.All(snapshot => snapshot.Health == ProviderHealth.Healthy)) + { + return ProviderHealth.Healthy; + } + + return ProviderHealth.Unknown; + } + + private string BuildTooltip(ProviderHealth health) + { + var summary = health switch + { + ProviderHealth.Healthy => "healthy", + ProviderHealth.Busy => "refreshing", + ProviderHealth.Warning => "quota warning", + ProviderHealth.Failing => "needs attention", + _ => "ready", + }; + return $"CodexBar - {_snapshots.Count} providers - {summary}"; + } + + private void OnNotifyIconMouseUp(object? sender, MouseEventArgs eventArgs) + { + if (eventArgs.Button == MouseButtons.Left && _settingsStore.Settings.OpenMenuOnLeftClick) + { + _menu.Show(Cursor.Position); + } + } + + private static void OpenFile(string path) + { + StartShell(path); + } + + private static void OpenUrl(string url) + { + StartShell(url); + } + + private static void StartShell(string target) + { + try + { + Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); + } + catch (Exception exception) + { + MessageBox.Show(exception.Message, "CodexBar", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } +} diff --git a/Windows/CodexBar.Windows/Program.cs b/Windows/CodexBar.Windows/Program.cs new file mode 100644 index 0000000000..a615cccf5f --- /dev/null +++ b/Windows/CodexBar.Windows/Program.cs @@ -0,0 +1,22 @@ +using CodexBar.Windows.Core; + +namespace CodexBar.Windows; + +internal static class Program +{ + [STAThread] + private static void Main() + { + using var mutex = new Mutex(initiallyOwned: true, "CodexBar.Windows.Tray", out var ownsMutex); + if (!ownsMutex) + { + return; + } + + ApplicationConfiguration.Initialize(); + + var settings = WindowsSettingsStore.LoadOrCreate(); + using var context = new CodexBarTrayContext(settings, new ProviderProbeRunner()); + Application.Run(context); + } +} diff --git a/Windows/CodexBar.Windows/TrayIconFactory.cs b/Windows/CodexBar.Windows/TrayIconFactory.cs new file mode 100644 index 0000000000..f30b0c9248 --- /dev/null +++ b/Windows/CodexBar.Windows/TrayIconFactory.cs @@ -0,0 +1,58 @@ +using System.Drawing; +using System.Runtime.InteropServices; +using CodexBar.Windows.Core; + +namespace CodexBar.Windows; + +internal static class TrayIconFactory +{ + [DllImport("user32.dll", SetLastError = true)] + private static extern bool DestroyIcon(IntPtr handle); + + public static Icon Create(ProviderHealth health) + { + var fill = health switch + { + ProviderHealth.Healthy => Color.FromArgb(22, 163, 74), + ProviderHealth.Busy => Color.FromArgb(37, 99, 235), + ProviderHealth.Warning => Color.FromArgb(217, 119, 6), + ProviderHealth.Failing => Color.FromArgb(220, 38, 38), + _ => Color.FromArgb(82, 82, 91), + }; + + using var bitmap = new Bitmap(32, 32); + using var graphics = Graphics.FromImage(bitmap); + graphics.Clear(Color.Transparent); + graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + + using var background = new SolidBrush(fill); + FillRoundedRectangle(graphics, background, new Rectangle(2, 2, 28, 28), 7); + + using var font = new Font("Segoe UI", 15, FontStyle.Bold, GraphicsUnit.Pixel); + using var textBrush = new SolidBrush(Color.White); + var textSize = graphics.MeasureString("CB", font); + graphics.DrawString("CB", font, textBrush, (32 - textSize.Width) / 2, (31 - textSize.Height) / 2); + + var handle = bitmap.GetHicon(); + try + { + return (Icon)Icon.FromHandle(handle).Clone(); + } + finally + { + DestroyIcon(handle); + } + } + + private static void FillRoundedRectangle(Graphics graphics, Brush brush, Rectangle bounds, int radius) + { + using var path = new System.Drawing.Drawing2D.GraphicsPath(); + var diameter = radius * 2; + path.AddArc(bounds.Left, bounds.Top, diameter, diameter, 180, 90); + path.AddArc(bounds.Right - diameter, bounds.Top, diameter, diameter, 270, 90); + path.AddArc(bounds.Right - diameter, bounds.Bottom - diameter, diameter, diameter, 0, 90); + path.AddArc(bounds.Left, bounds.Bottom - diameter, diameter, diameter, 90, 90); + path.CloseFigure(); + graphics.FillPath(brush, path); + } +} diff --git a/docs/windows.md b/docs/windows.md new file mode 100644 index 0000000000..c390d950ae --- /dev/null +++ b/docs/windows.md @@ -0,0 +1,113 @@ +# CodexBar for Windows + +CodexBar's Windows support is a native notification-area companion. It is separate from the SwiftUI/AppKit menu bar app because that stack is macOS-only. + +The Windows app currently provides: + +- a single-instance native tray process +- left-click or right-click taskbar menu access +- provider quota rows from local JSON snapshots or command probes +- reset countdowns, health colors, and source links +- a per-user settings file under `%APPDATA%\CodexBar` +- self-contained `win-x64` and `win-arm64` publish targets +- an optional Inno Setup installer + +## Build + +Requirements: + +- Windows 10 2004 or newer +- .NET 8 SDK + +```powershell +.\Scripts\build_windows.ps1 test +.\Scripts\build_windows.ps1 build -Runtime win-x64 +.\Scripts\build_windows.ps1 publish -Runtime win-x64 +``` + +Use `win-arm64` on Windows on Arm. + +## Installer + +Install Inno Setup 6, then run: + +```powershell +.\Scripts\build_windows.ps1 installer -Runtime win-x64 +``` + +The installer lands under `Output\CodexBar-Setup-x64.exe`. + +## Run + +```powershell +.\Scripts\build_windows.ps1 run +``` + +The first run creates: + +```text +%APPDATA%\CodexBar\windows-settings.json +%APPDATA%\CodexBar\codex.sample.json +``` + +## Probe Contract + +Each provider can read a JSON snapshot file or run a command that prints one JSON object. This keeps the native tray independent from the macOS Swift provider engine while giving provider ports a stable Windows seam. + +```json +{ + "id": "codex", + "name": "Codex", + "health": "healthy", + "window": "weekly", + "remaining": 42, + "limit": 100, + "unit": "credits left", + "resetsAt": "2026-06-08T12:00:00Z", + "updatedAt": "2026-06-06T12:00:00Z", + "detail": "Replace this sample with a real provider probe.", + "sourceUrl": "https://codexbar.app" +} +``` + +`health` accepts `healthy`, `busy`, `warning`, `failing`, or the aliases `ok`, `running`, `warn`, and `error`. + +## Settings + +Example `%APPDATA%\CodexBar\windows-settings.json`: + +```json +{ + "refreshIntervalMinutes": 5, + "openMenuOnLeftClick": true, + "providers": [ + { + "id": "codex", + "name": "Codex", + "enabled": true, + "snapshotPath": "%APPDATA%\\CodexBar\\codex.json" + }, + { + "id": "claude", + "name": "Claude", + "enabled": true, + "command": "codexbar", + "arguments": ["usage", "--provider", "claude", "--json"], + "timeoutSeconds": 20 + } + ] +} +``` + +The app never writes provider tokens into this file. Command probes should read credentials from their own native store, environment, or provider CLI. + +## Release CI + +The Windows workflow builds and tests the native app on `windows-latest`, publishes `win-x64` and `win-arm64` artifacts, and compiles Inno installers. Tag builds sign Windows executables and installers with Azure Trusted Signing when the same secret names used by OpenClaw Windows CI are configured: + +- `AZURE_TENANT_ID` +- `AZURE_CLIENT_ID` +- `AZURE_CLIENT_SECRET` +- `AZURE_SUBSCRIPTION_ID` + +The signing account and certificate profile are pinned in `.github/workflows/ci.yml`. diff --git a/installer.iss b/installer.iss new file mode 100644 index 0000000000..da5d1f8d87 --- /dev/null +++ b/installer.iss @@ -0,0 +1,63 @@ +; CodexBar native Windows tray installer +#define MyAppName "CodexBar" +#define MyAppPublisher "Peter Steinberger" +#define MyAppURL "https://github.com/steipete/CodexBar" +#define MyAppExeName "CodexBar.Windows.exe" + +#ifndef MyAppArch + #define MyAppArch "x64" +#endif + +#ifndef publish + #define publish "publish\windows\win-x64" +#endif + +#if !FileExists(publish + "\CodexBar.Windows.exe") + #error CodexBar.Windows.exe payload missing. Run Scripts\build_windows.ps1 installer after publishing the Windows app. +#endif + +[Setup] +AppId={{76C53A02-90C7-4EB7-8132-4F8032741474}} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL=https://github.com/steipete/CodexBar/issues +AppUpdatesURL=https://github.com/steipete/CodexBar/releases +DefaultDirName={localappdata}\CodexBar +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +OutputBaseFilename=CodexBar-Setup-{#MyAppArch} +Compression=lzma +SolidCompression=yes +WizardStyle=modern +PrivilegesRequired=lowest +UninstallDisplayIcon={app}\{#MyAppExeName} +AppMutex=CodexBar.Windows.Tray +#if MyAppArch == "arm64" +ArchitecturesInstallIn64BitMode=arm64 +ArchitecturesAllowed=arm64 +#else +ArchitecturesInstallIn64BitMode=x64 +ArchitecturesAllowed=x64 +#endif + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "startupicon"; Description: "Start CodexBar when Windows starts"; GroupDescription: "Startup:"; Flags: unchecked + +[Files] +Source: "{#publish}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{group}\CodexBar Windows settings"; Filename: "{userappdata}\CodexBar\windows-settings.json" +Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: startupicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent