diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index dc3b1778..65164411 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: go-version: [1.25.x, 1.26.x] - platform: [ubuntu-latest] + platform: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 https://github.com/actions/checkout/releases/tag/v7.0.0 @@ -25,12 +25,21 @@ jobs: with: go-version: ${{ matrix.go-version }} - name: Install dependencies + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev libx11-dev libxcursor-dev libxrandr-dev libxinerama-dev libxi-dev libxxf86vm-dev - name: Build binaries + if: runner.os == 'Linux' run: make build - name: go-test + if: runner.os == 'Linux' run: go test -v -timeout 10m ./... env: LANG: en_US.UTF-8 + # Windows: test tray/setup only (registry autostart + engine lifecycle). + # No fyne/cgo deps, so no MSYS2 toolchain needed; other packages are + # platform-neutral and covered by the Linux job. + - name: go-test (windows) + if: runner.os == 'Windows' + run: go test -v -timeout 10m ./tray/setup/... diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bdda30b3..9403125f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: outputs: RELEASE_ID: ${{ steps.create-release.outputs.result }} steps: - - run: "echo \"RELEASE_TAG=${GITHUB_REF#refs/tags/}\" >> $GITHUB_ENV" + - run: "echo \"RELEASE_TAG=${GITHUB_REF#refs/tags/}\" >> \"$GITHUB_ENV\"" - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 https://github.com/actions/github-script/releases/tag/v9.0.0 id: create-release if: startsWith(github.ref, 'refs/tags/') @@ -105,7 +105,7 @@ jobs: echo "RELEASE_TAG=$tagName" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Set RELEASE_TAG if: matrix.os != 'windows' - run: "echo \"RELEASE_TAG=${GITHUB_REF#refs/tags/}\" >> $GITHUB_ENV" + run: "echo \"RELEASE_TAG=${GITHUB_REF#refs/tags/}\" >> \"$GITHUB_ENV\"" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 https://github.com/actions/checkout/releases/tag/v7.0.0 with: fetch-depth: '0' @@ -200,7 +200,7 @@ jobs: - name: Set up Cloud SDK if: ${{ startsWith(github.ref, 'refs/tags/') && matrix.os == 'windows' }} uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 https://github.com/google-github-actions/setup-gcloud/releases/tag/v3.0.1 - - name: Sign binary (Windows) + - name: Sign Windows binaries (adder.exe + adder-tray.exe) if: ${{ startsWith(github.ref, 'refs/tags/') && matrix.os == 'windows' }} shell: pwsh run: | @@ -220,25 +220,142 @@ jobs: [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${{ secrets.CERTIFICATE_CHAIN }}")) | Out-File -FilePath "codesign-chain.pem" -Encoding utf8 - $filename = "${{ env.APPLICATION_NAME }}.exe" $ACCESS_TOKEN = & gcloud auth print-access-token Write-Host "::add-mask::$ACCESS_TOKEN" - java -jar jsign.jar ` - --storetype ${{ secrets.CERTIFICATE_STORE_TYPE }} ` - --storepass "$ACCESS_TOKEN" ` - --keystore ${{ secrets.CERTIFICATE_KEYSTORE }} ` - --alias ${{ secrets.CERTIFICATE_KEY_NAME }} ` - --certfile "codesign-chain.pem" ` - --tsmode RFC3161 ` - --tsaurl "http://timestamp.globalsign.com/tsa/r6advanced1" ` - $filename + # Sign every PE that will end up in the MSI. The outer MSI signature + # only covers the OLE compound, not the embedded binaries — so each + # .exe must be signed individually BEFORE wix bundles them. + $binariesToSign = @("${{ env.APPLICATION_NAME }}.exe") + if ("${{ matrix.tray }}" -eq "true") { + $binariesToSign += "${{ env.APPLICATION_NAME }}-tray.exe" + } + + foreach ($filename in $binariesToSign) { + if (-not (Test-Path $filename)) { + Write-Error "Expected binary not found: $filename" + exit 1 + } + Write-Host "Signing $filename" + # --tsretries/--tsretrywait are jsign's native timestamp retry + # (the TSA is the part that flakes). + java -jar jsign.jar ` + --storetype ${{ secrets.CERTIFICATE_STORE_TYPE }} ` + --storepass "$ACCESS_TOKEN" ` + --keystore ${{ secrets.CERTIFICATE_KEYSTORE }} ` + --alias ${{ secrets.CERTIFICATE_KEY_NAME }} ` + --certfile "codesign-chain.pem" ` + --alg SHA-256 ` + --tsmode RFC3161 ` + --tsaurl "http://timestamp.globalsign.com/tsa/r6advanced1" ` + --tsretries 3 ` + --tsretrywait 10 ` + --name "Adder" ` + --url "https://github.com/blinklabs-io/adder" ` + $filename + if ($LASTEXITCODE -ne 0) { Write-Error "jsign failed on $filename"; exit 1 } + + # Independently confirm the PE is signed, chain-trusted AND + # timestamped before it gets embedded in the MSI. jsign can exit 0 + # while the timestamp step silently failed, which this catches. + $sig = Get-AuthenticodeSignature $filename + if ($sig.Status -ne 'Valid') { + Write-Error "signature not valid for ${filename}: $($sig.Status)"; exit 1 + } + if ($null -eq $sig.TimeStamperCertificate) { + Write-Error "signature for ${filename} is not timestamped"; exit 1 + } + Write-Host "Signed + verified: $filename ($($sig.SignerCertificate.Subject))" + } + + $ACCESS_TOKEN = $null + # NOTE: jsign.jar and codesign-chain.pem are intentionally kept on + # disk for the subsequent "Build MSI" step to reuse. + + - name: Install WiX toolset (Windows) + if: ${{ startsWith(github.ref, 'refs/tags/') && matrix.os == 'windows' && matrix.tray }} + shell: pwsh + run: | + dotnet tool install --global wix --version 4.0.5 + # Make the new global tools dir discoverable in subsequent steps. + "$env:USERPROFILE\.dotnet\tools" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + & "$env:USERPROFILE\.dotnet\tools\wix.exe" --version + + - name: Build and sign MSI (Windows) + if: ${{ startsWith(github.ref, 'refs/tags/') && matrix.os == 'windows' && matrix.tray }} + shell: pwsh + env: + # Reuse the binaries the previous "Sign Windows binaries" step + # already signed; build-msi.ps1 skips its internal `go build` when + # both ADDER_EXE and ADDER_TRAY_EXE point at existing files. + ADDER_EXE: ${{ github.workspace }}\${{ env.APPLICATION_NAME }}.exe + ADDER_TRAY_EXE: ${{ github.workspace }}\${{ env.APPLICATION_NAME }}-tray.exe + # MSI ProductVersion: strip leading "v" from the git tag (e.g. v0.39.1 -> 0.39.1). + VERSION: ${{ env.RELEASE_TAG }} + ARCH: ${{ matrix.arch }} + # jsign: reuse the artefacts already on disk from the binary-sign step. + JSIGN_JAR: ${{ github.workspace }}\jsign.jar + JSIGN_KEYSTORE: ${{ secrets.CERTIFICATE_KEYSTORE }} + JSIGN_STORETYPE: ${{ secrets.CERTIFICATE_STORE_TYPE }} + JSIGN_ALIAS: ${{ secrets.CERTIFICATE_KEY_NAME }} + JSIGN_CERTFILE: ${{ github.workspace }}\codesign-chain.pem + JSIGN_TSAURL: 'http://timestamp.globalsign.com/tsa/r6advanced1' + run: | + # Fresh, masked Google Cloud KMS access token for jsign --storepass. + $ACCESS_TOKEN = & gcloud auth print-access-token + Write-Host "::add-mask::$ACCESS_TOKEN" + $env:JSIGN_STOREPASS = $ACCESS_TOKEN + + pwsh ./packaging/windows/build-msi.ps1 + if ($LASTEXITCODE -ne 0) { Write-Error "build-msi.ps1 failed"; exit 1 } + $env:JSIGN_STOREPASS = $null $ACCESS_TOKEN = $null - Write-Host "Signed Windows binary: $filename" - Write-Host "Cleaning up certificate chain" - Remove-Item -Path "codesign-chain.pem" -Force + Write-Host "Cleaning up code-signing artefacts" + Remove-Item -Path "codesign-chain.pem" -Force -ErrorAction SilentlyContinue + Remove-Item -Path "jsign.jar" -Force -ErrorAction SilentlyContinue + + - name: Verify MSI signature (Windows) + if: ${{ startsWith(github.ref, 'refs/tags/') && matrix.os == 'windows' && matrix.tray }} + shell: pwsh + run: | + # Locate signtool.exe in the Windows 10/11 SDK install (preinstalled + # on windows-latest / windows-11-arm runners). + $sdkRoot = "${env:ProgramFiles(x86)}\Windows Kits\10\bin" + if (-not (Test-Path $sdkRoot)) { + Write-Error "Windows SDK not found at $sdkRoot" + exit 1 + } + $archDir = if ("${{ matrix.arch }}" -eq "arm64") { "arm64" } else { "x64" } + $signtool = Get-ChildItem -Path $sdkRoot -Recurse -Filter signtool.exe ` + | Where-Object { $_.FullName -match "\\$archDir\\signtool\.exe$" } ` + | Sort-Object FullName -Descending ` + | Select-Object -First 1 + if (-not $signtool) { + # Fall back to any signtool.exe under the SDK. + $signtool = Get-ChildItem -Path $sdkRoot -Recurse -Filter signtool.exe ` + | Sort-Object FullName -Descending | Select-Object -First 1 + } + if (-not $signtool) { + Write-Error "signtool.exe not found under $sdkRoot" + exit 1 + } + Write-Host "Using signtool at: $($signtool.FullName)" + + $cleanVersion = "${{ env.RELEASE_TAG }}" -replace '^v', '' + $msi = "dist\${{ env.APPLICATION_NAME }}-$cleanVersion-windows-${{ matrix.arch }}.msi" + if (-not (Test-Path $msi)) { + Write-Error "MSI not found at $msi" + exit 1 + } + + & $signtool.FullName verify /pa /v $msi + if ($LASTEXITCODE -ne 0) { + Write-Error "signtool verify failed for $msi" + exit 1 + } + Write-Host "MSI signature verified: $msi" # Build macOS .pkg installer # @@ -306,20 +423,26 @@ jobs: echo "::endgroup::" fi - - name: Upload release asset (Windows) - if: startsWith(github.ref, 'refs/tags/') && matrix.os == 'windows' + - name: Upload MSI release asset (Windows) + if: startsWith(github.ref, 'refs/tags/') && matrix.os == 'windows' && matrix.tray shell: pwsh run: | - $filename = "${{ env.APPLICATION_NAME }}-${{ env.RELEASE_TAG }}-${{ matrix.os }}-${{ matrix.arch }}.zip" - Compress-Archive "${{ env.APPLICATION_NAME }}.exe" "$filename" - Write-Host "Uploading file: $filename" - # Upload file using PowerShell + $cleanVersion = "${{ env.RELEASE_TAG }}" -replace '^v', '' + $msiPath = "dist\${{ env.APPLICATION_NAME }}-$cleanVersion-windows-${{ matrix.arch }}.msi" + # Asset name follows the existing convention: + # adder---. (RELEASE_TAG keeps the leading "v"). + $assetName = "${{ env.APPLICATION_NAME }}-${{ env.RELEASE_TAG }}-${{ matrix.os }}-${{ matrix.arch }}.msi" + if (-not (Test-Path $msiPath)) { + Write-Error "MSI not found at $msiPath" + exit 1 + } + Write-Host "Uploading $msiPath as $assetName" $headers = @{ "Authorization" = "token ${{ secrets.GITHUB_TOKEN }}" "Content-Type" = "application/octet-stream" } - $uploadUrl = "https://uploads.github.com/repos/${{ github.repository }}/releases/${{ needs.create-draft-release.outputs.RELEASE_ID }}/assets?name=$filename" - Invoke-RestMethod -Uri $uploadUrl -Method Post -Headers $headers -InFile $filename + $uploadUrl = "https://uploads.github.com/repos/${{ github.repository }}/releases/${{ needs.create-draft-release.outputs.RELEASE_ID }}/assets?name=$assetName" + Invoke-RestMethod -Uri $uploadUrl -Method Post -Headers $headers -InFile $msiPath - name: Upload release asset (macOS pkg) if: startsWith(github.ref, 'refs/tags/') && matrix.os == 'darwin' @@ -348,11 +471,19 @@ jobs: --data-binary @"${_filename}" \ "https://uploads.github.com/repos/${{ github.repository }}/releases/${{ needs.create-draft-release.outputs.RELEASE_ID }}/assets?name=${_filename}" - - name: Attest binary (Windows) - if: startsWith(github.ref, 'refs/tags/') && matrix.os == 'windows' + - name: Compute MSI path (Windows) + if: startsWith(github.ref, 'refs/tags/') && matrix.os == 'windows' && matrix.tray + shell: pwsh + run: | + $cleanVersion = "${{ env.RELEASE_TAG }}" -replace '^v', '' + $msiPath = "dist\${{ env.APPLICATION_NAME }}-$cleanVersion-windows-${{ matrix.arch }}.msi" + "MSI_PATH=$msiPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Attest MSI (Windows) + if: startsWith(github.ref, 'refs/tags/') && matrix.os == 'windows' && matrix.tray uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 https://github.com/actions/attest/releases/tag/v4.1.1 with: - subject-path: '${{ env.APPLICATION_NAME }}.exe' + subject-path: ${{ env.MSI_PATH }} - name: Attest pkg (macOS) if: startsWith(github.ref, 'refs/tags/') && matrix.os == 'darwin' @@ -386,7 +517,7 @@ jobs: arch: arm64 runs-on: ${{ matrix.os }} steps: - - run: "echo \"RELEASE_TAG=${GITHUB_REF#refs/tags/}\" >> $GITHUB_ENV" + - run: "echo \"RELEASE_TAG=${GITHUB_REF#refs/tags/}\" >> \"$GITHUB_ENV\"" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 https://github.com/actions/checkout/releases/tag/v7.0.0 with: fetch-depth: '0' @@ -476,12 +607,15 @@ jobs: - name: manifest-create shell: bash run: | - for t in `echo '${{ steps.meta.outputs.tags }}'`; do - # Extract the underlying manifests from each manifests list and create a new single manifest list - docker manifest create ${t} \ - $(docker manifest inspect ${t}-amd64 | jq -r '.manifests[] | .digest' | sed -e "s|^|${t%:*}@|") \ - $(docker manifest inspect ${t}-arm64 | jq -r '.manifests[] | .digest' | sed -e "s|^|${t%:*}@|") - docker manifest push ${t} + for t in ${{ steps.meta.outputs.tags }}; do + # Extract the underlying manifests from each manifests list and create a new single manifest list. + # Word-splitting of the two command substitutions below is intentional: + # each digest line becomes a separate argument to `docker manifest create`. + # shellcheck disable=SC2046 + docker manifest create "${t}" \ + $(docker manifest inspect "${t}-amd64" | jq -r '.manifests[] | .digest' | sed -e "s|^|${t%:*}@|") \ + $(docker manifest inspect "${t}-arm64" | jq -r '.manifests[] | .digest' | sed -e "s|^|${t%:*}@|") + docker manifest push "${t}" done # Checkout repo so README.md is available for next step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 https://github.com/actions/checkout/releases/tag/v7.0.0 diff --git a/.github/workflows/test-msi.yml b/.github/workflows/test-msi.yml new file mode 100644 index 00000000..4e9a4558 --- /dev/null +++ b/.github/workflows/test-msi.yml @@ -0,0 +1,249 @@ +name: test-msi + +on: + workflow_dispatch: + inputs: + arch: + description: 'Windows architecture to build' + type: choice + default: amd64 + options: + - amd64 + - arm64 + +concurrency: + group: test-msi-${{ github.ref }} + cancel-in-progress: true + +env: + APPLICATION_NAME: 'adder' + +jobs: + build-msi: + # Push builds default to amd64; manual runs can choose amd64 or arm64. + runs-on: ${{ github.event_name == 'workflow_dispatch' && inputs.arch == 'arm64' && 'windows-11-arm' || 'windows-latest' }} + permissions: + contents: read + id-token: write + env: + WINDOWS_ARCH: ${{ github.event_name == 'workflow_dispatch' && inputs.arch || 'amd64' }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: '0' + + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: 1.26.x + + - name: Set up MSYS2 MinGW64 (amd64 CGO for Fyne) + if: env.WINDOWS_ARCH == 'amd64' + id: msys2-amd64 + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + update: true + install: >- + mingw-w64-x86_64-gcc + + - name: Set up MSYS2 CLANGARM64 (arm64 CGO for Fyne) + if: env.WINDOWS_ARCH == 'arm64' + id: msys2-arm64 + uses: msys2/setup-msys2@v2 + with: + msystem: CLANGARM64 + update: true + install: >- + mingw-w64-clang-aarch64-clang + + - name: Build binaries + shell: pwsh + run: | + $env:GOOS = 'windows' + $env:GOARCH = '${{ env.WINDOWS_ARCH }}' + + if ('${{ env.WINDOWS_ARCH }}' -eq 'amd64') { + $msys2Loc = '${{ steps.msys2-amd64.outputs.msys2-location }}' + $env:CC = "$msys2Loc\mingw64\bin\gcc.exe" + $env:CXX = "$msys2Loc\mingw64\bin\g++.exe" + } else { + $msys2Loc = '${{ steps.msys2-arm64.outputs.msys2-location }}' + $env:CC = "$msys2Loc\clangarm64\bin\clang.exe" + $env:CXX = "$msys2Loc\clangarm64\bin\clang++.exe" + } + + go env GOOS GOARCH CC CXX + & $env:CC --version + + make build + make build-tray + + # --- Optional binary signing (only when secrets are accessible) ------- + # When the EV-cert secrets aren't available (forks, missing env), the + # subsequent MSI build still works and produces an UNSIGNED .msi. + # + # NOTE: the `secrets` context is NOT available in step-level `if:` + # conditions, so we surface secret presence as a step output here + # (secrets ARE allowed in step `env:`) and gate the signing steps on it. + - name: Check signing secrets + id: check_signing + shell: pwsh + env: + SA_CREDS: ${{ secrets.CERTIFICATE_SA_CREDENTIALS }} + run: | + if ([string]::IsNullOrEmpty($env:SA_CREDS)) { + "enabled=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Warning "No signing credentials available - MSI will be UNSIGNED." + } else { + "enabled=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + } + + - name: Set up Java (for jsign) + if: steps.check_signing.outputs.enabled == 'true' + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + with: + java-version: 21 + distribution: 'temurin' + + - id: 'auth' + name: Authenticate with Google Cloud + if: steps.check_signing.outputs.enabled == 'true' + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + continue-on-error: true + with: + credentials_json: '${{ secrets.CERTIFICATE_SA_CREDENTIALS }}' + + - name: Set up Cloud SDK + if: ${{ steps.auth.outcome == 'success' }} + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 + + - name: Sign Windows binaries (adder.exe + adder-tray.exe) + id: sign-binaries + if: ${{ steps.auth.outcome == 'success' }} + shell: pwsh + run: | + Write-Host "Downloading jsign.jar" + Invoke-WebRequest -Uri "https://github.com/ebourg/jsign/releases/download/6.0/jsign-6.0.jar" -OutFile "jsign.jar" + $expectedHash = "05ca18d4ab7b8c2183289b5378d32860f0ea0f3bdab1f1b8cae5894fb225fa8a" + $actualHash = (Get-FileHash -Path "jsign.jar" -Algorithm SHA256).Hash.ToLower() + if ($actualHash -ne $expectedHash) { + Write-Error "jsign.jar hash mismatch (expected $expectedHash, got $actualHash)" + exit 1 + } + + [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${{ secrets.CERTIFICATE_CHAIN }}")) | Out-File -FilePath "codesign-chain.pem" -Encoding utf8 + + $ACCESS_TOKEN = & gcloud auth print-access-token + Write-Host "::add-mask::$ACCESS_TOKEN" + + foreach ($filename in @("${{ env.APPLICATION_NAME }}.exe", "${{ env.APPLICATION_NAME }}-tray.exe")) { + if (-not (Test-Path $filename)) { + Write-Error "Expected binary not found: $filename" + exit 1 + } + Write-Host "Signing $filename" + # --tsretries/--tsretrywait are jsign's native timestamp retry. + java -jar jsign.jar ` + --storetype ${{ secrets.CERTIFICATE_STORE_TYPE }} ` + --storepass "$ACCESS_TOKEN" ` + --keystore ${{ secrets.CERTIFICATE_KEYSTORE }} ` + --alias ${{ secrets.CERTIFICATE_KEY_NAME }} ` + --certfile "codesign-chain.pem" ` + --alg SHA-256 ` + --tsmode RFC3161 ` + --tsaurl "http://timestamp.globalsign.com/tsa/r6advanced1" ` + --tsretries 3 ` + --tsretrywait 10 ` + --name "Adder" ` + --url "https://github.com/blinklabs-io/adder" ` + $filename + if ($LASTEXITCODE -ne 0) { Write-Error "jsign failed on $filename"; exit 1 } + + # Confirm signed, chain-trusted AND timestamped before MSI embedding. + $sig = Get-AuthenticodeSignature $filename + if ($sig.Status -ne 'Valid') { + Write-Error "signature not valid for ${filename}: $($sig.Status)"; exit 1 + } + if ($null -eq $sig.TimeStamperCertificate) { + Write-Error "signature for ${filename} is not timestamped"; exit 1 + } + Write-Host "Signed + verified: $filename ($($sig.SignerCertificate.Subject))" + } + $ACCESS_TOKEN = $null + + # --- WiX + MSI build (always runs, signed or not) --------------------- + + - name: Install WiX toolset + shell: pwsh + run: | + dotnet tool install --global wix --version 4.0.5 + "$env:USERPROFILE\.dotnet\tools" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + & "$env:USERPROFILE\.dotnet\tools\wix.exe" --version + + - name: Build MSI + shell: pwsh + env: + ADDER_EXE: ${{ github.workspace }}\${{ env.APPLICATION_NAME }}.exe + ADDER_TRAY_EXE: ${{ github.workspace }}\${{ env.APPLICATION_NAME }}-tray.exe + # Use the short commit SHA as a fake version for the MSI ProductVersion. + # MSI requires <=3 numeric dot-separated fields, so we derive a fresh + # increasing version from the GitHub run number for ordered installs. + VERSION: 0.0.${{ github.run_number }} + ARCH: ${{ env.WINDOWS_ARCH }} + # Signing env (passed through to build-msi.ps1). When the auth step + # was skipped these are all empty and the script no-ops signing + # with a warning, producing an UNSIGNED .msi. + JSIGN_JAR: ${{ steps.sign-binaries.outcome == 'success' && format('{0}\jsign.jar', github.workspace) || '' }} + JSIGN_KEYSTORE: ${{ steps.sign-binaries.outcome == 'success' && secrets.CERTIFICATE_KEYSTORE || '' }} + JSIGN_STORETYPE: ${{ steps.sign-binaries.outcome == 'success' && secrets.CERTIFICATE_STORE_TYPE || '' }} + JSIGN_ALIAS: ${{ steps.sign-binaries.outcome == 'success' && secrets.CERTIFICATE_KEY_NAME || '' }} + JSIGN_CERTFILE: ${{ steps.sign-binaries.outcome == 'success' && format('{0}\codesign-chain.pem', github.workspace) || '' }} + JSIGN_TSAURL: 'http://timestamp.globalsign.com/tsa/r6advanced1' + run: | + if ('${{ steps.sign-binaries.outcome }}' -eq 'success') { + $ACCESS_TOKEN = & gcloud auth print-access-token + Write-Host "::add-mask::$ACCESS_TOKEN" + $env:JSIGN_STOREPASS = $ACCESS_TOKEN + } else { + Write-Warning "No signing credentials available — producing UNSIGNED MSI for testing." + } + + pwsh ./packaging/windows/build-msi.ps1 + if ($LASTEXITCODE -ne 0) { Write-Error "build-msi.ps1 failed"; exit 1 } + + $env:JSIGN_STOREPASS = $null + + - name: Verify MSI signature + if: ${{ steps.sign-binaries.outcome == 'success' }} + shell: pwsh + run: | + $sdkRoot = "${env:ProgramFiles(x86)}\Windows Kits\10\bin" + $archDir = if ('${{ env.WINDOWS_ARCH }}' -eq 'arm64') { 'arm64' } else { 'x64' } + $signtool = Get-ChildItem -Path $sdkRoot -Recurse -Filter signtool.exe ` + | Where-Object { $_.FullName -match "\\$archDir\\signtool\.exe$" } ` + | Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $signtool) { + $signtool = Get-ChildItem -Path $sdkRoot -Recurse -Filter signtool.exe ` + | Sort-Object FullName -Descending | Select-Object -First 1 + } + if (-not $signtool) { Write-Error "signtool.exe not found under $sdkRoot"; exit 1 } + + $msi = "dist\${{ env.APPLICATION_NAME }}-0.0.${{ github.run_number }}-windows-${{ env.WINDOWS_ARCH }}.msi" + & $signtool.FullName verify /pa /v $msi + if ($LASTEXITCODE -ne 0) { Write-Error "signtool verify failed for $msi"; exit 1 } + Write-Host "MSI signature verified: $msi" + + - name: Clean up signing artefacts + if: ${{ always() && steps.sign-binaries.outcome == 'success' }} + shell: pwsh + run: | + Remove-Item -Path "codesign-chain.pem" -Force -ErrorAction SilentlyContinue + Remove-Item -Path "jsign.jar" -Force -ErrorAction SilentlyContinue + + - name: Upload MSI artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: adder-test-windows-${{ env.WINDOWS_ARCH }}-run${{ github.run_number }} + path: dist\${{ env.APPLICATION_NAME }}-0.0.${{ github.run_number }}-windows-${{ env.WINDOWS_ARCH }}.msi + if-no-files-found: error + retention-days: 1 diff --git a/Makefile b/Makefile index 9a38ea44..9ba9302f 100644 --- a/Makefile +++ b/Makefile @@ -11,12 +11,18 @@ BINARIES=$(shell cd $(ROOT_DIR)/cmd && ls -1 | grep -v ^common | grep -v ^adder- GOMODULE=$(shell grep ^module $(ROOT_DIR)/go.mod | awk '{ print $$2 }') # Set version strings based on git tag and current ref -GO_LDFLAGS=-ldflags "-s -w -X '$(GOMODULE)/internal/version.Version=$(shell git describe --tags --exact-match 2>/dev/null)' -X '$(GOMODULE)/internal/version.CommitHash=$(shell git rev-parse --short HEAD)'" +GO_LDFLAGS_CONTENT=-s -w -X '$(GOMODULE)/internal/version.Version=$(shell git describe --tags --exact-match 2>/dev/null)' -X '$(GOMODULE)/internal/version.CommitHash=$(shell git rev-parse --short HEAD)' +GO_LDFLAGS=-ldflags "$(GO_LDFLAGS_CONTENT)" + +# adder-tray must link against the Windows GUI subsystem so launching it +# does not spawn a console window. -H=windowsgui is only valid for +# GOOS=windows, so it is appended conditionally. +TRAY_LDFLAGS=-ldflags "$(GO_LDFLAGS_CONTENT)$(if $(filter windows,$(GOOS)), -H=windowsgui,)" GO_CGO_CFLAGS=$(shell go env CGO_CFLAGS) TRAY_CGO_CFLAGS=$(strip $(GO_CGO_CFLAGS) $(if $(filter windows/arm64,$(GOOS)/$(GOARCH)),-DWINBOOL=BOOL,)) -.PHONY: build build-tray mod-tidy clean test bundle-macos pkg-macos pkg-macos-adhoc +.PHONY: build build-tray mod-tidy clean test wizard-screenshots bundle-macos pkg-macos pkg-macos-adhoc # Alias for building program binary build: $(BINARIES) @@ -58,11 +64,17 @@ swagger: test: mod-tidy go test -v -race ./... +# Render each setup-wizard step to /tmp/wizard_step{N}.png for visual +# review (override dir with WIZARD_CAPTURE_DIR). Dev-only; the underlying +# test is skipped in normal runs unless WIZARD_CAPTURE=1. +wizard-screenshots: + WIZARD_CAPTURE=1 go test ./tray/wizard/ -run TestCaptureSteps -count=1 -v + # Build adder-tray binary # CGO is required on all platforms for Fyne UI support. build-tray: mod-tidy $(GO_FILES) CGO_CFLAGS="$(TRAY_CGO_CFLAGS)" CGO_ENABLED=1 go build \ - $(GO_LDFLAGS) \ + $(TRAY_LDFLAGS) \ -o adder-tray$(if $(filter windows,$(GOOS)),.exe,) \ ./cmd/adder-tray diff --git a/README.md b/README.md index b65f2991..64322a4c 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,12 @@ specifying multiple possible values separated by commas. When specifying multiple values for a filter, only one of the values specified must match an event. +Adder Tray applies target-oriented notification semantics rather than the +generic pipeline rules described below. See +[Adder Tray Filtering and Notification Semantics](./docs/adder-tray-filtering.md) +for simple target matching, advanced rule groups, DRep and pool behavior, and +the current ChainSync notification inventory. + You can get a list of all available filter options by using the `-h`/`-help` flag. diff --git a/api/api.go b/api/api.go index 3b62cf7e..d873de87 100644 --- a/api/api.go +++ b/api/api.go @@ -1,9 +1,13 @@ package api import ( + "context" "encoding/json" + "errors" "fmt" "log" + "log/slog" + "net" "net/http" "sync" "time" @@ -21,6 +25,7 @@ type HealthChecker interface { type API interface { Start() error + Shutdown(context.Context) error AddRoute(method, path string, handler gin.HandlerFunc) } @@ -29,6 +34,9 @@ type APIv1 struct { ApiGroup *gin.RouterGroup Host string Port uint + server *http.Server + listener net.Listener + mu sync.Mutex } type APIRouteRegistrar interface { @@ -122,22 +130,51 @@ func (a *APIv1) Engine() *gin.Engine { // @license.name Apache 2.0 // @license.url http://www.apache.org/licenses/LICENSE-2.0.html func (a *APIv1) Start() error { + a.mu.Lock() + defer a.mu.Unlock() + if a.listener != nil { + return errors.New("API server is already running") + } address := fmt.Sprintf("%s:%d", a.Host, a.Port) - // Use buffered channel to not block goroutine - errChan := make(chan error, 1) - + listener, err := net.Listen("tcp", address) + if err != nil { + return fmt.Errorf("listening on %s: %w", address, err) + } + a.listener = listener + a.server = &http.Server{ + Handler: a.engine, + ReadHeaderTimeout: 60 * time.Second, + } + server := a.server go func() { - // Capture the error returned by Run - errChan <- a.engine.Run(address) + if err := server.Serve(listener); err != nil && + !errors.Is(err, http.ErrServerClosed) { + slog.Error("API server stopped unexpectedly", "error", err) + } }() + return nil +} - select { - case err := <-errChan: - return err - default: - // No starting errors, start server +func (a *APIv1) Shutdown(ctx context.Context) error { + a.mu.Lock() + server := a.server + listener := a.listener + a.mu.Unlock() + if server == nil { + return nil } - + if listener != nil { + if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + return fmt.Errorf("closing API listener: %w", err) + } + } + if err := server.Shutdown(ctx); err != nil && !errors.Is(err, net.ErrClosed) { + return fmt.Errorf("shutting down API server: %w", err) + } + a.mu.Lock() + a.server = nil + a.listener = nil + a.mu.Unlock() return nil } diff --git a/api/events.go b/api/events.go index 4e66447d..3297b3d9 100644 --- a/api/events.go +++ b/api/events.go @@ -18,6 +18,7 @@ import ( "encoding/json" "log/slog" "net/http" + "strconv" "strings" "sync" @@ -184,13 +185,19 @@ var wsUpgrader = websocket.Upgrader{ // WebSocket if possible, otherwise falls back to SSE. func (h *EventHub) HandleEvents(c *gin.Context) { typeFilter := parseTypeFilter(c.Query("types")) + replay := true + if value := c.Query("replay"); value != "" { + if parsed, err := strconv.ParseBool(value); err == nil { + replay = parsed + } + } if websocket.IsWebSocketUpgrade(c.Request) { - h.handleWebSocket(c.Writer, c.Request, typeFilter) + h.handleWebSocket(c.Writer, c.Request, typeFilter, replay) return } - h.handleSSE(c, typeFilter) + h.handleSSE(c, typeFilter, replay) } // parseTypeFilter parses a comma-separated list of event types into a @@ -216,6 +223,7 @@ func (h *EventHub) handleWebSocket( w http.ResponseWriter, r *http.Request, typeFilter map[string]bool, + replayEnabled bool, ) { conn, err := wsUpgrader.Upgrade(w, r, nil) if err != nil { @@ -233,7 +241,10 @@ func (h *EventHub) handleWebSocket( // event can be both replayed and delivered via client.send. h.mu.Lock() h.clients[client] = struct{}{} - replay := h.recentEventsLocked(typeFilter) + var replay []event.Event + if replayEnabled { + replay = h.recentEventsLocked(typeFilter) + } h.mu.Unlock() // Replay recent events directly on this goroutine. This MUST @@ -292,7 +303,11 @@ func (h *EventHub) handleWebSocket( }() } -func (h *EventHub) handleSSE(c *gin.Context, typeFilter map[string]bool) { +func (h *EventHub) handleSSE( + c *gin.Context, + typeFilter map[string]bool, + replayEnabled bool, +) { c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") @@ -307,7 +322,10 @@ func (h *EventHub) handleSSE(c *gin.Context, typeFilter map[string]bool) { // event can be both replayed and delivered via client.send. h.mu.Lock() h.clients[client] = struct{}{} - replay := h.recentEventsLocked(typeFilter) + var replay []event.Event + if replayEnabled { + replay = h.recentEventsLocked(typeFilter) + } h.mu.Unlock() defer func() { diff --git a/api/events_test.go b/api/events_test.go index bba8401c..17dd8d3f 100644 --- a/api/events_test.go +++ b/api/events_test.go @@ -119,6 +119,29 @@ func TestEventHub_RingBufferReplay(t *testing.T) { } } +func TestEventHub_ReplayCanBeDisabled(t *testing.T) { + hub := api.NewEventHub(5) + defer hub.Close() + hub.Broadcast(event.Event{ + Type: "input.rollback", + Timestamp: time.Now(), + }) + + router := newTestRouter(hub) + server := httptest.NewServer(router) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + + "/events?replay=false" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() + + conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) + _, _, err = conn.ReadMessage() + assert.Error(t, err, "buffered rollback must not replay") +} + func TestEventHub_TypeFiltering(t *testing.T) { hub := api.NewEventHub(10) defer hub.Close() diff --git a/api/lifecycle_test.go b/api/lifecycle_test.go new file mode 100644 index 00000000..81ec139f --- /dev/null +++ b/api/lifecycle_test.go @@ -0,0 +1,62 @@ +// Copyright 2025 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "context" + "net" + "testing" + "time" +) + +func TestAPIStartReturnsBindError(t *testing.T) { + occupied, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer occupied.Close() + port := uint(occupied.Addr().(*net.TCPAddr).Port) + server := &APIv1{ + engine: ConfigureRouter(false), + Host: "127.0.0.1", + Port: port, + } + + if err := server.Start(); err == nil { + t.Fatal("expected occupied port to fail synchronously") + } +} + +func TestAPIShutdownReleasesListener(t *testing.T) { + server := &APIv1{ + engine: ConfigureRouter(false), + Host: "127.0.0.1", + Port: 0, + } + if err := server.Start(); err != nil { + t.Fatal(err) + } + address := server.listener.Addr().String() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + t.Fatal(err) + } + conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond) + if err == nil { + conn.Close() + t.Fatal("listener still accepted connections after shutdown") + } +} diff --git a/bundle-macos.sh b/bundle-macos.sh index 3ea7a6f4..b29a1ebb 100755 --- a/bundle-macos.sh +++ b/bundle-macos.sh @@ -8,6 +8,12 @@ CONTENTS_DIR="${BUNDLE_DIR}/Contents" MACOS_DIR="${CONTENTS_DIR}/MacOS" RESOURCES_DIR="${CONTENTS_DIR}/Resources" +echo "--- Terminating running instances ---" +# Best-effort terminate any running tray or engine to prevent "text file busy" locks +pkill -f "${APP_NAME}" || true +pkill -f "Contents/MacOS/adder" || true +pkill -f "adder-tray" || true + echo "--- Cleaning old builds ---" rm -f adder adder-tray rm -rf "${BUNDLE_DIR}" @@ -32,8 +38,12 @@ else echo "Warning: Adder.icns not found in .github/assets/" fi -VERSION="1.5.0" -echo "--- Generating Info.plist (Version: ${VERSION}) ---" +BASE_VERSION="1.5.0" +TIMESTAMP=$(date +%Y%m%d.%H%M%S) +GIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "nohash") +BUILD_VER="${BASE_VERSION}-dev+${TIMESTAMP}.${GIT_HASH}" + +echo "--- Generating Info.plist (Version: ${BASE_VERSION}, Build: ${BUILD_VER}) ---" cat < "${CONTENTS_DIR}/Info.plist" @@ -52,9 +62,9 @@ cat < "${CONTENTS_DIR}/Info.plist" CFBundlePackageType APPL CFBundleShortVersionString - ${VERSION} + ${BASE_VERSION} CFBundleVersion - ${VERSION} + ${BUILD_VER} LSUIElement NSHighResolutionCapable @@ -88,5 +98,8 @@ rm -rf "${DEST_APP}" cp -R "${BUNDLE_DIR}" /Applications/ echo "--- SUCCESS: ${APP_NAME}.app installed to /Applications ---" +echo "To verify the installed build version (QA), run:" +echo " defaults read \"${DEST_APP}/Contents/Info\" CFBundleVersion" +echo "" echo "To run with the correct icon and functional notifications, use:" -echo "open \"${DEST_APP}\"" +echo " open \"${DEST_APP}\"" diff --git a/cmd/adder-tray/instance_other.go b/cmd/adder-tray/instance_other.go new file mode 100644 index 00000000..ff494108 --- /dev/null +++ b/cmd/adder-tray/instance_other.go @@ -0,0 +1,20 @@ +// Copyright 2026 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package main + +// acquireSingleInstance is a no-op on non-Windows platforms. +func acquireSingleInstance() bool { return true } diff --git a/cmd/adder-tray/instance_windows.go b/cmd/adder-tray/instance_windows.go new file mode 100644 index 00000000..21b0ad1f --- /dev/null +++ b/cmd/adder-tray/instance_windows.go @@ -0,0 +1,48 @@ +// Copyright 2026 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package main + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +// instanceMutexName is per-session (Local\) so one tray runs per logon session. +const instanceMutexName = `Local\io.blinklabs.adder.tray` + +// acquireSingleInstance returns true if this is the only tray instance in the +// session. It creates a named mutex held for the process lifetime; a second +// instance sees ERROR_ALREADY_EXISTS. On unexpected errors it returns true +// (fail open) rather than blocking startup. +func acquireSingleInstance() bool { + name, err := windows.UTF16PtrFromString(instanceMutexName) + if err != nil { + return true + } + // CreateMutex returns a valid handle even when the mutex already exists, + // with err == ERROR_ALREADY_EXISTS. Intentionally leak the handle: it must + // live for the whole process so the session-wide name stays claimed. + h, err := windows.CreateMutex(nil, false, name) + if errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + if h != 0 { + _ = windows.CloseHandle(h) + } + return false + } + return true +} diff --git a/cmd/adder-tray/main.go b/cmd/adder-tray/main.go index d723d718..35b5dd85 100644 --- a/cmd/adder-tray/main.go +++ b/cmd/adder-tray/main.go @@ -15,9 +15,14 @@ package main import ( + "io" + "log" "log/slog" "os" "os/signal" + "path/filepath" + "runtime" + "runtime/debug" "syscall" "fyne.io/fyne/v2/app" @@ -25,19 +30,60 @@ import ( "github.com/blinklabs-io/adder/internal/logging" "github.com/blinklabs-io/adder/internal/ui/assets" "github.com/blinklabs-io/adder/tray" + "github.com/blinklabs-io/adder/tray/setup" ) func main() { + // On Windows the MSI bundles Mesa's software OpenGL (opengl32.dll + + // libgallium_wgl.dll) next to the exe so the Fyne GUI renders on VMs / + // headless / RDP hosts that lack a hardware OpenGL driver. Pin the Gallium + // driver to llvmpipe so Mesa does not try the D3D12 path, which needs + // dxil.dll we do not ship. Respect an explicit user override. + if runtime.GOOS == "windows" { + if _, ok := os.LookupEnv("GALLIUM_DRIVER"); !ok { + _ = os.Setenv("GALLIUM_DRIVER", "llvmpipe") + } + } + // Initialize logging cfg := config.GetConfig() if err := cfg.Load(""); err != nil { slog.Warn("failed to load environment config", "error", err) } - logging.Configure() + + // The tray is linked -H=windowsgui on Windows, so it has no console and + // os.Stderr is discarded. Tee logs to a file so errors and crashes are + // diagnosable; keep stderr for dev/console builds where it is visible. + logOut := io.Writer(os.Stderr) + if f := openTrayLogFile(); f != nil { + logOut = io.MultiWriter(f, os.Stderr) + } + logging.ConfigureWithWriter(logOut) slog.SetDefault(logging.GetLogger()) + // Route the standard library logger to the same sink so Fyne/GLFW + // diagnostics (which use the stdlib logger) are captured too. + log.SetOutput(logOut) cfgLevel := config.GetConfig().Logging.Level slog.Debug("logging initialized", "level", cfgLevel) + // Capture an otherwise-silent panic (e.g. an OpenGL/window init failure on + // a GPU-less VM) to the log before the process exits. + defer func() { + if r := recover(); r != nil { + slog.Error("adder-tray panic", + "panic", r, + "stack", string(debug.Stack())) + os.Exit(1) + } + }() + + // Refuse to run a second tray in the same session. A duplicate would fight + // the first over the engine lifecycle. + if !acquireSingleInstance() { + slog.Info("another adder-tray instance is already running; exiting") + return + } + // Initialize Fyne app with a unique ID for professional persistence a := app.NewWithID("io.blinklabs.adder.tray") @@ -68,3 +114,21 @@ func main() { // Start the application. This blocks until a.Quit() is called. application.Run() } + +// openTrayLogFile opens (creating as needed) the tray's log file under the +// platform log directory, appending. It returns nil on failure so logging +// falls back to os.Stderr only — logging setup must never abort startup. +func openTrayLogFile() *os.File { + dir := setup.LogDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + slog.Warn("could not create log directory", "dir", dir, "error", err) + return nil + } + path := filepath.Join(dir, "adder-tray.log") + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + slog.Warn("could not open log file", "path", path, "error", err) + return nil + } + return f +} diff --git a/cmd/adder/main.go b/cmd/adder/main.go index fe75e5f1..2590691c 100644 --- a/cmd/adder/main.go +++ b/cmd/adder/main.go @@ -15,6 +15,7 @@ package main import ( + "context" "fmt" "log/slog" "net/http" @@ -220,6 +221,15 @@ func run(cmd *cobra.Command) error { logger.Error(fmt.Sprintf("failed to start API: %s", err)) return fmt.Errorf("failed to start API: %w", err) } + defer func() { + shutdownCtx, cancel := context.WithTimeout( + context.Background(), 10*time.Second, + ) + defer cancel() + if err := apiInstance.Shutdown(shutdownCtx); err != nil { + logger.Error(fmt.Sprintf("failed to stop API: %s", err)) + } + }() // Start pipeline and wait for error if err := pipe.Start(); err != nil { @@ -250,6 +260,12 @@ func run(cmd *cobra.Command) error { logger.Error(fmt.Sprintf("failed to stop pipeline: %s", err)) return fmt.Errorf("failed to stop pipeline: %w", err) } + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := apiInstance.Shutdown(shutdownCtx); err != nil { + logger.Error(fmt.Sprintf("failed to stop API: %s", err)) + return fmt.Errorf("failed to stop API: %w", err) + } logger.Info("Adder stopped gracefully") return nil diff --git a/docs/adder-tray-filtering.md b/docs/adder-tray-filtering.md new file mode 100644 index 00000000..da8dbb4e --- /dev/null +++ b/docs/adder-tray-filtering.md @@ -0,0 +1,451 @@ +# Adder Tray Filtering and Notification Semantics + +## Purpose + +This document describes how monitoring filters should behave from a user's +perspective. It focuses on the tray application and desktop notifications, +especially when a user follows DReps and stake pools. + +The central principle is: + +> A monitoring target answers "who or what do I care about?" A notification +> preference answers "which actions involving that target should alert me?" + +These are separate choices. Adding more targets should normally broaden what +the user monitors, not make notifications nearly impossible to trigger. + +## Cardano Roles + +### DReps + +A Delegated Representative (DRep) participates in Cardano governance on behalf +of ADA holders who delegate voting power to that DRep. A user may follow: + +- the DRep to whom they delegated; +- their own DRep identity; +- a candidate they are evaluating; +- several DReps whose voting records they want to compare; +- a DRep whose registration status or activity is important to them. + +The useful DRep events are: + +- a vote cast by the DRep, including the governance action and vote choice; +- registration, update, or retirement of the DRep; +- a delegation to or away from the DRep, when that event is available; +- a new governance proposal that the followed DRep may vote on. + +A proposal is not normally created "by" a DRep. Therefore, a new-proposal +alert is a general call to action for somebody following DReps, not proof that +the followed DRep participated in the proposal. + +### Stake pools + +A stake pool produces blocks and may participate in governance as an SPO. A +user may follow: + +- the pool to which they delegated stake; +- a pool they operate; +- several pools they are comparing; +- pools whose production or governance activity they audit. + +The useful pool events are: + +- a block minted by the pool; +- pool registration, retirement, or parameter changes; +- an SPO governance vote cast by the pool; +- stake delegated to or away from the pool, when attributable data is + available; +- operational or performance alerts derived from reliable chain data. + +DReps and pools are independent Cardano actors. A DRep vote does not normally +contain a pool ID, and a block minted by a pool does not normally contain a +DRep ID. A user can care about both, but that does not imply that both must be +present in the same event. + +## Recommended Mental Model + +### Target groups use explicit connectors + +Multiple values within one target group are alternatives. The user chooses +AND or OR between adjacent groups: + +```text +(Wallet A OR Wallet B) +AND (DRep A OR DRep B) +OR (Pool X OR Pool Y) +AND (Asset T) +AND (Policy P) +``` + +AND uses normal Boolean precedence over OR. In this example the expression is +equivalent to `(Wallets AND DReps) OR (Pools AND Assets AND Policies)`. + +The application evaluates the expression exactly as configured, even when +different event families make a combination unlikely or currently impossible. +For example, Pool AND Asset currently matches neither a block event nor a +transaction event because block events do not carry transaction assets and +transaction events do not carry the pool issuer. The UI must not silently +change that AND to OR. + +Examples: + +- Connecting DRep A OR Pool X means "notify me about DRep A or Pool X." +- Connecting DRep A AND Pool X means both must match the same event. +- Following DRep A and DRep B means "notify me when either DRep acts." +- Following Pool X and Pool Y means "notify me when either pool acts." + +Existing configurations default missing connectors to OR for compatibility. +Selecting AND is an explicit instruction and may suppress all alerts when no +supported event can satisfy both groups. + +### Notification preferences narrow each target + +Event preferences should narrow the relevant target category: + +```text +(DRep A OR DRep B) AND (votes enabled OR registration changes enabled) +``` + +For example, a user following DRep A with only "Votes cast" enabled should get +DRep A's vote alerts, but not DRep registration alerts or votes from DRep B. + +Preferences that do not apply to a target should not create impossible AND +conditions. "Blocks minted" applies to pools, while "Votes cast" may apply to +both DReps and SPOs if the UI explicitly supports both kinds of vote. + +### Use AND only for an explicit compound condition + +AND is reasonable when the user deliberately constructs a relationship that +can occur in one event. Examples include: + +- transactions involving Wallet A AND Asset T; +- transactions involving Wallet A AND Policy P; +- incoming transfers to Wallet A above a specified amount. + +The UI exposes AND/OR directly between adjacent target groups. It does not +maintain a separate custom transaction-rule view. + +## Expected Notification Matrix + +| Followed target | Event | Notify by default when enabled? | Identity match | +| --------------- | ---------------------------------------------- | ------------------------------- | --------------------------------------------------- | +| DRep | DRep casts a vote | Yes | Vote voter ID/hash is followed | +| DRep | DRep registers, updates, or retires | Yes | Certificate DRep ID/hash is followed | +| DRep | New governance proposal | Optional | Global event; clearly label it as not DRep-specific | +| DRep | Stake is delegated to/from DRep | Optional | Delegation references followed DRep | +| Pool | Pool mints a block | Yes | Block issuer matches followed pool | +| Pool | Pool registers, retires, or changes parameters | Yes when supported | Certificate operator/pool ID is followed | +| Pool | Pool casts an SPO vote | Optional | SPO voter hash is followed pool | +| Pool | Stake is delegated to/from pool | Optional | Delegation references followed pool | +| Any | Unrelated actor performs the action | No | No followed identity matches | + +"Optional" means the user should be able to enable or disable that alert type; +it does not mean that identity matching may be skipped. + +## Common User Scenarios + +### Delegator following one DRep and one pool + +Configuration: + +- DRep A; +- Pool X; +- DRep votes enabled; +- blocks minted enabled. + +Expected behavior: + +- DRep A votes: notify; +- Pool X mints a block: notify; +- DRep B votes: do not notify; +- Pool Y mints a block: do not notify; +- an event involving only DRep A must not also require Pool X. + +### DRep operator + +The operator follows their own DRep and enables votes plus registration +changes. They should be notified when that DRep votes or its certificate +changes. They should not receive alerts for all governance activity merely +because the event type is governance. + +### Pool operator + +The operator follows one or more pool IDs and enables block production, +registration/parameter changes, and optionally SPO votes. Each pool is an +alternative target. A block from any followed pool should notify exactly once. + +### Researcher following several actors + +A researcher may follow many DReps and pools. All IDs within each list and all +independent actor lists should OR together. Notification preferences then +control which kinds of activity enter the alert stream. + +## Notification Quality + +An alert should answer: + +- what happened; +- which followed identity caused the match; +- the important action detail, such as Yes/No/Abstain or block number; +- the transaction, block, or governance action reference; +- whether the event is confirmed, pending, or rolled back when known. + +When one chain event matches several rules, the application should avoid +duplicating equivalent desktop notifications. It may combine distinct facts +into one notification or emit separate notifications only when each conveys a +different useful action. + +Rate limiting should coalesce bursts without changing filter semantics. A +summary such as "12 followed events occurred" is preferable to dropping alerts +silently. + +## Input and Validation Expectations + +- Accept DRep IDs in supported bech32 and hexadecimal forms. +- Accept pool IDs in bech32 and hexadecimal forms. +- Normalize equivalent forms before matching. +- Reject malformed IDs before saving the configuration. +- Prevent or clearly warn about IDs for the wrong target type. +- Respect the selected Cardano network where an identifier is network-aware. +- Show the saved target in a recognizable truncated form, with access to the + complete value. + +## Monitor Everything + +"Monitor everything" should be a separate, understandable mode. It should +ignore target lists and emit enabled event families for the whole network. The +UI should make the potentially high notification volume clear. + +Turning off "Monitor everything" should require at least one valid target. +Previously entered targets should not unexpectedly combine into hidden AND +conditions. + +## ChainSync Event and Notification Inventory + +This section distinguishes events emitted by the ChainSync input from desktop +notifications currently implemented by the tray. An event being present in +ChainSync does not automatically mean that the tray has a rule and useful +message for it. + +### Events emitted by ChainSync + +For each confirmed block, ChainSync emits events in this order: + +1. one `input.block` event; +2. one `input.transaction` event for every transaction in the block; +3. one `input.governance` event for each transaction containing governance + data; +4. one individual DRep certificate event for every DRep certificate in the + transaction. + +ChainSync also emits `input.rollback` when the chain rolls back. + +The individual DRep certificate event types are: + +- `chainsync.drep.registration`; +- `chainsync.drep.update`; +- `chainsync.drep.deregistration`. + +The same DRep certificate is also represented inside the transaction's +`input.governance` event. Consumers must avoid producing duplicate alerts from +both representations. + +### Transaction data available from ChainSync + +Every confirmed `input.transaction` event includes: + +- transaction hash, index, block hash, block number, and slot number; +- inputs and outputs; +- resolved input outputs when Kupo resolution succeeds; +- ADA and native assets in outputs; +- fee, TTL, withdrawals, metadata, reference inputs, and certificates when + present; +- optional transaction CBOR. + +This data can support these notification rules: + +| Transaction notification | Current tray support | Notes | +| ------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------- | +| Incoming ADA to a followed wallet | Yes | Matches a followed address in outputs | +| Outgoing ADA from a followed wallet | Yes, when inputs resolve | Requires followed address in resolved inputs | +| Native-token transfer involving a followed wallet | Yes | Requires wallet involvement and token output | +| Activity for a followed asset fingerprint | Yes | Matches assets in transaction outputs | +| Activity for a followed policy ID | Yes | Matches policies in transaction outputs | +| Any confirmed transaction | Yes, in Monitor Everything | Usually too noisy for desktop use | +| Specific transaction ID | Not currently exposed | Transaction hash is available in context | +| Minimum ADA/token amount | Not currently exposed | Amount data is available in outputs | +| Certificate-bearing transaction | Not currently exposed as a general alert | Certificates are present in the transaction event | +| Withdrawal involving a followed reward account | Not currently exposed | Withdrawal data is available | +| Metadata-based transaction | Not currently exposed | Metadata is available but requires a structured matcher | +| Transaction included by a followed pool | Not currently supported directly | Requires block issuer correlation described below | + +Outgoing matching depends on resolved inputs. If Kupo is unavailable or input +resolution fails, ChainSync still emits the transaction but the tray cannot +reliably determine that a followed wallet spent an input. + +### DRep and governance data available from ChainSync + +An `input.governance` event can contain: + +- governance proposals; +- votes by DReps, SPOs, or constitutional committee members; +- DRep registration, update, and deregistration certificates; +- vote delegation certificates; +- combined stake and vote delegation certificates; +- vote registration delegation certificates; +- combined stake, vote, and registration delegation certificates; +- constitutional committee hot-key authorization and resignation + certificates. + +The current DRep-related notification coverage is: + +| DRep/governance notification | Current tray support | Identity scope | +| -------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------ | +| Followed DRep casts a vote | Yes | Matches followed DRep voter ID/hash | +| Followed DRep registers | Yes | Reported as a registration change | +| Followed DRep updates its registration | Yes | Reported as a registration change | +| Followed DRep deregisters/retires | Yes | Reported as a registration change | +| New governance proposal | Yes, optional | Global; not caused by the followed DRep | +| Stake credential delegates voting power to followed DRep | Not currently notified | DRep identity is available in vote delegation data | +| Stake credential changes away from followed DRep | Not directly derivable from one event | Requires prior delegation state | +| Followed DRep inactivity or missed vote | Not currently derivable | Requires proposal deadlines and historical state | +| DRep voting-power change | Not currently notified | Requires delegation and stake-state aggregation | +| Individual `chainsync.drep.*` event | Not consumed directly by tray rules | Equivalent certificate is handled through `input.governance` | + +The governance event also contains SPO and committee activity, but current +DRep rules intentionally match only the followed DRep for votes and +certificates. + +### Pool data available from ChainSync + +The `input.block` event includes: + +- block issuer key hash, which identifies the minting pool; +- block hash, block number, slot, body size, and transaction count; +- optional block CBOR. + +Transactions and governance events may additionally carry pool-related +certificates or SPO voter hashes. ChainSync therefore exposes data for more +pool actions than the tray currently alerts on. + +| Pool notification | Current tray support | Available source | +| ----------------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------- | +| Followed pool mints a block | Yes | `input.block` issuer key hash | +| Chain rollback affecting observed blocks | Yes | `input.rollback`, gated by block notification preference | +| Followed pool casts an SPO governance vote | Not currently notified | `input.governance.votingProcedures` | +| Stake delegates to followed pool | Not currently notified | Transaction certificate; combined forms also appear in governance data | +| Pool registration | Not currently notified | Transaction certificate | +| Pool retirement | Not currently notified | Transaction certificate | +| Pool parameter update | Not currently notified | Pool registration/update certificate data needs a dedicated usable projection | +| Pool rewards | Not emitted as a dedicated ChainSync event | Requires reward-account/epoch-state data | +| Pool performance or missed blocks | Not directly emitted | Requires schedule and historical analysis | +| Transaction included in a followed pool's block | Not currently notified | Block and transaction share block hash, but transaction lacks issuer ID | + +The existing "Pool parameter changes" notification preference has no working +tray rule. It should remain hidden or marked unavailable until an appropriate +event projection and matcher exist. + +### Pool and transaction correlation + +ChainSync emits the block before its transactions. The block event contains +both the block hash and issuer key hash; each confirmed transaction contains +the same block hash. The information needed to correlate them therefore exists +across two events, but not in one transaction event. + +There are two reasonable implementation options: + +1. Add the block issuer/pool key hash to each confirmed transaction context or + payload when ChainSync constructs it. This makes compound matching + stateless and is the simpler model. +2. Cache `blockHash -> issuerVkey` in the notification engine and join each + transaction to the preceding block event. This avoids changing the event + schema but adds state, ordering, expiry, reconnect, and rollback concerns. + +Adding issuer identity to the confirmed transaction event is preferable. A +pool-inclusion rule can then require: + +```text +transaction.blockIssuer is one of the followed pools +AND transaction matches a wallet, transaction ID, asset, policy, or amount +``` + +Mempool transactions have no block hash or issuer and cannot match this rule. + +### Complete current desktop notification set + +For the requested DRep, pool, and transaction scope, the tray can currently +produce these desktop notifications: + +1. incoming transaction for a followed wallet; +2. outgoing transaction for a followed wallet when inputs are resolved; +3. token transfer involving a followed wallet; +4. followed asset activity; +5. followed policy activity; +6. followed DRep vote; +7. followed DRep registration, update, or deregistration as a registration + change; +8. new governance proposal for users who enabled that DRep-related global + alert; +9. block minted by a followed pool; +10. generic block, transaction, and governance notifications in Monitor + Everything mode; +11. rollback notification when block notifications are enabled. + +Connection notifications also exist, but they are synthesized by the tray and +are not ChainSync DRep, pool, or transaction events. + +## Current Adder Behavior and Gaps + +The tray application correctly owns its monitoring targets separately from the +sidecar Cardano filter configuration. This is important because the tray needs +notification-oriented semantics rather than a generic pipeline query. + +Current tray rule behavior includes: + +- one target-expression view shared by setup and notification rules; +- OR matching among multiple values inside each target group; +- persisted AND/OR connectors between Wallet, DRep, Pool, Asset, and Policy + groups in Standard monitoring, evaluated with AND before OR; +- followed DRep votes matched by DRep ID/hash; +- followed DRep registration changes matched by DRep ID/hash; +- new proposals treated as target-independent governance alerts; +- blocks matched to followed pools by block issuer; +- notification preferences used to enable relevant rule families; +- rollback, connection, and rate-limit handling outside target identity rules. + +Known gaps or inconsistencies: + +1. "Pool parameter changes" is presented as a notification preference, but the + notification rules intentionally do not create such an alert because Adder + does not currently emit a suitable pool-parameter event. +2. Pool notification rules currently cover block production, not SPO votes, + delegations, registrations, retirements, rewards, or parameter changes. +3. DRep notification rules currently cover votes and DRep certificate changes. + Delegations to or away from a DRep are not exposed as a distinct tray alert. +4. The generic CLI Cardano filter documentation describes different filter + categories as AND conditions. That can remain valid for explicit pipeline + queries, but it should not define the tray's default target-following model. + +Unsupported options should be hidden or marked unavailable rather than shown +as if they produce notifications. + +## Recommended Product Contract + +The tray should guarantee: + +1. An event is eligible when it satisfies the configured expression across + followed target groups. +2. Multiple values in one target category match when any value matches. +3. The relevant notification preference must be enabled. +4. Unrelated identities never match solely because they share an event type. +5. Compatible transaction constraints use AND only when the UI explicitly + communicates that relationship. +6. One event does not produce duplicate equivalent notifications. +7. Unsupported event preferences are not offered as functional controls. + +In short, connecting DRep A OR Pool X should mean: + +> Notify me when an enabled event happens for DRep A or Pool X. + +Connecting the same groups with AND instead requires both to match one event, +even when no currently supported event can do so. diff --git a/event/block.go b/event/block.go index 461305ed..4429f7bd 100644 --- a/event/block.go +++ b/event/block.go @@ -44,11 +44,15 @@ func NewBlockContext(block ledger.Block, networkMagic uint32) BlockContext { return ctx } -func NewBlockHeaderContext(block ledger.BlockHeader) BlockContext { +func NewBlockHeaderContext( + block ledger.BlockHeader, + networkMagic uint32, +) BlockContext { ctx := BlockContext{ - BlockNumber: block.BlockNumber(), - SlotNumber: block.SlotNumber(), - Era: block.Era().Name, + BlockNumber: block.BlockNumber(), + SlotNumber: block.SlotNumber(), + NetworkMagic: networkMagic, + Era: block.Era().Name, } return ctx } diff --git a/filter/cardano/cardano.go b/filter/cardano/cardano.go index 6a72f902..d944b0bd 100644 --- a/filter/cardano/cardano.go +++ b/filter/cardano/cardano.go @@ -146,15 +146,17 @@ func (c *Cardano) filterTransactionEvent(te event.TransactionEvent) bool { } } - // Check pool filter - if c.filterSet.hasPoolFilter { + // Pool and DRep IDs identify independent actors. When both are configured, + // pass transactions involving either followed identity. + if c.filterSet.hasPoolFilter && c.filterSet.hasDRepFilter { + if !c.matchPoolFilterTx(te) && !c.matchDRepFilterTx(te) { + return false + } + } else if c.filterSet.hasPoolFilter { if !c.matchPoolFilterTx(te) { return false } - } - - // Check DRep filter - if c.filterSet.hasDRepFilter { + } else if c.filterSet.hasDRepFilter { if !c.matchDRepFilterTx(te) { return false } @@ -348,18 +350,21 @@ func (c *Cardano) filterGovernanceEvent(ge event.GovernanceEvent) bool { } } - // Check DRep filter - if c.filterSet.hasDRepFilter { - if !c.matchDRepFilterGovernance(ge) { + // Pool and DRep IDs identify independent actors. When both are configured, + // pass governance events involving either followed identity. + if c.filterSet.hasPoolFilter && c.filterSet.hasDRepFilter { + if !c.matchPoolFilterGovernance(ge) && + !c.matchDRepFilterGovernance(ge) { return false } - } - - // Check pool filter - if c.filterSet.hasPoolFilter { + } else if c.filterSet.hasPoolFilter { if !c.matchPoolFilterGovernance(ge) { return false } + } else if c.filterSet.hasDRepFilter { + if !c.matchDRepFilterGovernance(ge) { + return false + } } return true diff --git a/filter/cardano/cardano_test.go b/filter/cardano/cardano_test.go index fb4f0bdd..87663e95 100644 --- a/filter/cardano/cardano_test.go +++ b/filter/cardano/cardano_test.go @@ -531,6 +531,28 @@ func TestFilterByDRepIdTransactionEvent(t *testing.T) { }) } +func TestFilterByPoolOrDRepIdTransactionEvent(t *testing.T) { + poolHex := "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd" + drepHex := "abcd1234567890abcdef1234567890abcdef1234567890abcdef1234" + drepHashBytes, _ := hex.DecodeString(drepHex) + var drepCredHash common.CredentialHash + copy(drepCredHash[:], drepHashBytes) + + cs := New(WithPoolIds([]string{poolHex}), WithDRepIds([]string{drepHex})) + te := event.TransactionEvent{ + Certificates: []ledger.Certificate{ + &common.RegistrationDrepCertificate{ + DrepCredential: common.Credential{ + CredType: 0, + Credential: drepCredHash, + }, + }, + }, + } + + assert.True(t, cs.filterTransactionEvent(te)) +} + func TestFilterByDRepIdGovernanceEvent(t *testing.T) { // 28 bytes = 56 hex chars for Blake2b224 drepHex := "abcd1234567890abcdef1234567890abcdef1234567890abcdef1234" @@ -990,6 +1012,53 @@ func TestFilterByPoolIdGovernanceEvent(t *testing.T) { }) } +func TestFilterByPoolOrDRepIdGovernanceEvent(t *testing.T) { + poolHex := "abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd" + drepHex := "abcd1234567890abcdef1234567890abcdef1234567890abcdef1234" + otherHex := "99999999999999999999999999999999999999999999999999999999" + cs := New(WithPoolIds([]string{poolHex}), WithDRepIds([]string{drepHex})) + + tests := []struct { + name string + governance event.GovernanceEvent + shouldMatch bool + }{ + { + name: "matches followed DRep vote without pool activity", + governance: event.GovernanceEvent{ + VotingProcedures: []event.VotingProcedureData{ + {VoterType: "DRep", VoterHash: drepHex, Vote: "Yes"}, + }, + }, + shouldMatch: true, + }, + { + name: "matches followed pool vote without DRep activity", + governance: event.GovernanceEvent{ + VotingProcedures: []event.VotingProcedureData{ + {VoterType: "SPO", VoterHash: poolHex, Vote: "Yes"}, + }, + }, + shouldMatch: true, + }, + { + name: "rejects unrelated governance activity", + governance: event.GovernanceEvent{ + VotingProcedures: []event.VotingProcedureData{ + {VoterType: "DRep", VoterHash: otherHex, Vote: "Yes"}, + }, + }, + shouldMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.shouldMatch, cs.filterGovernanceEvent(tt.governance)) + }) + } +} + func TestFilterByAddressGovernanceEvent(t *testing.T) { // Use a stake/reward address format for proposal reward accounts stakeAddr := "stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw" diff --git a/input/chainsync/chainsync.go b/input/chainsync/chainsync.go index 4c80225b..086959fa 100644 --- a/input/chainsync/chainsync.go +++ b/input/chainsync/chainsync.go @@ -481,7 +481,7 @@ func (c *ChainSync) handleRollForward( blockEvt := event.New( "input.block", time.Now(), - event.NewBlockHeaderContext(block.Header()), + event.NewBlockHeaderContext(block.Header(), c.networkMagic), event.NewBlockEvent(block, c.includeCbor), ) tmpEvents = append(tmpEvents, blockEvt) diff --git a/input/utxorpc/mapper.go b/input/utxorpc/mapper.go index 898bb57b..732594ca 100644 --- a/input/utxorpc/mapper.go +++ b/input/utxorpc/mapper.go @@ -102,7 +102,7 @@ func followTipApplyCBOR(nativeBytes []byte, includeCbor bool, networkMagic uint3 out = append(out, event.New( "input.block", time.Now(), - event.NewBlockHeaderContext(block.Header()), + event.NewBlockHeaderContext(block.Header(), networkMagic), event.NewBlockEvent(block, includeCbor), )) for t, transaction := range txns { diff --git a/input/utxorpc/mapper_test.go b/input/utxorpc/mapper_test.go index 4327870f..c4c0d60e 100644 --- a/input/utxorpc/mapper_test.go +++ b/input/utxorpc/mapper_test.go @@ -68,6 +68,7 @@ func TestFollowTipApplyCBORRealProviderBlock(t *testing.T) { blockCtx := evts[0].Context.(event.BlockContext) assert.Equal(t, uint64(13380271), blockCtx.BlockNumber) assert.Equal(t, uint64(186435630), blockCtx.SlotNumber) + assert.Equal(t, uint32(764824073), blockCtx.NetworkMagic) blockEvt := evts[0].Payload.(event.BlockEvent) assert.Equal(t, uint64(25), blockEvt.TransactionCount) @@ -209,6 +210,9 @@ func TestFollowTipApplyProtobufFansOut(t *testing.T) { blockEvt := evts[0].Payload.(event.BlockEvent) assert.Equal(t, uint64(1), blockEvt.TransactionCount) + blockCtx := evts[0].Context.(event.BlockContext) + assert.Equal(t, uint32(764824073), blockCtx.NetworkMagic) + txCtx := evts[1].Context.(event.TransactionContext) assert.Equal(t, hex.EncodeToString(txHash), txCtx.TransactionHash) assert.Equal(t, uint64(50), txCtx.BlockNumber) diff --git a/input/utxorpc/protobuf.go b/input/utxorpc/protobuf.go index 52f8b7d8..8bd32457 100644 --- a/input/utxorpc/protobuf.go +++ b/input/utxorpc/protobuf.go @@ -438,12 +438,18 @@ func pbPoolCertificatesToLedger(certs []*cardanopb.Certificate) []lcommon.Certif } switch c := cert.GetCertificate().(type) { case *cardanopb.Certificate_StakeDelegation: + if c == nil || c.StakeDelegation == nil { + continue + } d := c.StakeDelegation poolHash := lcommon.NewBlake2b224(d.GetPoolKeyhash()) out = append(out, &lcommon.StakeDelegationCertificate{ PoolKeyHash: poolHash, }) case *cardanopb.Certificate_PoolRetirement: + if c == nil || c.PoolRetirement == nil { + continue + } d := c.PoolRetirement poolHash := lcommon.NewBlake2b224(d.GetPoolKeyhash()) out = append(out, &lcommon.PoolRetirementCertificate{ @@ -451,6 +457,9 @@ func pbPoolCertificatesToLedger(certs []*cardanopb.Certificate) []lcommon.Certif Epoch: d.GetEpoch(), }) case *cardanopb.Certificate_PoolRegistration: + if c == nil || c.PoolRegistration == nil { + continue + } d := c.PoolRegistration operatorHash := lcommon.NewBlake2b224(d.GetOperator()) out = append(out, &lcommon.PoolRegistrationCertificate{ diff --git a/internal/explorer/explorer.go b/internal/explorer/explorer.go new file mode 100644 index 00000000..457d9d80 --- /dev/null +++ b/internal/explorer/explorer.go @@ -0,0 +1,38 @@ +// Copyright 2026 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package explorer maps a Cardano network magic to the block-explorer base URL +// used when building links in notifications (telegram, webhook, and the tray). +// Keeping this in one place means an explorer or domain change is a single edit. +package explorer + +// Cardano network magics. These are stable protocol constants; mainnet +// (764824073) and any unrecognized magic fall through to the mainnet explorer. +const ( + preprodMagic uint32 = 1 + previewMagic uint32 = 2 +) + +// BaseURL returns the cexplorer.io base URL for the given network magic: +// mainnet at the apex, each testnet on its own subdomain. +func BaseURL(networkMagic uint32) string { + switch networkMagic { + case preprodMagic: + return "https://preprod.cexplorer.io" + case previewMagic: + return "https://preview.cexplorer.io" + default: + return "https://cexplorer.io" + } +} diff --git a/internal/explorer/explorer_test.go b/internal/explorer/explorer_test.go new file mode 100644 index 00000000..5fd5d3d8 --- /dev/null +++ b/internal/explorer/explorer_test.go @@ -0,0 +1,32 @@ +// Copyright 2026 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package explorer + +import "testing" + +func TestBaseURL(t *testing.T) { + cases := map[uint32]string{ + 764824073: "https://cexplorer.io", // mainnet + 1: "https://preprod.cexplorer.io", // preprod + 2: "https://preview.cexplorer.io", // preview + 0: "https://cexplorer.io", // unset → mainnet + 999: "https://cexplorer.io", // unknown → mainnet + } + for magic, want := range cases { + if got := BaseURL(magic); got != want { + t.Errorf("BaseURL(%d) = %q, want %q", magic, got, want) + } + } +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go index f8e47d3b..77d962e3 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -15,6 +15,7 @@ package logging import ( + "io" "log/slog" "os" "time" @@ -31,7 +32,16 @@ func defaultLogger() *slog.Logger { var globalLogger = defaultLogger() +// Configure initializes the global logger writing to os.Stderr. func Configure() { + ConfigureWithWriter(os.Stderr) +} + +// ConfigureWithWriter initializes the global logger writing to w. The GUI tray +// uses this to log to a file: when linked with -H=windowsgui there is no +// console, so os.Stderr is discarded and logs (and panics) would otherwise be +// lost. +func ConfigureWithWriter(w io.Writer) { cfg := config.GetConfig() var level slog.Level switch cfg.Logging.Level { @@ -47,7 +57,7 @@ func Configure() { level = slog.LevelInfo } - handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + handler := slog.NewJSONHandler(w, &slog.HandlerOptions{ ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { if a.Key == slog.TimeKey { // Format the time attribute to use RFC3339 or your custom format diff --git a/output/telegram/telegram.go b/output/telegram/telegram.go index 9a1ab55f..f64168b1 100644 --- a/output/telegram/telegram.go +++ b/output/telegram/telegram.go @@ -24,6 +24,7 @@ import ( "github.com/blinklabs-io/adder/event" "github.com/blinklabs-io/adder/internal/cardanofmt" + "github.com/blinklabs-io/adder/internal/explorer" "github.com/blinklabs-io/adder/internal/logging" "github.com/blinklabs-io/adder/plugin" "github.com/go-telegram/bot" @@ -31,10 +32,6 @@ import ( ) const ( - mainnetNetworkMagic uint32 = 764824073 - previewNetworkMagic uint32 = 2 - preprodNetworkMagic uint32 = 1 - // Default retry configuration defaultMaxRetries = 3 defaultInitialBackoff = 1 * time.Second @@ -523,16 +520,7 @@ func formatLovelace(lovelace uint64) string { // getBaseURL returns the block explorer URL based on network magic func getBaseURL(networkMagic uint32) string { - switch networkMagic { - case mainnetNetworkMagic: - return "https://cexplorer.io" - case preprodNetworkMagic: - return "https://preprod.cexplorer.io" - case previewNetworkMagic: - return "https://preview.cexplorer.io" - default: - return "https://cexplorer.io" - } + return explorer.BaseURL(networkMagic) } // SendMessage sends a message to the configured Telegram chat diff --git a/output/telegram/telegram_test.go b/output/telegram/telegram_test.go index 7707620a..5153e47f 100644 --- a/output/telegram/telegram_test.go +++ b/output/telegram/telegram_test.go @@ -22,6 +22,14 @@ import ( "github.com/stretchr/testify/assert" ) +// Cardano network magics used by the tests as BlockContext fixtures and to +// exercise getBaseURL's mapping. +const ( + mainnetNetworkMagic uint32 = 764824073 + previewNetworkMagic uint32 = 2 + preprodNetworkMagic uint32 = 1 +) + func TestNew(t *testing.T) { tests := []struct { name string diff --git a/output/webhook/webhook.go b/output/webhook/webhook.go index d4620ee2..3b2aaae6 100644 --- a/output/webhook/webhook.go +++ b/output/webhook/webhook.go @@ -28,16 +28,13 @@ import ( "time" "github.com/blinklabs-io/adder/event" + "github.com/blinklabs-io/adder/internal/explorer" "github.com/blinklabs-io/adder/internal/logging" "github.com/blinklabs-io/adder/internal/version" "github.com/blinklabs-io/adder/plugin" ) const ( - mainnetNetworkMagic uint32 = 764824073 - previewNetworkMagic uint32 = 2 - preprodNetworkMagic uint32 = 1 - // Default retry configuration defaultMaxRetries = 3 defaultInitialBackoff = 1 * time.Second @@ -277,16 +274,7 @@ type DiscordMessageEmbedField struct { } func getBaseURL(networkMagic uint32) string { - switch networkMagic { - case mainnetNetworkMagic: - return "https://cexplorer.io" - case preprodNetworkMagic: - return "https://preprod.cexplorer.io" - case previewNetworkMagic: - return "https://preview.cexplorer.io" - default: - return "https://cexplorer.io" // default to mainnet if unknown network - } + return explorer.BaseURL(networkMagic) } func (w *WebhookOutput) SendWebhook(e *event.Event) error { diff --git a/packaging/windows/README.md b/packaging/windows/README.md new file mode 100644 index 00000000..cc9c00a4 --- /dev/null +++ b/packaging/windows/README.md @@ -0,0 +1,239 @@ +# Windows `.msi` installer + +This directory builds a signed Windows installer (`.msi`) that installs **both** +the `adder` CLI (`adder.exe`) and the `adder-tray` GUI (`adder-tray.exe`) under +`%ProgramFiles%\Adder\`, with a Start Menu shortcut for the tray. + +``` +packaging/windows/ +├── adder.wxs # WiX v4 source (product, both binaries, shortcut, ARP) +├── build-msi.ps1 # main build script (build → wix build → jsign) +└── README.md # this file +``` + +## Installed layout + +``` +%ProgramFiles%\Adder\adder.exe # CLI binary +%ProgramFiles%\Adder\adder-tray.exe # tray GUI binary +%ProgramFiles%\Adder\opengl32.dll # Mesa software OpenGL (loader) [amd64] +%ProgramFiles%\Adder\libgallium_wgl.dll # Mesa llvmpipe megadriver [amd64] +%ProgramFiles%\Adder\OpenGL-Mesa-LICENSE.txt # Mesa MIT license [amd64] +Start Menu\Programs\Adder # shortcut → adder-tray.exe +``` + +Double-clicking the Start Menu shortcut launches the tray/wizard GUI. The CLI +ships alongside it in the same `Adder` directory. An Add/Remove Programs (ARP) +entry is created with publisher **Blink Labs Software**, contact, and the +support / about URL pointing at . + +## Software OpenGL (Mesa / llvmpipe) + +The tray GUI is built with Fyne, which needs OpenGL 2.1+. VM / headless / RDP +hosts (e.g. **VirtualBox** without 3D acceleration) frequently have no hardware +OpenGL driver, so the tray would render a black window and exit. To make it work +everywhere, the installer bundles **Mesa3D's `llvmpipe` software renderer** next +to `adder-tray.exe`: + +- `opengl32.dll` is only a loader; `libgallium_wgl.dll` is the megadriver that + actually contains `llvmpipe` (split out of `opengl32.dll` since Mesa 21.3.0), + so **both** are required. Windows resolves `opengl32.dll` from the application + directory before System32, so the tray uses Mesa instead of any host driver. +- The tray pins `GALLIUM_DRIVER=llvmpipe` at startup (in `cmd/adder-tray`) so + Mesa does not attempt the D3D12 (`dozen`) path, which needs `dxil.dll` we do + not ship. Set `GALLIUM_DRIVER` yourself to override. +- The DLLs come from [`pal1000/mesa-dist-win`](https://github.com/pal1000/mesa-dist-win), + pinned via `MESA_VERSION`. We use the **`release-mingw`** build, which + statically links its C runtime, so **only these two DLLs are shipped** — no + extra runtime DLLs are needed. Verified: their only imports are Windows + in-box system libraries (`api-ms-win-crt-*` UCRT, `KERNEL32`, `GDI32`, + `ADVAPI32`), present on every Windows 10/11. `release-mingw` requires SSSE3, + which every x64 CPU since ~2006 has. Mesa is **MIT-licensed**; the license + text ships as `OpenGL-Mesa-LICENSE.txt`. +- If OpenGL still fails to initialize, the tray's log file + (`%LOCALAPPDATA%\Adder\Logs\adder-tray.log`) records the error. +- **amd64 only** — there is no upstream arm64 Mesa build, so arm64 MSIs do not + bundle it (the arm64 GUI needs a host OpenGL driver). Bundling is skipped with + a warning rather than failing the build. + +## What the installer does NOT do + +- It does **not** register any autostart. Autostart is owned by the + `adder-tray` first-run wizard (`tray/setup/service_windows.go`), which writes + a per-user `HKCU\...\Run` value that launches the **tray** at logon (GUI + subsystem → silent, no elevation). The tray in turn launches the **engine** + (`adder.exe`) as a detached, windowless child and manages its lifecycle. This + mirrors the macOS installer's "no LaunchAgent" stance. + +## WiX version + +This uses **WiX v4** (`wix build`, single command), not the older v3 +`candle`/`light` pair. v4 is installed as a .NET tool and pinned in the build +script (`WIX_VERSION`, default `4.0.5`): + +```powershell +dotnet tool install --global wix --version 4.0.5 +wix extension add -g WixToolset.Util.wixext # not required by this .wxs +``` + +v4 collapses ``/`` into a single ``, uses the +`http://wixtoolset.org/schemas/v4/wxs` namespace, and provides +`` for well-known folders — all reflected in `adder.wxs`. + +## Building + +```powershell +# Local, unsigned (dev): signing warns and skips. +pwsh ./packaging/windows/build-msi.ps1 + +# Signed (CI / release): set VERSION and the jsign env vars below. +$env:VERSION = "0.39.1" +$env:JSIGN_KEYSTORE = "https://adder-kv.vault.azure.net" +$env:JSIGN_STORETYPE= "AZUREKEYVAULT" +$env:JSIGN_STOREPASS= "" +$env:JSIGN_ALIAS = "adder-ev-cert" +pwsh ./packaging/windows/build-msi.ps1 +``` + +The artifact is written to `dist\adder--windows-.msi` +(e.g. `dist\adder-0.39.1-windows-amd64.msi`). + +## Environment variables + +| Variable | Required for | Default | Purpose | +| ----------------- | ------------ | ----------------------------------------- | ------- | +| `VERSION` | optional | `git describe --tags --always --dirty` (leading `v` stripped) | Installer version. The MSI `ProductVersion` must be ≤3 dot-separated integers, so the script sanitizes to the first `a.b.c`; for releases set a clean semver. | +| `ARCH` | optional | `amd64` | `amd64` or `arm64`. Maps to `GOARCH` and the msi name. | +| `WIX_VERSION` | optional | `4.0.5` | WiX toolset version to install/use. | +| `ICON_SRC` | optional | `.github\assets\adder.ico` | App icon embedded for ARP and the shortcut. | +| `DIST_DIR` | optional | `\dist` | Where the final `.msi` is written. | +| `BUILD_DIR` | optional | `\build\windows` | Scratch build/staging directory. | +| `ADDER_EXE` | optional | _(unset → script runs `go build`)_ | Path to a prebuilt `adder.exe` (typically already code-signed). When both this and `ADDER_TRAY_EXE` resolve to existing files, the script's internal `go build` step is skipped and these files are packaged directly. CI uses this to feed in individually-signed binaries — the MSI signature only covers the OLE compound, not the embedded PEs. | +| `ADDER_TRAY_EXE` | optional | _(unset → script runs `go build`)_ | Path to a prebuilt `adder-tray.exe`. See `ADDER_EXE`. | +| `BUNDLE_MESA` | optional | `1` | Set to `0` to skip bundling Mesa software OpenGL (the GUI then needs a host OpenGL driver). Always skipped for `arm64` (no upstream build). | +| `MESA_VERSION` | optional | `26.1.3` | Pinned [`pal1000/mesa-dist-win`](https://github.com/pal1000/mesa-dist-win) release tag to download (`release-mingw`). | +| `MESA_OPENGL_DIR` | optional | _(unset → download)_ | Use Mesa DLLs from an already-extracted directory instead of downloading (offline / air-gapped builds). | +| `JSIGN_KEYSTORE` | signing | _(unset → skip)_ | Keystore reference: cloud HSM/KMS name (e.g. Key Vault URL), PKCS#11 config, or `.p12` path holding the EV certificate. | +| `JSIGN_STOREPASS` | signing | _(unset → skip)_ | Keystore / token password or cloud credential. | +| `JSIGN_STORETYPE` | signing | _(unset)_ | jsign storetype: `AZUREKEYVAULT`, `GOOGLECLOUD`, `AWS`, `DIGICERTONE`, `PKCS11`, `PKCS12`, … | +| `JSIGN_ALIAS` | signing | _(unset)_ | Certificate / key alias within the keystore. | +| `JSIGN_TSAURL` | optional | `http://timestamp.digicert.com` | RFC 3161 timestamp authority URL (CI timestamps the signature). | +| `JSIGN_CERTFILE` | optional | _(unset → chain inferred from keystore)_ | Path to a PEM file with the full certificate chain (intermediates + root). Needed when the keystore holds only the leaf cert (typical for cloud HSM/KMS-backed EV keys). | +| `JSIGN_JAR` | optional | _(unset → use `jsign` on PATH)_ | Path to `jsign.jar` (invoked via `java -jar`) if no `jsign` launcher is on PATH. | + +### Skip-when-unset behavior + +The script uses `Set-StrictMode` + `$ErrorActionPreference = 'Stop'` and treats +all signing vars as optional: + +- **`JSIGN_KEYSTORE` or `JSIGN_STOREPASS` unset** → the `.msi` is left unsigned + and `jsign` is skipped (warns). A plain local run always yields a working + **unsigned** msi; CI with the secrets set yields a **signed + timestamped** msi. + +## Binary build commands + +Mirrors the repo `Makefile`: + +```powershell +# adder CLI: pure Go. +$env:CGO_ENABLED="0"; $env:GOOS="windows"; $env:GOARCH="amd64" +go build -ldflags "-s -w -X '/internal/version.Version=...' -X '/internal/version.CommitHash=...'" -tags nodbus -o adder.exe ./cmd/adder + +# adder-tray GUI: requires cgo (Fyne → go-gl/OpenGL). +$env:CGO_ENABLED="1"; $env:GOOS="windows"; $env:GOARCH="amd64" +go build -ldflags "..." -o adder-tray.exe ./cmd/adder-tray +``` + +> **Toolchain requirement (important):** `adder-tray.exe` **cannot be +> cross-compiled** from macOS/Linux. Fyne pulls in `go-gl/gl`, whose files are +> excluded unless cgo is enabled, so a C compiler (mingw-w64 `gcc` for `amd64`) +> must be on `PATH`. Build on a **native Windows runner**. The `adder` CLI is +> pure Go and *can* be cross-built, but the script builds both on the Windows +> runner for consistency (the direct analog of the macOS installer building on a +> native macOS runner for the target arch). + +`arm64` is supported via `ARCH=arm64`; it needs an `aarch64` Windows toolchain +for the tray build. + +## Signing with jsign + +The MSI is signed with the existing EV certificate using +[jsign](https://github.com/ebourg/jsign). MSI is an OLE compound file, which +jsign signs natively (no need for `signtool` on the build host). The exact +invocation the script runs: + +```bash +jsign \ + --keystore "$JSIGN_KEYSTORE" \ + --storepass "$JSIGN_STOREPASS" \ + --storetype "$JSIGN_STORETYPE" \ + --alias "$JSIGN_ALIAS" \ + --tsaurl "$JSIGN_TSAURL" \ + --name "Adder" \ + --url "https://github.com/blinklabs-io/adder" \ + adder--windows-amd64.msi +``` + +The `--tsaurl` timestamps the signature so it remains valid after the +certificate expires (CI performs the timestamping at sign time). + +Because EV code-signing keys live on an HSM, `JSIGN_STORETYPE` selects the +backend (e.g. `AZUREKEYVAULT`, `GOOGLECLOUD`, `AWS`, `DIGICERTONE`, or `PKCS11` +for a physical/cloud token). For a software `.p12` (non-EV / testing), use +`JSIGN_STORETYPE=PKCS12` with `JSIGN_KEYSTORE` pointing at the `.p12` file. + +## Verifying the release artifact + +On a Windows host with the Windows SDK (`signtool`): + +```cmd +signtool verify /pa adder--windows-amd64.msi +``` + +`/pa` uses the default authentication-code policy and should report a valid +signature chain plus a countersignature (timestamp). + +## Validation: local vs. CI + +`adder.wxs` is validated for **XML well-formedness** locally +(`xmllint --noout packaging/windows/adder.wxs`). Its **WiX/ICE semantics** +(64-bit folder resolution, component keypaths, per-machine/per-user +consistency, upgrade plumbing) are only checked by `wix build`, which runs ICE +validation by default — that happens on the Windows CI runner, not on macOS. +The `.wxs` is written to be ICE-clean (single component per shortcut, `HKMU` +keypath under a `perMachine` scope, `Bitness="always64"`), but the authoritative +lint is the CI `wix build` step. + +## CI wiring + +Implemented in [`.github/workflows/publish.yml`](../../.github/workflows/publish.yml) +on the `build-binaries` job, for the `windows-latest`/amd64 and +`windows-11-arm`/arm64 matrix rows, gated on tag pushes (`refs/tags/v*.*.*`). + +The flow per Windows row: + +1. `make build` + `make build-tray` produce `adder.exe` and `adder-tray.exe` + at the workspace root (CGO toolchain via MSYS2 `MINGW64` / `CLANGARM64`). +2. **Sign Windows binaries** — downloads `jsign-6.0.jar` (SHA256-pinned), + decodes `CERTIFICATE_CHAIN` to `codesign-chain.pem`, then loops jsign over + both `.exe` files (RFC3161 timestamp via GlobalSign), using a masked + `gcloud auth print-access-token` as `--storepass`. +3. **Install WiX toolset** — `dotnet tool install --global wix --version 4.0.5`. +4. **Build and sign MSI** — invokes `pwsh ./packaging/windows/build-msi.ps1` + with `ADDER_EXE` / `ADDER_TRAY_EXE` pointing at the just-signed binaries + (so the script skips its internal `go build`), and the `JSIGN_*` env vars + mapped from the existing `CERTIFICATE_*` secrets and a fresh access token. + Cleans up `jsign.jar` + `codesign-chain.pem` after. +5. **Verify MSI signature** — locates `signtool.exe` under the preinstalled + Windows SDK and runs `signtool verify /pa /v `; fails the job on a + bad chain or missing countersignature. +6. **Upload MSI release asset** — uploads + `dist\adder--windows-.msi` to the draft release, naming the + asset `adder--windows-.msi` (the existing convention). +7. **Attest MSI** — `actions/attest@v4` produces a build provenance + attestation for the `.msi`. + +Reuses the existing repository secrets (no new ones required): +`CERTIFICATE_KEYSTORE`, `CERTIFICATE_STORE_TYPE`, `CERTIFICATE_KEY_NAME`, +`CERTIFICATE_CHAIN`, `CERTIFICATE_SA_CREDENTIALS`. These map onto the +`JSIGN_*` env vars consumed by `build-msi.ps1`. diff --git a/packaging/windows/adder.wxs b/packaging/windows/adder.wxs new file mode 100644 index 00000000..44e148d1 --- /dev/null +++ b/packaging/windows/adder.wxs @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/build-msi.ps1 b/packaging/windows/build-msi.ps1 new file mode 100644 index 00000000..c0d3939d --- /dev/null +++ b/packaging/windows/build-msi.ps1 @@ -0,0 +1,437 @@ +# Copyright 2026 Blink Labs Software +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# build-msi.ps1 - build a (optionally signed) Windows .msi installer containing +# both the `adder` CLI (adder.exe) and the `adder-tray` GUI (adder-tray.exe), +# installed under %ProgramFiles%\Adder\, with a Start Menu shortcut for the tray. +# +# The script is fully parameterized via environment variables. Signing is +# SKIPPED with a clear warning when the jsign credentials are not provided, so +# it produces an UNSIGNED msi locally and a signed msi in CI. See +# packaging/windows/README.md for the full env-var contract and verification. +# +# Run on a NATIVE Windows runner: adder-tray uses Fyne (CGO -> go-gl/OpenGL) +# and cannot be cross-compiled. A C toolchain (e.g. mingw-w64 gcc) must be on +# PATH. The CLI is pure Go and could be cross-built, but we build both here for +# consistency (this mirrors the macOS installer building on a native runner). + +#Requires -Version 5.1 +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +function Write-Log { param([string]$m) Write-Host "==> $m" -ForegroundColor Blue } +function Write-Warn { param([string]$m) Write-Warning $m } +function Die { param([string]$m) Write-Error $m; exit 1 } + +# --------------------------------------------------------------------------- +# Configuration (all overridable via environment) +# --------------------------------------------------------------------------- + +# Resolve repository paths relative to this script so it works from any CWD. +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = (Resolve-Path (Join-Path $ScriptDir '..\..')).Path + +# Go module path, used for the version ldflags (mirrors the Makefile). +$GoModule = ((Select-String -Path (Join-Path $RepoRoot 'go.mod') -Pattern '^module').Line -split '\s+')[1] + +# Version string. Defaults to the git tag/description; strips a leading "v". +# NOTE: MSI ProductVersion must be <=3 dot-separated integers (a.b.c). The raw +# `git describe` form (e.g. 0.39.1-36-g1234567) is NOT a valid MSI version, so +# for releases set VERSION to a clean semver. We sanitize for local dev builds. +$Version = $env:VERSION +if ([string]::IsNullOrEmpty($Version)) { + $Version = (& git -C $RepoRoot describe --tags --always --dirty 2>$null) + if ([string]::IsNullOrEmpty($Version)) { $Version = '0.0.0' } +} +$Version = $Version -replace '^v', '' + +# Sanitize to the first 3 numeric dotted fields for the MSI ProductVersion. +$MsiVersion = '0.0.0' +$m = [regex]::Match($Version, '^(\d+(?:\.\d+){0,2})') +if ($m.Success) { $MsiVersion = $m.Value } +if ($MsiVersion -ne $Version) { + Write-Warn "VERSION '$Version' is not a valid MSI version; using '$MsiVersion' for the MSI ProductVersion. Set VERSION to a clean semver for releases." +} + +# Commit hash for the version ldflags. +$CommitHash = (& git -C $RepoRoot rev-parse --short HEAD 2>$null) +if ([string]::IsNullOrEmpty($CommitHash)) { $CommitHash = 'unknown' } + +# Target architecture: amd64 (default) or arm64. +$Arch = $env:ARCH +if ([string]::IsNullOrEmpty($Arch)) { $Arch = 'amd64' } +# $MsiArch feeds the output filename (release convention: amd64/arm64). +# $WixArch is the WiX `-arch` platform token, which uses x64/arm64 (NOT amd64). +switch ($Arch) { + 'amd64' { $GoArch = 'amd64'; $MsiArch = 'amd64'; $WixArch = 'x64' } + 'x86_64'{ $GoArch = 'amd64'; $MsiArch = 'amd64'; $WixArch = 'x64' } + 'arm64' { $GoArch = 'arm64'; $MsiArch = 'arm64'; $WixArch = 'arm64' } + default { Die "unsupported ARCH '$Arch' (use amd64 or arm64)" } +} + +# Pinned WiX toolset version (installed as a dotnet global/local tool in CI). +$WixVersion = if ($env:WIX_VERSION) { $env:WIX_VERSION } else { '4.0.5' } + +# Output / work locations. +$DistDir = if ($env:DIST_DIR) { $env:DIST_DIR } else { Join-Path $RepoRoot 'dist' } +$BuildDir = if ($env:BUILD_DIR) { $env:BUILD_DIR } else { Join-Path $RepoRoot 'build\windows' } +$BinDir = Join-Path $BuildDir 'bin' +$MsiName = "adder-$Version-windows-$MsiArch.msi" +$FinalMsi = Join-Path $DistDir $MsiName + +# Pre-built binary inputs. When BOTH ADDER_EXE and ADDER_TRAY_EXE point at +# existing files, the Build-Binaries step is skipped and these files are +# packaged directly. CI uses this to feed in binaries that have already been +# individually code-signed by an earlier step (signing inside the MSI requires +# that the embedded PEs were signed BEFORE the MSI was built; jsign of the MSI +# itself does not sign the contents). +$AdderExeIn = $env:ADDER_EXE +$AdderTrayExeIn = $env:ADDER_TRAY_EXE + +# Icon source (already present in the repo). +$IconSrc = if ($env:ICON_SRC) { $env:ICON_SRC } else { Join-Path $RepoRoot '.github\assets\adder.ico' } + +# Mesa3D software OpenGL bundling. The Fyne tray needs OpenGL 2.1+; VM / +# headless / RDP hosts (e.g. VirtualBox) often have no hardware OpenGL driver, +# so we bundle Mesa's llvmpipe software renderer (opengl32.dll loader + +# libgallium_wgl.dll megadriver) next to adder-tray.exe. +# BUNDLE_MESA=0 disable bundling (GUI then needs a host OpenGL driver) +# MESA_VERSION pinned mesa-dist-win release tag (default below) +# MESA_SHA256 expected SHA-256 of the downloaded archive (default matches +# MESA_VERSION below). Set this when overriding MESA_VERSION. +# MESA_OPENGL_DIR use DLLs from an already-extracted dir instead of download +# amd64 only: upstream (pal1000/mesa-dist-win) ships no arm64 build, so bundling +# is skipped for arm64 with a warning. +$BundleMesa = if ($null -ne $env:BUNDLE_MESA) { $env:BUNDLE_MESA } else { '1' } +$MesaVersion = if ($env:MESA_VERSION) { $env:MESA_VERSION } else { '26.1.3' } +# SHA-256 of mesa3d-26.1.3-release-mingw.7z. A downloaded archive is verified +# against this before extraction so a tampered/MITM'd payload cannot be baked +# into the signed MSI. When bumping MESA_VERSION, set MESA_SHA256 to the new +# archive's hash (a mismatch fails the build rather than shipping unverified +# binaries). +$MesaSha256 = if ($env:MESA_SHA256) { $env:MESA_SHA256 } else { '80d5add64254c839b4c784bdab6a2b504e448675604b0fe54a9bce3c543303a7' } +$MesaOpenGlDir = $env:MESA_OPENGL_DIR + +# Resolved, staged Mesa file paths (empty => not bundled; the .wxs guards on +# these being non-empty). Populated by Resolve-Mesa. +$script:MesaOpenGl = '' +$script:MesaGallium = '' +$script:MesaLicense = '' + +# Signing (jsign) parameters - empty => skip signing with a warning. +# JSIGN_JAR path to jsign.jar (or rely on a `jsign` launcher on PATH) +# JSIGN_KEYSTORE keystore: a cloud HSM/KMS name, PKCS#11 config, or .p12 path +# JSIGN_STOREPASS keystore / token password (or cloud credential token) +# JSIGN_STORETYPE e.g. AZUREKEYVAULT, GOOGLECLOUD, AWS, DIGICERTONE, PKCS11, PKCS12 +# JSIGN_ALIAS certificate/key alias within the keystore +# JSIGN_TSAURL timestamp authority URL (CI timestamps the sig) +# JSIGN_TSMODE timestamp protocol: RFC3161 (default) or AUTHENTICODE. +# Must match the TSA: RFC3161-only endpoints (e.g. GlobalSign +# r6advanced1) return HTTP 415 under the AUTHENTICODE default. +# JSIGN_CERTFILE optional PEM with the full cert chain (intermediates + root) +# JSIGN_ALG signature digest algorithm (default SHA-256) +# JSIGN_TSRETRIES jsign's native timestamping retries (default 3) - TSAs flake +# JSIGN_TSRETRYWAIT seconds jsign waits between timestamp retries (default 10) +$JsignJar = $env:JSIGN_JAR +$JsignKeystore = $env:JSIGN_KEYSTORE +$JsignStorePass= $env:JSIGN_STOREPASS +$JsignStoreType= $env:JSIGN_STORETYPE +$JsignAlias = $env:JSIGN_ALIAS +$JsignCertFile = $env:JSIGN_CERTFILE +$JsignTsaUrl = if ($env:JSIGN_TSAURL) { $env:JSIGN_TSAURL } else { 'http://timestamp.digicert.com' } +$JsignTsMode = if ($env:JSIGN_TSMODE) { $env:JSIGN_TSMODE } else { 'RFC3161' } +$JsignAlg = if ($env:JSIGN_ALG) { $env:JSIGN_ALG } else { 'SHA-256' } +$JsignTsRetries = if ($env:JSIGN_TSRETRIES) { [int]$env:JSIGN_TSRETRIES } else { 3 } +$JsignTsRetryWait= if ($env:JSIGN_TSRETRYWAIT) { [int]$env:JSIGN_TSRETRYWAIT } else { 10 } + +# --------------------------------------------------------------------------- +# 1. Build both binaries +# --------------------------------------------------------------------------- + +function Build-Binaries { + Write-Log "Building binaries (version=$Version, commit=$CommitHash, arch=$GoArch)" + + # Mirror the Makefile's version ldflags pattern exactly. + $ldflags = "-s -w " + + "-X '$GoModule/internal/version.Version=$Version' " + + "-X '$GoModule/internal/version.CommitHash=$CommitHash'" + + New-Item -ItemType Directory -Force -Path $BinDir | Out-Null + + $env:GOOS = 'windows' + $env:GOARCH = $GoArch + + # adder CLI: CGO disabled, nodbus build tag (matches Makefile $(BINARIES)). + Write-Log "Building adder.exe (CGO_ENABLED=0 -tags nodbus)" + $env:CGO_ENABLED = '0' + & go build -ldflags $ldflags -tags nodbus ` + -o (Join-Path $BinDir 'adder.exe') (Join-Path $RepoRoot 'cmd\adder') + if ($LASTEXITCODE -ne 0) { Die 'adder.exe build failed' } + + # adder-tray GUI: CGO enabled for Fyne (matches Makefile build-tray). + # NOTE: Fyne pulls in go-gl/OpenGL, which REQUIRES cgo and a C compiler + # (mingw-w64 gcc). This cannot be cross-compiled from macOS/Linux; build + # on a native Windows runner with gcc on PATH. + # -H=windowsgui links against the GUI subsystem so the tray does not + # spawn a console window on launch (the CLI keeps its console). + Write-Log "Building adder-tray.exe (CGO_ENABLED=1, -H=windowsgui)" + $env:CGO_ENABLED = '1' + $trayLdflags = "$ldflags -H=windowsgui" + & go build -ldflags $trayLdflags ` + -o (Join-Path $BinDir 'adder-tray.exe') (Join-Path $RepoRoot 'cmd\adder-tray') + if ($LASTEXITCODE -ne 0) { Die 'adder-tray.exe build failed (need cgo + a C toolchain such as mingw-w64 gcc)' } +} + +# --------------------------------------------------------------------------- +# 1b. Fetch + stage Mesa3D software OpenGL (llvmpipe) next to the tray +# --------------------------------------------------------------------------- + +function Resolve-Mesa { + if ($BundleMesa -in @('0', 'false', 'no', 'off')) { + Write-Warn "BUNDLE_MESA=$BundleMesa - NOT bundling Mesa software OpenGL. The GUI will require a host OpenGL driver." + return + } + if ($GoArch -ne 'amd64') { + Write-Warn "No upstream arm64 Mesa build available - skipping Mesa bundling for $MsiArch. The GUI will require a host OpenGL driver." + return + } + + # $BinDir is the staging area; it may not exist yet when prebuilt binaries + # were supplied (Build-Binaries was skipped). + New-Item -ItemType Directory -Force -Path $BinDir | Out-Null + + # Source of the extracted Mesa tree: an override dir, else download+extract. + $mesaRoot = $MesaOpenGlDir + if ([string]::IsNullOrEmpty($mesaRoot)) { + # Prefer the mingw build: it statically links its C runtime, so it does + # NOT depend on the MSVC redistributable that a bare VM may lack (the + # msvc build does). It requires SSSE3, which every x64 CPU since ~2006 + # has, so it is safe for VirtualBox and other VMs. + $asset = "mesa3d-$MesaVersion-release-mingw.7z" + $url = "https://github.com/pal1000/mesa-dist-win/releases/download/$MesaVersion/$asset" + $mesaDir = Join-Path $BuildDir 'mesa' + $archive = Join-Path $mesaDir $asset + $mesaRoot = Join-Path $mesaDir 'extracted' + + New-Item -ItemType Directory -Force -Path $mesaDir | Out-Null + Write-Log "Downloading Mesa $MesaVersion ($asset)" + # Windows PowerShell 5.1 defaults to TLS 1.0/1.1, which GitHub rejects; + # force TLS 1.2. Suppressing the progress bar speeds large downloads. + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $prevProgress = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + try { + Invoke-WebRequest -Uri $url -OutFile $archive -UseBasicParsing + } finally { + $ProgressPreference = $prevProgress + } + + # Verify integrity before extracting into the (signed) MSI. A mismatch + # means the release asset changed or the download was tampered with; + # fail rather than package unverified binaries. + if ([string]::IsNullOrEmpty($MesaSha256)) { + Die "MESA_SHA256 is empty; refusing to package an unverified Mesa archive. Set MESA_SHA256 for $asset (or BUNDLE_MESA=0)." + } + $actualHash = (Get-FileHash -Path $archive -Algorithm SHA256).Hash.ToLower() + if ($actualHash -ne $MesaSha256.ToLower()) { + Die "Mesa archive hash mismatch for $asset`nexpected $($MesaSha256.ToLower())`ngot $actualHash" + } + Write-Log "Verified Mesa archive SHA-256" + + # GitHub Windows runners ship 7-Zip; accept either launcher name. + $sevenZip = Get-Command 7z -ErrorAction SilentlyContinue + if (-not $sevenZip) { $sevenZip = Get-Command 7za -ErrorAction SilentlyContinue } + if (-not $sevenZip) { Die "7-Zip (7z/7za) not found on PATH; required to extract $asset" } + if (Test-Path $mesaRoot) { Remove-Item -Recurse -Force $mesaRoot } + New-Item -ItemType Directory -Force -Path $mesaRoot | Out-Null + Write-Log "Extracting $asset" + & $sevenZip.Source x $archive "-o$mesaRoot" -y | Out-Null + if ($LASTEXITCODE -ne 0) { Die "extracting $asset failed" } + } else { + Write-Log "Using MESA_OPENGL_DIR=$mesaRoot" + } + + # Locate the 64-bit DLLs. Prefer files under an 'x64' path segment (the + # archive separates x64/x86), falling back to the first match anywhere. + $opengl = Find-MesaFile $mesaRoot 'opengl32.dll' + $gallium = Find-MesaFile $mesaRoot 'libgallium_wgl.dll' + if (-not $opengl) { Die "opengl32.dll not found under $mesaRoot" } + if (-not $gallium) { Die "libgallium_wgl.dll not found under $mesaRoot (needed since Mesa 21.3.0)" } + + # Stage next to adder-tray.exe so the .wxs File sources resolve there. + Copy-Item $opengl (Join-Path $BinDir 'opengl32.dll') -Force + Copy-Item $gallium (Join-Path $BinDir 'libgallium_wgl.dll') -Force + $script:MesaOpenGl = Join-Path $BinDir 'opengl32.dll' + $script:MesaGallium = Join-Path $BinDir 'libgallium_wgl.dll' + + # MIT license text (best-effort; components in the .wxs are optional). + $license = Get-ChildItem -Path $mesaRoot -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '(?i)licen[cs]e|copying' } | + Select-Object -First 1 + if ($license) { + Copy-Item $license.FullName (Join-Path $BinDir 'OpenGL-Mesa-LICENSE.txt') -Force + $script:MesaLicense = Join-Path $BinDir 'OpenGL-Mesa-LICENSE.txt' + } else { + Write-Warn "Mesa license file not found under $mesaRoot; not bundling a license file." + } + + Write-Log "Staged Mesa software OpenGL (opengl32.dll + libgallium_wgl.dll)" +} + +# Find-MesaFile returns the best matching path for $name under $root, preferring +# a match whose path contains an 'x64' segment (64-bit binaries). +function Find-MesaFile { + param([string]$root, [string]$name) + $found = Get-ChildItem -Path $root -Recurse -File -Filter $name -ErrorAction SilentlyContinue + if (-not $found) { return $null } + $x64 = $found | Where-Object { $_.FullName -match '(?i)[\\/]x64[\\/]' } | Select-Object -First 1 + if ($x64) { return $x64.FullName } + return ($found | Select-Object -First 1).FullName +} + +# --------------------------------------------------------------------------- +# 2. Build the MSI with the WiX toolchain +# --------------------------------------------------------------------------- + +function Build-Msi { + Write-Log "Building MSI with WiX v$WixVersion" + New-Item -ItemType Directory -Force -Path $DistDir | Out-Null + + if (-not (Get-Command wix -ErrorAction SilentlyContinue)) { + Die "WiX 'wix' command not found. Install the pinned toolset with: dotnet tool install --global wix --version $WixVersion" + } + + # Ensure the Util extension (util:CloseApplication) is available, pinned to + # the toolset version. Idempotent: re-adding an already-added extension is a + # no-op. + & wix extension add -g "WixToolset.Util.wixext/$WixVersion" + if ($LASTEXITCODE -ne 0) { Die 'wix extension add WixToolset.Util.wixext failed' } + + # Prefer caller-supplied (typically pre-signed) binaries; fall back to the + # binaries Build-Binaries placed under $BinDir. + $AdderExeSrc = if ($AdderExeIn) { $AdderExeIn } else { Join-Path $BinDir 'adder.exe' } + $AdderTrayExeSrc = if ($AdderTrayExeIn) { $AdderTrayExeIn } else { Join-Path $BinDir 'adder-tray.exe' } + if (-not (Test-Path $AdderExeSrc)) { Die "adder.exe not found at $AdderExeSrc" } + if (-not (Test-Path $AdderTrayExeSrc)) { Die "adder-tray.exe not found at $AdderTrayExeSrc" } + + # The Mesa vars are ALWAYS passed; adder.wxs guards its Mesa components on + # them != "NONE". A sentinel (not an empty string) is used to avoid relying + # on `wix -d Name=` empty-value parsing and undefined-variable handling. + $mesaOpenGl = if ($script:MesaOpenGl) { $script:MesaOpenGl } else { 'NONE' } + $mesaGallium = if ($script:MesaGallium) { $script:MesaGallium } else { 'NONE' } + $mesaLicense = if ($script:MesaLicense) { $script:MesaLicense } else { 'NONE' } + $wixArgs = @( + 'build', (Join-Path $ScriptDir 'adder.wxs'), + '-arch', $WixArch, + '-ext', 'WixToolset.Util.wixext', + '-d', "Version=$MsiVersion", + '-d', "AdderExe=$AdderExeSrc", + '-d', "AdderTrayExe=$AdderTrayExeSrc", + '-d', "AdderIco=$IconSrc", + '-d', "MesaOpenGl=$mesaOpenGl", + '-d', "MesaGallium=$mesaGallium", + '-d', "MesaLicense=$mesaLicense", + '-o', $FinalMsi + ) + & wix @wixArgs + if ($LASTEXITCODE -ne 0) { Die 'wix build failed' } + + Write-Log "Built: $FinalMsi" +} + +# --------------------------------------------------------------------------- +# 3. Sign the MSI with the EV certificate via jsign (or skip with a warning) +# --------------------------------------------------------------------------- + +function Sign-Msi { + if ([string]::IsNullOrEmpty($JsignKeystore) -or [string]::IsNullOrEmpty($JsignStorePass)) { + Write-Warn "JSIGN_KEYSTORE / JSIGN_STOREPASS unset - SKIPPING code signing (jsign)." + Write-Warn "Producing an UNSIGNED installer: $FinalMsi" + Write-Warn "It will trigger SmartScreen / 'Unknown publisher' on other machines." + return + } + + Write-Log "Signing MSI with jsign (storetype=$JsignStoreType, alias=$JsignAlias)" + + # Build the jsign argument list. `--alg` and `--tsmode` are pinned rather + # than left to jsign defaults; `--tsretries`/`--tsretrywait` are jsign's + # native timestamp retry (the TSA is the part that flakes). + $jsignArgs = @( + '--keystore', $JsignKeystore, + '--storepass', $JsignStorePass, + '--alg', $JsignAlg, + '--tsaurl', $JsignTsaUrl, + '--tsmode', $JsignTsMode, + '--tsretries', $JsignTsRetries, + '--tsretrywait', $JsignTsRetryWait, + '--name', 'Adder', + '--url', 'https://github.com/blinklabs-io/adder' + ) + if (-not [string]::IsNullOrEmpty($JsignStoreType)) { $jsignArgs += @('--storetype', $JsignStoreType) } + if (-not [string]::IsNullOrEmpty($JsignAlias)) { $jsignArgs += @('--alias', $JsignAlias) } + if (-not [string]::IsNullOrEmpty($JsignCertFile)) { $jsignArgs += @('--certfile', $JsignCertFile) } + $jsignArgs += $FinalMsi + + if (-not [string]::IsNullOrEmpty($JsignJar)) { + & java -jar $JsignJar @jsignArgs + } elseif (Get-Command jsign -ErrorAction SilentlyContinue) { + & jsign @jsignArgs + } else { + Die "jsign not found: set JSIGN_JAR to jsign.jar or install a 'jsign' launcher on PATH." + } + if ($LASTEXITCODE -ne 0) { Die 'jsign signing failed' } + + # Independently confirm the MSI is signed, chain-trusted AND timestamped. + # (jsign can exit 0 while the timestamp step silently failed.) Windows-only + # cmdlet; skipped on non-Windows dev hosts where signing is not exercised. + if (Get-Command Get-AuthenticodeSignature -ErrorAction SilentlyContinue) { + $sig = Get-AuthenticodeSignature $FinalMsi + if ($sig.Status -ne 'Valid') { Die "MSI signature not valid: $($sig.Status)" } + if ($null -eq $sig.TimeStamperCertificate) { Die 'MSI signature is not timestamped' } + Write-Log "Verified MSI signature: Valid, timestamped" + } + + Write-Log "Signed. Verify on Windows with: signtool verify /pa $MsiName" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +Write-Log 'Adder Windows installer build' +Write-Log " version : $Version (msi: $MsiVersion)" +Write-Log " arch : $MsiArch (GOARCH=$GoArch)" +Write-Log " output : $FinalMsi" + +if ($AdderExeIn -and $AdderTrayExeIn -and (Test-Path $AdderExeIn) -and (Test-Path $AdderTrayExeIn)) { + Write-Log "Skipping go build: using prebuilt binaries" + Write-Log " adder.exe : $AdderExeIn" + Write-Log " adder-tray.exe : $AdderTrayExeIn" +} else { + Build-Binaries +} +Resolve-Mesa +Build-Msi +Sign-Msi + +Write-Log "Done: $FinalMsi" +if ([string]::IsNullOrEmpty($JsignKeystore) -or [string]::IsNullOrEmpty($JsignStorePass)) { + Write-Warn 'This is an UNSIGNED build (local/dev). It will warn under SmartScreen on other machines.' +} else { + Write-Log "Verify with: signtool verify /pa $MsiName" +} diff --git a/pipeline/pipeline.go b/pipeline/pipeline.go index 7fa9542d..31b52a2d 100644 --- a/pipeline/pipeline.go +++ b/pipeline/pipeline.go @@ -24,19 +24,20 @@ import ( ) type Pipeline struct { - filterChan chan event.Event - outputChan chan event.Event - errorChan chan error - doneChan chan bool - inputs []plugin.Plugin - filters []plugin.Plugin - outputs []plugin.Plugin - observer chan<- event.Event // optional observer for API /events - observerMu sync.RWMutex - wg sync.WaitGroup - stopOnce sync.Once - running bool - runningMu sync.RWMutex + filterChan chan event.Event + outputChan chan event.Event + errorChan chan error + doneChan chan bool + inputs []plugin.Plugin + filters []plugin.Plugin + outputs []plugin.Plugin + observer chan<- event.Event // optional observer for API /events + observerMu sync.RWMutex + wg sync.WaitGroup + stopOnce sync.Once + lifecycleMu sync.Mutex + running bool + runningMu sync.RWMutex } func New() *Pipeline { @@ -83,6 +84,11 @@ func (p *Pipeline) ErrorChan() <-chan error { // A stopped pipeline can be restarted by calling Start() again. // Note: After restart, consumers must re-obtain channels via ErrorChan() as the old channels are closed. func (p *Pipeline) Start() error { + p.lifecycleMu.Lock() + defer p.lifecycleMu.Unlock() + if p.IsRunning() { + return errors.New("pipeline is already running") + } // Check if doneChan is already closed (pipeline was stopped) // If so, recreate channels to allow restart select { @@ -96,11 +102,30 @@ func (p *Pipeline) Start() error { // continue } + var started []plugin.Plugin + rollback := func(startErr error) error { + var rollbackErrs []error + p.stopOnce.Do(func() { + close(p.doneChan) + for idx := len(started) - 1; idx >= 0; idx-- { + if err := started[idx].Stop(); err != nil { + rollbackErrs = append(rollbackErrs, err) + } + } + p.wg.Wait() + close(p.errorChan) + close(p.filterChan) + close(p.outputChan) + }) + return errors.Join(startErr, errors.Join(rollbackErrs...)) + } + // Start inputs for _, input := range p.inputs { if err := input.Start(); err != nil { - return fmt.Errorf("failed to start input: %w", err) + return rollback(fmt.Errorf("failed to start input: %w", err)) } + started = append(started, input) // Start background process to send input events to combined filter channel p.wg.Add(1) go p.chanCopyLoop(input.OutputChan(), p.filterChan) @@ -111,8 +136,9 @@ func (p *Pipeline) Start() error { // Start filters for idx, filter := range p.filters { if err := filter.Start(); err != nil { - return fmt.Errorf("failed to start filter: %w", err) + return rollback(fmt.Errorf("failed to start filter: %w", err)) } + started = append(started, filter) if idx == 0 { // Start background process to send events from combined filter channel to first filter plugin p.wg.Add(1) @@ -140,8 +166,9 @@ func (p *Pipeline) Start() error { // Start outputs for _, output := range p.outputs { if err := output.Start(); err != nil { - return fmt.Errorf("failed to start output: %w", err) + return rollback(fmt.Errorf("failed to start output: %w", err)) } + started = append(started, output) // Start background error listener p.wg.Add(1) go p.errorChanWait(output.ErrorChan()) @@ -160,6 +187,8 @@ func (p *Pipeline) Start() error { // Stop is idempotent and safe to call multiple times // A stopped pipeline can be restarted by calling Start() again func (p *Pipeline) Stop() error { + p.lifecycleMu.Lock() + defer p.lifecycleMu.Unlock() var stopErrors []error p.stopOnce.Do(func() { diff --git a/pipeline/pipeline_test.go b/pipeline/pipeline_test.go index 99ea8742..9c0ab62a 100644 --- a/pipeline/pipeline_test.go +++ b/pipeline/pipeline_test.go @@ -1,6 +1,7 @@ package pipeline import ( + "errors" "sync" "testing" "time" @@ -34,6 +35,46 @@ type noopPlugin struct { errChan chan error } +type lifecyclePlugin struct { + mu sync.Mutex + startErr error + starts int + stops int + errChan chan error + inputChan chan event.Event + outputChan chan event.Event +} + +func (p *lifecyclePlugin) Start() error { + p.mu.Lock() + defer p.mu.Unlock() + p.starts++ + if p.startErr != nil { + return p.startErr + } + p.errChan = make(chan error) + p.inputChan = make(chan event.Event) + p.outputChan = make(chan event.Event) + return nil +} + +func (p *lifecyclePlugin) Stop() error { + p.mu.Lock() + defer p.mu.Unlock() + p.stops++ + return nil +} + +func (p *lifecyclePlugin) ErrorChan() <-chan error { return p.errChan } +func (p *lifecyclePlugin) InputChan() chan<- event.Event { return p.inputChan } +func (p *lifecyclePlugin) OutputChan() <-chan event.Event { return p.outputChan } + +func (p *lifecyclePlugin) counts() (int, int) { + p.mu.Lock() + defer p.mu.Unlock() + return p.starts, p.stops +} + func (n *noopPlugin) Start() error { return nil } func (n *noopPlugin) Stop() error { return nil } func (n *noopPlugin) ErrorChan() <-chan error { @@ -75,6 +116,57 @@ func TestStopIdempotent(t *testing.T) { } } +func TestStartFailureRollsBackStartedPlugins(t *testing.T) { + p := New() + input := &lifecyclePlugin{} + failing := &lifecyclePlugin{startErr: errors.New("start failed")} + later := &lifecyclePlugin{} + p.AddInput(input) + p.AddFilter(failing) + p.AddOutput(later) + + if err := p.Start(); err == nil { + t.Fatal("expected startup failure") + } + inputStarts, inputStops := input.counts() + failingStarts, failingStops := failing.counts() + laterStarts, laterStops := later.counts() + if inputStarts != 1 || inputStops != 1 { + t.Fatalf("input starts/stops = %d/%d, want 1/1", inputStarts, inputStops) + } + if failingStarts != 1 || failingStops != 0 { + t.Fatalf("failing filter starts/stops = %d/%d, want 1/0", failingStarts, failingStops) + } + if laterStarts != 0 || laterStops != 0 { + t.Fatalf("later output starts/stops = %d/%d, want 0/0", laterStarts, laterStops) + } + if p.IsRunning() { + t.Fatal("pipeline reported running after failed startup") + } + if err := p.Stop(); err != nil { + t.Fatalf("Stop after failed startup: %v", err) + } +} + +func TestPipelineDoubleStartRejected(t *testing.T) { + p := New() + input := &lifecyclePlugin{} + p.AddInput(input) + if err := p.Start(); err != nil { + t.Fatal(err) + } + if err := p.Start(); err == nil { + t.Fatal("expected second Start to fail") + } + starts, _ := input.counts() + if starts != 1 { + t.Fatalf("plugin started %d times, want 1", starts) + } + if err := p.Stop(); err != nil { + t.Fatal(err) + } +} + func TestPipelineRestart(t *testing.T) { p := New() np := &noopPlugin{} diff --git a/tray/app.go b/tray/app.go index eb29ea74..e408b236 100644 --- a/tray/app.go +++ b/tray/app.go @@ -28,11 +28,14 @@ import ( "sync" "sync/atomic" "time" + "unicode" + "unicode/utf8" "fyne.io/fyne/v2" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/driver/desktop" "github.com/blinklabs-io/adder/event" + "github.com/blinklabs-io/adder/internal/explorer" "github.com/blinklabs-io/adder/internal/ui/assets" "github.com/blinklabs-io/adder/tray/notifications" "github.com/blinklabs-io/adder/tray/setup" @@ -70,16 +73,31 @@ type App struct { uiMu sync.Mutex // Recent events for the tray menu - recentEvents []event.Event + recentEvents []recentAlert mRecent *fyne.MenuItem mMenu *fyne.Menu + // recentKeys dedups engine events replayed by the API ring buffer on + // every reconnect (each Apply restart triggers a reconnect). Bounded + // FIFO (recentKeyOrder evicts oldest) sized to the ring buffer so a + // full replay cannot re-add a still-tracked event. Guarded by uiMu. + recentKeys map[string]struct{} + recentKeyOrder []string // intentionalStop records whether the user deliberately stopped the // service, so a Stopped status is not reported as a lost connection. // Written from menu handlers and read from the status observer // goroutine, hence atomic. intentionalStop atomic.Bool - quitChan chan struct{} + // applyDeadline suppresses the transient connection alerts an Apply + // (wizard finish / reconfigure / notification-rules edit) emits when + // it restarts the engine. It holds a UnixNano deadline (0 = not + // applying): alerts are suppressed until the connection settles + // (StatusConnected/StatusError clears it) OR the deadline passes — + // the deadline guarantees a genuine, persistent loss still surfaces + // if the engine never reconnects after a restart. Written from + // applyPlan, read from the status observer goroutine, hence atomic. + applyDeadline atomic.Int64 + quitChan chan struct{} // shutdownOnce guards Shutdown so multiple call sites (Quit menu, // signal handler, test cleanup) cannot double-close quitChan. shutdownOnce sync.Once @@ -119,7 +137,11 @@ func NewApp(fyneApp fyne.App) (*App, error) { // Prepare specialized icons for notifications cacheDir, err := os.UserCacheDir() if err != nil { - slog.Warn("failed to get user cache dir, falling back to temp", "error", err) + slog.Warn( + "failed to get user cache dir, falling back to temp", + "error", + err, + ) cacheDir = os.TempDir() } adderCache := filepath.Join(cacheDir, "adder") @@ -135,13 +157,31 @@ func NewApp(fyneApp fyne.App) (*App, error) { slog.Debug("preparing specialized icons", "cache", adderCache) if err := os.WriteFile(blockPath, assets.GetBlockIcon(128).Content(), 0o600); err != nil { - slog.Error("failed to write block icon", "path", blockPath, "error", err) + slog.Error( + "failed to write block icon", + "path", + blockPath, + "error", + err, + ) } if err := os.WriteFile(govPath, assets.GetGovernanceIcon(128).Content(), 0o600); err != nil { - slog.Error("failed to write governance icon", "path", govPath, "error", err) + slog.Error( + "failed to write governance icon", + "path", + govPath, + "error", + err, + ) } if err := os.WriteFile(txPath, assets.GetTransactionIcon(128).Content(), 0o600); err != nil { - slog.Error("failed to write transaction icon", "path", txPath, "error", err) + slog.Error( + "failed to write transaction icon", + "path", + txPath, + "error", + err, + ) } a := &App{ @@ -236,10 +276,24 @@ func (a *App) onWizardFinish( func (a *App) applyPlan( ctx context.Context, plan setup.SetupPlan, ) (setup.ApplyResult, error) { + // Suppress the transient connection alerts the engine restart inside + // Apply would otherwise emit. runner.Apply reconnects synchronously, + // but the status observer fires asynchronously, so a plain + // clear-on-return would race that delivery and leak phantom alerts. + // Instead arm a deadline: the observer clears it on the settling + // StatusConnected/StatusError, and the deadline bounds suppression so + // a genuine, persistent loss still surfaces if the engine never + // reconnects after the restart. + a.applyDeadline.Store(time.Now().Add(applyGrace).UnixNano()) + result, err := a.runner.Apply(ctx, plan) if err != nil { + a.applyDeadline.Store(0) return result, err } + if ctx.Err() != nil { + a.applyDeadline.Store(0) + } a.configMu.Lock() a.config = result.TrayConfig a.configMu.Unlock() @@ -471,6 +525,8 @@ func (a *App) setupTray() { a.uiMu.Lock() defer a.uiMu.Unlock() a.recentEvents = nil + a.recentKeys = nil + a.recentKeyOrder = nil a.mRecent.ChildMenu = fyne.NewMenu("Recent") if a.mMenu != nil { a.mMenu.Refresh() @@ -587,6 +643,7 @@ func (a *App) setupTray() { a.fyneApp, eng.CurrentEpoch, eng.RecordDrop, + a.addRecentAlert, ) // Log non-zero drop deltas every 30s so suppressed notifications @@ -603,6 +660,7 @@ func (a *App) setupTray() { // so rapid transitions can still deliver out-of-order. var initialFire atomic.Bool initialFire.Store(true) + var hasConnected atomic.Bool a.conn.status.OnChange(func(s Status) { icon := GetStatusIcon(s) slog.Info("tray status changed", @@ -626,6 +684,25 @@ func (a *App) setupTray() { return } + // During an Apply the engine restart cycles the connection + // (stopped → connected). Suppress the phantom Lost/Reconnected + // alerts, clearing the deadline once the connection settles. + if dl := a.applyDeadline.Load(); dl != 0 { + suppress, clear := applySuppress(dl, time.Now().UnixNano(), s) + if clear { + a.applyDeadline.Store(0) + } + if suppress { + // Mark a suppressed connect as seen so the first genuine + // reconnect after this Apply still notifies (otherwise it + // looks like the first-ever connect and is skipped). + if s == StatusConnected { + hasConnected.Store(true) + } + return + } + } + // Connection-status notifications go through the engine: the // dispatcher is the single SendNotification path, the // "connection issues" pref gates them, and they bypass the @@ -641,9 +718,11 @@ func (a *App) setupTray() { "Adder Connection", "Lost connection to sidecar API. Reconnecting...") case s == StatusConnected: - eng.NotifyConnection( - "Adder Connection", - "Reconnected to node.") + if hasConnected.Swap(true) { + eng.NotifyConnection( + "Adder Connection", + "Reconnected to node.") + } } }) @@ -698,10 +777,6 @@ func (a *App) setupTray() { "type", evt.Type, "count", eventCount) - // Recent-events menu update (UI state, separate from - // notification dispatch). - a.addRecentEvent(evt) - // Drop-rather-than-block on the engine queue so a slow // engine cannot stall event processing. Warn-level // because the drop is unrecoverable: the event never @@ -724,28 +799,64 @@ func (a *App) setupTray() { slog.Info("starting adder-tray") - if a.Config().AutoStart { - // Ensure engine is running if configured - status, _ := setup.ServiceStatusCheck() - if status == setup.ServiceRegistered { - _ = setup.StartService() + // Bring the engine to a monitorable state, then connect + // unconditionally so the tray always reflects the real engine state + // (the EventClient retries with backoff, so a down engine shows + // "reconnecting" rather than a permanent gray "stopped"). + // + // If a config exists, assert the registration on every tray launch + // and restart only when the rendered command or config contents + // changed. This is the same lifecycle guarantee used by wizard + // Apply/Reconfigure, and repairs a deleted or stale login-startup + // artifact without requiring the user to open the wizard. + if ConfigExists() { + if err := a.ensureConfiguredService(); err != nil { + slog.Error("failed to ensure adder service", "error", err) + a.fyneApp.SendNotification(fyne.NewNotification( + "Adder setup incomplete", + "Could not ensure the adder service. Open the tray "+ + "menu and choose Reconfigure… to finish setup.", + )) } + } - if err := a.conn.Connect(); err != nil { - slog.Error( - "failed to auto-connect to adder", - "error", err, - ) - } + if err := a.conn.Connect(); err != nil { + slog.Error("failed to connect to adder", "error", err) } } +// ensureConfiguredService asserts registration and runtime state for the +// saved engine config using the same two-step lifecycle as wizard Apply. +func (a *App) ensureConfiguredService() error { + if a.runner == nil || a.runner.Finder == nil || a.runner.Service == nil { + return errors.New("service lifecycle dependencies are not configured") + } + binPath, err := a.runner.Finder.Find() + if err != nil { + return fmt.Errorf("finding adder binary: %w", err) + } + cfgPath := a.Config().AdderConfig + if cfgPath == "" { + cfgPath = filepath.Join(setup.ConfigDir(), "config.yaml") + } + if err := a.runner.Service.EnsureRegistered(binPath, cfgPath); err != nil { + return fmt.Errorf("registering service: %w", err) + } + if err := a.runner.Service.RestartIfConfigChanged(binPath, cfgPath); err != nil { + return fmt.Errorf("ensuring service is running: %w", err) + } + return nil +} + // Shutdown gracefully tears down the tray. Ordering: close quitChan, // join the producer, stop the engine, then quit fyne. Idempotent via // sync.Once. func (a *App) Shutdown() { a.shutdownOnce.Do(func() { close(a.quitChan) + if a.conn != nil { + a.conn.Disconnect() + } if a.producerDone != nil { <-a.producerDone } @@ -862,12 +973,141 @@ func getEmojiForType(evtType string) string { } } -func (a *App) addRecentEvent(evt event.Event) { +// recentEventKeyCap bounds the dedup set. Sized to the API ring buffer +// (defaultRingSize) so a full history replay on reconnect cannot re-add +// a still-tracked event. +const recentEventKeyCap = 128 + +// applyGrace bounds how long an Apply suppresses connection alerts. The +// restart churn (stop → reconnect) settles well under this; if the +// engine never reconnects within it, suppression lapses so a genuine +// persistent loss still surfaces. Var (not const) so tests can shrink it. +var applyGrace = 10 * time.Second + +// applySuppress decides, for a connection-status transition, whether to +// suppress its notification and whether to clear the Apply deadline. It +// is pure so the lifecycle is unit-testable without timers: +// +// - deadline == 0 → not applying: never suppress, nothing to clear. +// - now >= deadline → suppression lapsed: surface the alert and clear +// the stale deadline (covers an engine that never reconnected). +// - StatusError → surface the error but clear (a failed Apply settled). +// - StatusConnected → suppress this restart connect and clear (settled). +// - anything else while in-flight → suppress (transient restart churn). +func applySuppress( + deadlineNano, nowNano int64, s Status, +) (suppress, clear bool) { + if deadlineNano == 0 { + return false, false + } + if nowNano >= deadlineNano { + return false, true + } + if s == StatusError { + return false, true + } + if s == StatusConnected { + return true, true + } + return true, false +} + +// recentLabel builds a recent-events menu label. The notification title +// already carries an emoji for chain events (rules.go), so prepend the +// type emoji only when the title has none — otherwise it doubles up +// (e.g. "🔄 🔄 Chain Rollback"). Connection alerts start with a letter +// and get the getEmojiForType fallback. +func recentLabel(title, evtType, eventTime string) string { + if titleHasLeadingEmoji(title) { + return fmt.Sprintf("%s (%s)", title, eventTime) + } + return fmt.Sprintf("%s %s (%s)", + getEmojiForType(evtType), title, eventTime) +} + +// titleHasLeadingEmoji reports whether the first rune is a symbol/emoji +// rather than a letter, digit or space — i.e. the title already begins +// with its own icon. +func titleHasLeadingEmoji(title string) bool { + r, sz := utf8.DecodeRuneInString(title) + if sz == 0 || r == utf8.RuneError { + return false + } + return !unicode.IsLetter(r) && !unicode.IsDigit(r) && !unicode.IsSpace(r) +} + +// recentEventKey is a stable identity for dedup. explorerHash gives a +// tx/block hash when present; Type+timestamp disambiguates events +// without one (e.g. rollbacks). +func recentEventKey(e event.Event) string { + return e.Type + "|" + explorerHash(e) + "|" + + e.Timestamp.Format(time.RFC3339Nano) +} + +// recentEventSeen reports whether the event was already added. Caller +// must hold uiMu. +func (a *App) recentEventSeen(e event.Event) bool { + _, ok := a.recentKeys[recentEventKey(e)] + return ok +} + +// markRecentEvent records the event key, evicting the oldest when the +// bounded set is full. Caller must hold uiMu. +func (a *App) markRecentEvent(e event.Event) { + key := recentEventKey(e) + if a.recentKeys == nil { + a.recentKeys = make(map[string]struct{}, recentEventKeyCap) + } + a.recentKeys[key] = struct{}{} + a.recentKeyOrder = append(a.recentKeyOrder, key) + if len(a.recentKeyOrder) > recentEventKeyCap { + oldest := a.recentKeyOrder[0] + a.recentKeyOrder = a.recentKeyOrder[1:] + delete(a.recentKeys, oldest) + } +} + +type recentAlert struct { + Title string + Timestamp time.Time + Event event.Event +} + +func (a *App) addRecentAlert(req notifications.Request) { + title := req.Title + if title == "" { + title = "Adder" + } + ts := req.Event.Timestamp + if ts.IsZero() { + ts = time.Now() + } + alert := recentAlert{ + Title: title, + Timestamp: ts, + Event: req.Event, + } + fyne.Do(func() { a.uiMu.Lock() defer a.uiMu.Unlock() - // Keep last 10 events (LIFO order for "Recent" display) - a.recentEvents = append([]event.Event{evt}, a.recentEvents...) + + // Drop events already shown: the API ring buffer replays its + // history to every reconnecting client, so an Apply restart + // would otherwise duplicate the same event. Connection alerts + // (empty Type) are intentionally repeatable and skip dedup. + if alert.Event.Type != "" { + if a.recentEventSeen(alert.Event) { + return + } + a.markRecentEvent(alert.Event) + } + + // Keep the latest 10 matching alerts, newest first so the most recent + // transaction or chain event is immediately visible at the top. + a.recentEvents = append(a.recentEvents, recentAlert{}) + copy(a.recentEvents[1:], a.recentEvents[:len(a.recentEvents)-1]) + a.recentEvents[0] = alert if len(a.recentEvents) > 10 { a.recentEvents = a.recentEvents[:10] } @@ -876,28 +1116,21 @@ func (a *App) addRecentEvent(evt event.Event) { // Update the child menu items := make([]*fyne.MenuItem, 0, len(a.recentEvents)) - for _, e := range a.recentEvents { - eventTime := e.Timestamp.Format("15:04:05") - if e.Timestamp.IsZero() { - eventTime = time.Now().Format("15:04:05") - } - emoji := getEmojiForType(e.Type) - cleanType := strings.TrimPrefix(e.Type, "input.") - label := fmt.Sprintf("%s %s (%s)", emoji, cleanType, eventTime) - - // Create action to "Show" the event in an explorer - hash := "" - if payload, ok := e.Payload.(map[string]any); ok { - if h, ok := payload["blockHash"].(string); ok { - hash = h - } else if h, ok := payload["transactionHash"].(string); ok { - hash = h - } - } + for _, alert := range a.recentEvents { + eventTime := alert.Timestamp.Format("15:04:05") + label := recentLabel(alert.Title, alert.Event.Type, eventTime) + + // Create action to "Show" the event in an explorer. + // Pick the hash by event type: a transaction's/governance + // action's hash lives in Context (transactionHash), a + // block's in Payload (blockHash). Both tx and gov payloads + // also carry blockHash, so reading the payload first would + // mislink them to the block. + hash := explorerHash(alert.Event) item := fyne.NewMenuItem(label, func() { if hash != "" { - url := getExplorerURL(e, hash) + url := getExplorerURL(alert.Event, hash) openURL(url) } }) @@ -912,25 +1145,45 @@ func (a *App) addRecentEvent(evt event.Event) { } func getExplorerURL(e event.Event, hash string) string { - baseURL := "https://cexplorer.io" - // Inspect context for network info + // The event carries its own network magic in Context, so a link always + // resolves on the chain the event came from (JSON numbers decode as float64). + var magic uint32 if ctx, ok := e.Context.(map[string]any); ok { - if magic, ok := ctx["networkMagic"].(float64); ok { - switch uint32(magic) { - case 1: - baseURL = "https://preprod.cexplorer.io" - case 2: - baseURL = "https://preview.cexplorer.io" - } + if m, ok := ctx["networkMagic"].(float64); ok { + magic = uint32(m) } } + baseURL := explorer.BaseURL(magic) - if e.Type == "input.transaction" { + if e.Type == "input.transaction" || e.Type == "input.governance" { return fmt.Sprintf("%s/tx/%s", baseURL, hash) } return fmt.Sprintf("%s/block/%s", baseURL, hash) } +// explorerHash returns the chain hash to link for an event, chosen by +// event type: transactions and governance actions are identified by +// their transactionHash in Context; blocks by blockHash in Payload. +// Reading the payload first would be wrong because tx and governance +// payloads also carry blockHash. +func explorerHash(e event.Event) string { + switch e.Type { + case "input.transaction", "input.governance": + if ctx, ok := e.Context.(map[string]any); ok { + if h, ok := ctx["transactionHash"].(string); ok { + return h + } + } + default: + if p, ok := e.Payload.(map[string]any); ok { + if h, ok := p["blockHash"].(string); ok { + return h + } + } + } + return "" +} + // openFolder opens the given directory in the platform file manager. func openFolder(dir string) { if dir == "" { @@ -940,7 +1193,13 @@ func openFolder(dir string) { // Ensure directory exists so 'open' doesn't fail or open script editor if err := os.MkdirAll(dir, 0o700); err != nil { - slog.Error("failed to create directory before opening", "dir", dir, "error", err) + slog.Error( + "failed to create directory before opening", + "dir", + dir, + "error", + err, + ) } var cmd string @@ -953,7 +1212,10 @@ func openFolder(dir string) { cmd = "xdg-open" } slog.Debug("opening folder", "cmd", cmd, "dir", dir) - p := exec.Command(cmd, dir) //nolint:gosec // directory path from internal config + p := exec.Command( + cmd, + dir, + ) //nolint:gosec // directory path from internal config if err := p.Start(); err != nil { slog.Error("failed to open folder", "dir", dir, "error", err) return diff --git a/tray/app_helpers_test.go b/tray/app_helpers_test.go index be805698..0933f22e 100644 --- a/tray/app_helpers_test.go +++ b/tray/app_helpers_test.go @@ -28,6 +28,7 @@ import ( "fyne.io/fyne/v2/test" "github.com/blinklabs-io/adder/event" "github.com/blinklabs-io/adder/internal/config" + "github.com/blinklabs-io/adder/tray/notifications" "github.com/blinklabs-io/adder/tray/setup" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -135,9 +136,9 @@ func TestEmojiAndExplorerURLHelpers(t *testing.T) { // - tray/notifications/rules_test.go (per-template rule fan-out) // - tray/notifications/render_test.go (Cardano-aware template // rendering) -// - TestAddRecentEventKeepsNewestTen below (recent-events history) +// - TestAddRecentAlertKeepsNewestTen below (recent alert history) -func TestAddRecentEventKeepsNewestTen(t *testing.T) { +func TestAddRecentAlertKeepsNewestTen(t *testing.T) { test.NewApp() trayApp := &App{ mRecent: fyne.NewMenuItem("Recent Events", nil), @@ -145,10 +146,13 @@ func TestAddRecentEventKeepsNewestTen(t *testing.T) { } for i := 0; i < 12; i++ { - trayApp.addRecentEvent(event.Event{ - Type: "input.block", - Timestamp: time.Date(2026, 5, 28, 12, 0, i, 0, time.UTC), - Payload: map[string]any{"blockHash": "hash"}, + trayApp.addRecentAlert(notifications.Request{ + Title: "Block Minted", + Event: event.Event{ + Type: "input.block", + Timestamp: time.Date(2026, 5, 28, 12, 0, i, 0, time.UTC), + Payload: map[string]any{"blockHash": "hash"}, + }, }) } @@ -159,6 +163,192 @@ func TestAddRecentEventKeepsNewestTen(t *testing.T) { }, time.Second, 10*time.Millisecond) assert.Equal(t, 11, trayApp.recentEvents[0].Timestamp.Second()) assert.Equal(t, 2, trayApp.recentEvents[9].Timestamp.Second()) + assert.Equal(t, "Block Minted", trayApp.recentEvents[0].Title) + assert.Contains(t, trayApp.mRecent.ChildMenu.Items[0].Label, "12:00:11") +} + +func TestRecentLabelAvoidsDoubleEmoji(t *testing.T) { + tests := []struct { + name string + title string + evtType string + eventTime string + want string + }{ + { + // Title already carries the emoji (rules.go) — must not + // double up. + name: "title with leading emoji", + title: "🔄 Chain Rollback", + evtType: "input.rollback", + eventTime: "16:56:06", + want: "🔄 Chain Rollback (16:56:06)", + }, + { + name: "block title with emoji", + title: "🧱 Block Minted", + evtType: "input.block", + eventTime: "12:00:00", + want: "🧱 Block Minted (12:00:00)", + }, + { + // Connection alert: plain title, empty type → fallback. + name: "connection alert no emoji", + title: "Adder Connection", + evtType: "", + eventTime: "12:00:00", + want: "❓ Adder Connection (12:00:00)", + }, + { + // Plain event title gets the type emoji prepended. + name: "plain title gets type emoji", + title: "Block Minted", + evtType: "input.block", + eventTime: "12:00:00", + want: "🧱 Block Minted (12:00:00)", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, + recentLabel(tc.title, tc.evtType, tc.eventTime)) + }) + } +} + +func TestTitleHasLeadingEmoji(t *testing.T) { + assert.True(t, titleHasLeadingEmoji("🔄 Chain Rollback")) + assert.True(t, titleHasLeadingEmoji("🧱 Block Minted")) + assert.False(t, titleHasLeadingEmoji("Adder Connection")) + assert.False(t, titleHasLeadingEmoji("123 numeric")) + assert.False(t, titleHasLeadingEmoji("")) + assert.False(t, titleHasLeadingEmoji(" leading space")) +} + +func TestAddRecentAlertDedupsReplayedEvents(t *testing.T) { + test.NewApp() + trayApp := &App{ + mRecent: fyne.NewMenuItem("Recent Events", nil), + mMenu: fyne.NewMenu("Adder"), + } + + evt := event.Event{ + Type: "input.rollback", + Timestamp: time.Date(2026, 7, 12, 16, 56, 6, 0, time.UTC), + Payload: map[string]any{"blockHash": "hash"}, + } + // Same event replayed by the ring buffer on 3 reconnects. + for i := 0; i < 3; i++ { + trayApp.addRecentAlert(notifications.Request{ + Title: "🔄 Chain Rollback", + Event: evt, + }) + } + require.Eventually(t, func() bool { + return len(trayApp.recentEvents) == 1 + }, time.Second, 10*time.Millisecond) + + // A distinct event (different timestamp) is still added. + trayApp.addRecentAlert(notifications.Request{ + Title: "🔄 Chain Rollback", + Event: event.Event{ + Type: "input.rollback", + Timestamp: time.Date(2026, 7, 12, 16, 57, 0, 0, time.UTC), + Payload: map[string]any{"blockHash": "hash"}, + }, + }) + require.Eventually(t, func() bool { + return len(trayApp.recentEvents) == 2 + }, time.Second, 10*time.Millisecond) +} + +func TestAddRecentAlertConnectionEventsNotDeduped(t *testing.T) { + test.NewApp() + trayApp := &App{ + mRecent: fyne.NewMenuItem("Recent Events", nil), + mMenu: fyne.NewMenu("Adder"), + } + // Connection alerts have empty Type — intentionally repeatable. + for i := 0; i < 3; i++ { + trayApp.addRecentAlert(notifications.Request{ + Title: "Adder Connection", + Event: event.Event{}, + }) + } + require.Eventually(t, func() bool { + return len(trayApp.recentEvents) == 3 + }, time.Second, 10*time.Millisecond) +} + +func TestApplySuppress(t *testing.T) { + const now = int64(1_000) + const future = now + 1 // deadline not yet reached + const past = now - 1 // deadline already passed + + tests := []struct { + name string + deadline int64 + status Status + wantSuppress bool + wantClear bool + }{ + { + // Not applying: never suppress, nothing to clear. + name: "not applying", + deadline: 0, + status: StatusStopped, + }, + { + // In-flight restart churn: the transient stop is suppressed, + // deadline stays armed until it settles. + name: "in-flight stopped suppressed", + deadline: future, + status: StatusStopped, + wantSuppress: true, + wantClear: false, + }, + { + name: "in-flight reconnecting suppressed", + deadline: future, + status: StatusReconnecting, + wantSuppress: true, + wantClear: false, + }, + { + // Connection settled the Apply: suppress this restart connect + // and clear the deadline. + name: "in-flight connected settles", + deadline: future, + status: StatusConnected, + wantSuppress: true, + wantClear: true, + }, + { + // A failed Apply settles too, but the error still surfaces. + name: "in-flight error surfaces and settles", + deadline: future, + status: StatusError, + wantSuppress: false, + wantClear: true, + }, + { + // Engine never reconnected within the grace: suppression + // lapses so a genuine persistent loss surfaces, and the stale + // deadline is cleared. + name: "deadline lapsed surfaces and clears", + deadline: past, + status: StatusStopped, + wantSuppress: false, + wantClear: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + suppress, clear := applySuppress(tc.deadline, now, tc.status) + assert.Equal(t, tc.wantSuppress, suppress, "suppress") + assert.Equal(t, tc.wantClear, clear, "clear") + }) + } } func TestSetupTrayBuildsDesktopMenu(t *testing.T) { @@ -189,9 +379,13 @@ func TestSetupTrayBuildsDesktopMenu(t *testing.T) { setup.NotifyPrefConnectionIssues: true, }, }, - fyneApp: deskApp, - conn: NewConnectionManager(), - runner: &setup.SetupRunner{Store: &reconfigureStore{engine: reconfigurePlan.ToEngineConfig(*config.GetConfig())}}, + fyneApp: deskApp, + conn: NewConnectionManager(), + runner: &setup.SetupRunner{ + Store: &reconfigureStore{ + engine: reconfigurePlan.ToEngineConfig(*config.GetConfig()), + }, + }, quitChan: make(chan struct{}), } t.Cleanup(func() { @@ -206,7 +400,7 @@ func TestSetupTrayBuildsDesktopMenu(t *testing.T) { assert.NotNil(t, trayApp.mRecent) assert.NotNil(t, trayApp.mMenu) - trayApp.recentEvents = []event.Event{{Type: "input.block"}} + trayApp.recentEvents = []recentAlert{{Title: "Block Minted"}} clearItem := deskApp.menu.Items[3] require.Equal(t, "Clear History", clearItem.Label) clearItem.Action() @@ -241,6 +435,48 @@ func TestSetupTrayBuildsDesktopMenu(t *testing.T) { }, time.Second, 10*time.Millisecond) } +func TestSetupTrayEnsuresConfiguredServiceOnLaunch(t *testing.T) { + configDir := t.TempDir() + t.Setenv("ADDER_TRAY_CONFIG_DIR", configDir) + require.NoError(t, os.WriteFile( + ConfigPath(), + []byte("api_address: 127.0.0.1\napi_port: 8080\n"), + 0o600, + )) + + baseApp := test.NewApp() + deskApp := &desktopTestApp{App: baseApp} + service := &launchEnsureService{} + trayApp := &App{ + config: TrayConfig{ + APIAddress: "127.0.0.1", + APIPort: 8080, + AdderConfig: filepath.Join(configDir, "config.yaml"), + NotifyPrefs: make(map[string]bool), + }, + fyneApp: deskApp, + conn: NewConnectionManager(), + runner: &setup.SetupRunner{ + Service: service, + Finder: &wizardFinishFinder{path: "/opt/Adder/adder"}, + }, + quitChan: make(chan struct{}), + } + t.Cleanup(func() { + trayApp.conn.Disconnect() + trayApp.Shutdown() + }) + + trayApp.setupTray() + + require.Eventually(t, func() bool { + return service.registered && service.restarted + }, time.Second, 10*time.Millisecond) + assert.Equal(t, "/opt/Adder/adder", service.bin) + assert.Equal(t, trayApp.Config().AdderConfig, service.cfg) + assert.Equal(t, []string{"register", "restart"}, service.calls) +} + func TestShutdownClosesQuitChannel(t *testing.T) { app := test.NewApp() trayApp := &App{ @@ -311,6 +547,32 @@ func TestShutdownWaitsForProducer(t *testing.T) { "Shutdown must wait for producerDone before returning") } +func TestShutdownDisconnectsConnection(t *testing.T) { + app := test.NewApp() + manager := NewConnectionManager() + client := NewEventClient("127.0.0.1", 1, + WithEventStatusTracker(manager.status)) + require.NoError(t, client.Start()) + manager.mu.Lock() + manager.eventClient = client + manager.connected = true + manager.mu.Unlock() + trayApp := &App{ + fyneApp: app, + conn: manager, + quitChan: make(chan struct{}), + } + + trayApp.Shutdown() + + select { + case <-client.stopCh: + default: + t.Fatal("connection worker was not stopped by Shutdown") + } + assert.False(t, manager.connected) +} + func TestOpenFolderIgnoresEmptyPath(t *testing.T) { openFolder("") } @@ -374,7 +636,10 @@ func (s *wizardFinishStore) LoadEngine(string) (config.Config, error) { return s.engine, nil } -func (s *wizardFinishStore) SaveEngineAtomic(_ string, cfg config.Config) error { +func (s *wizardFinishStore) SaveEngineAtomic( + _ string, + cfg config.Config, +) error { s.engine = cfg return nil } @@ -390,8 +655,17 @@ func (s *wizardFinishStore) SaveTrayAtomic(cfg setup.TrayConfig) error { type wizardFinishService struct{} -func (s *wizardFinishService) EnsureRegistered(string, string) error { return nil } -func (s *wizardFinishService) EnsureRunning() error { return nil } +func (s *wizardFinishService) EnsureRegistered( + string, + string, +) error { + return nil +} + +func (s *wizardFinishService) EnsureRunning() error { + return nil +} + func (s *wizardFinishService) RestartIfConfigChanged(string, string) error { return nil } @@ -416,6 +690,37 @@ func (f *wizardFinishFinder) Find() (string, error) { return f.path, nil } +type launchEnsureService struct { + registered bool + restarted bool + bin string + cfg string + calls []string +} + +func (s *launchEnsureService) EnsureRegistered(bin, cfg string) error { + s.registered = true + s.bin = bin + s.cfg = cfg + s.calls = append(s.calls, "register") + return nil +} + +func (s *launchEnsureService) EnsureRunning() error { return nil } + +func (s *launchEnsureService) RestartIfConfigChanged(bin, cfg string) error { + s.restarted = true + s.bin = bin + s.cfg = cfg + s.calls = append(s.calls, "restart") + return nil +} + +func (s *launchEnsureService) Stop() error { return nil } +func (s *launchEnsureService) Status() (setup.ServiceStatus, error) { + return setup.ServiceRunning, nil +} + func installFakeTrayCommands(t *testing.T) { t.Helper() @@ -444,34 +749,34 @@ func installFakeTrayCommands(t *testing.T) { // initial status flows through to the user. func TestSuppressInitialFire(t *testing.T) { cases := []struct { - name string - first bool - status Status + name string + first bool + status Status wantSuppress bool wantFirst bool // value of `first` after the call }{ { - name: "first fire with default Stopped is suppressed", + name: "first fire with default Stopped is suppressed", first: true, status: StatusStopped, wantSuppress: true, wantFirst: false, }, { - name: "first fire with Error is NOT suppressed", + name: "first fire with Error is NOT suppressed", first: true, status: StatusError, wantSuppress: false, wantFirst: false, }, { - name: "first fire with Connected is NOT suppressed", + name: "first fire with Connected is NOT suppressed", first: true, status: StatusConnected, wantSuppress: false, wantFirst: false, }, { - name: "subsequent Stopped fire is NOT suppressed", + name: "subsequent Stopped fire is NOT suppressed", first: false, status: StatusStopped, wantSuppress: false, wantFirst: false, }, { - name: "subsequent Error fire is NOT suppressed", + name: "subsequent Error fire is NOT suppressed", first: false, status: StatusError, wantSuppress: false, wantFirst: false, }, diff --git a/tray/events.go b/tray/events.go index 7b5b8d52..08c33c14 100644 --- a/tray/events.go +++ b/tray/events.go @@ -20,6 +20,7 @@ import ( "fmt" "log/slog" "net/url" + "strconv" "strings" "sync" "time" @@ -143,19 +144,21 @@ func (c *EventClient) Stop() { c.wg.Wait() } -// wsURL builds the WebSocket URL with optional type filter query -// parameter. -func (c *EventClient) wsURL() string { +// wsURL builds the WebSocket URL with optional type filtering. The first +// connection requests live events only; reconnects request buffered replay +// to recover events emitted while the tray was disconnected. +func (c *EventClient) wsURL(replay bool) string { u := url.URL{ Scheme: "ws", Host: fmt.Sprintf("%s:%d", c.address, c.port), Path: "/events", } + q := u.Query() + q.Set("replay", strconv.FormatBool(replay)) if len(c.typeFilter) > 0 { - q := u.Query() q.Set("types", strings.Join(c.typeFilter, ",")) - u.RawQuery = q.Encode() } + u.RawQuery = q.Encode() return u.String() } @@ -168,6 +171,7 @@ func (c *EventClient) connectLoop() { }() attempt := 0 + hasConnected := false for { select { case <-c.stopCh: @@ -175,7 +179,7 @@ func (c *EventClient) connectLoop() { default: } - conn, err := c.dial() + conn, err := c.dial(hasConnected) if err != nil { slog.Debug("ws dial failed", "error", err, "attempt", attempt) attempt++ @@ -202,7 +206,9 @@ func (c *EventClient) connectLoop() { attempt = 0 c.status.Set(StatusConnected) - slog.Info("connected to adder events endpoint", "url", c.wsURL()) + slog.Info("connected to adder events endpoint", + "url", c.wsURL(hasConnected)) + hasConnected = true // Read events until error or stop reconnect := c.readLoop(conn) @@ -226,8 +232,8 @@ func (c *EventClient) connectLoop() { } // dial connects to the WS endpoint. -func (c *EventClient) dial() (*websocket.Conn, error) { - conn, _, err := websocket.DefaultDialer.Dial(c.wsURL(), nil) +func (c *EventClient) dial(replay bool) (*websocket.Conn, error) { + conn, _, err := websocket.DefaultDialer.Dial(c.wsURL(replay), nil) if err != nil { return nil, fmt.Errorf("dialing ws: %w", err) } diff --git a/tray/events_test.go b/tray/events_test.go index 18d5320a..4d28822c 100644 --- a/tray/events_test.go +++ b/tray/events_test.go @@ -165,6 +165,54 @@ func TestEventClient_ReconnectsOnClose(t *testing.T) { mu.Unlock() } +func TestEventClient_ReplaysOnlyAfterReconnect(t *testing.T) { + queries := make(chan string, 2) + var connectionCount int + var mu sync.Mutex + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + queries <- r.URL.Query().Get("replay") + conn, err := testUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + mu.Lock() + connectionCount++ + count := connectionCount + mu.Unlock() + if count == 1 { + conn.Close() + return + } + defer conn.Close() + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }) + + server := httptest.NewServer(handler) + defer server.Close() + address, port := parseTestURL(t, server.URL) + client := NewEventClient(address, port) + require.NoError(t, client.Start()) + defer client.Stop() + + select { + case replay := <-queries: + assert.Equal(t, "false", replay) + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for initial connection") + } + select { + case replay := <-queries: + assert.Equal(t, "true", replay) + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for reconnect") + } +} + func TestEventClient_TypeFilter(t *testing.T) { // Verify that the type filter is sent as a query parameter queryCh := make(chan string, 1) diff --git a/tray/notifications/engine.go b/tray/notifications/engine.go index ee87e73d..16e97153 100644 --- a/tray/notifications/engine.go +++ b/tray/notifications/engine.go @@ -47,6 +47,10 @@ type Request struct { // avoid delivering notifications rendered against superseded // rules. Epoch int64 + // Event is the source event that matched the rule. It lets callers + // build a history from the same filtered alert stream the desktop + // dispatcher uses, instead of keeping a separate raw-event history. + Event event.Event } // Timer is a stoppable single-shot timer mirroring *time.Timer so @@ -499,6 +503,7 @@ func (e *Engine) process(evt event.Event, lim *limiter) { Body: renderRule(r.bodyTmpl, r.NotifyBody, evt, r.Params), Count: 1, Epoch: epoch, + Event: evt, }) continue } @@ -518,6 +523,7 @@ func (e *Engine) process(evt event.Event, lim *limiter) { Body: renderRule(r.bodyTmpl, r.NotifyBody, evt, r.Params), Count: 1, Epoch: epoch, + Event: evt, }) lim.sent++ } diff --git a/tray/notifications/engine_test.go b/tray/notifications/engine_test.go index aacc8f9b..34166761 100644 --- a/tray/notifications/engine_test.go +++ b/tray/notifications/engine_test.go @@ -57,6 +57,9 @@ func TestEngine_EmitsOnMatch(t *testing.T) { assert.Equal(t, "block", req.RuleID) assert.Equal(t, "Block deadbeef", req.Title) assert.Equal(t, "minted", req.Body) + assert.Equal(t, EventTypeBlock, req.Event.Type) + assert.Equal(t, "deadbeef", + req.Event.Payload.(map[string]any)["blockHash"]) } func TestEngine_NoMatchNoEmit(t *testing.T) { @@ -216,20 +219,17 @@ func TestEngine_RateLimitResetsAfterQuietGap(t *testing.T) { assert.Equal(t, 1, r3.Count) } -// TestEngine_MultiTargetPlanORSemantics asserts OR semantics across -// kinds: a plan with wallet + DRep + pool must fire on a pure event of -// any one kind, with no cross-kind AND filtering. -func TestEngine_MultiTargetPlanORSemantics(t *testing.T) { +// TestEngine_SimpleTargetGroupsORSemantics asserts that target groups default +// to OR. Users can narrow matching by selecting explicit AND connectors. +func TestEngine_SimpleTargetGroupsORSemantics(t *testing.T) { plan := setup.SetupPlan{ Filter: setup.FilterConfig{ Wallets: []string{"addr1xyz"}, - DReps: []string{"drep1abc"}, - Pools: []string{"pool1abc"}, + Assets: []string{"asset1abc"}, }, Notify: setup.NotificationPrefs{ - setup.NotifyPrefIncomingTx: true, - setup.NotifyPrefVotesCast: true, - setup.NotifyPrefBlocksMinted: true, + setup.NotifyPrefIncomingTx: true, + setup.NotifyPrefAssetActivity: true, }, } events := make(chan event.Event, 8) @@ -237,39 +237,20 @@ func TestEngine_MultiTargetPlanORSemantics(t *testing.T) { eng.Start() defer eng.Stop() - cases := []struct { - name string - evt event.Event - wantRuleIDPart string - }{ - { - name: "pure wallet tx fires wallet rule", - evt: txEventTo("addr1xyz"), - wantRuleIDPart: "wallet", - }, - { - name: "pure pool block fires pool rule", - evt: blockEvent("h2"), - wantRuleIDPart: "pool", - }, - { - name: "pure governance event fires drep rule", - evt: govEvent(), - wantRuleIDPart: "drep", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - events <- tc.evt - req, ok := recvRequest(t, eng.Requests()) - require.True(t, ok, - "engine must emit a Request for %s under a "+ - "multi-target plan (OR-semantics across kinds)", - tc.name) - assert.Contains(t, req.RuleID, tc.wantRuleIDPart, - "expected RuleID to indicate the kind that matched") - }) - } + events <- txEventTo("addr1xyz") + req, ok := recvRequest(t, eng.Requests()) + require.True(t, ok) + assert.Equal(t, "wallet-in", req.RuleID) + + events <- txWithTokens([2]string{"polA", "asset1abc"}) + req, ok = recvRequest(t, eng.Requests()) + require.True(t, ok) + assert.Equal(t, "asset-activity", req.RuleID) + + events <- txToWithTokens("addr1xyz", [2]string{"polA", "asset1abc"}) + req, ok = recvRequest(t, eng.Requests()) + require.True(t, ok) + assert.Contains(t, []string{"wallet-in", "asset-activity"}, req.RuleID) } // TestEngine_SetRulesDrainsStaleRequests guards the review finding diff --git a/tray/notifications/format.go b/tray/notifications/format.go index ceed94cf..4b865822 100644 --- a/tray/notifications/format.go +++ b/tray/notifications/format.go @@ -45,6 +45,9 @@ func formatFuncs() template.FuncMap { "outAda": totalOutputAda, "field": firstItemField, "int": intString, + // Governance-aware: select the voting procedure cast by a followed + // DRep so a multi-vote event renders the RIGHT vote, not the first. + "voteFor": votingProcedureFor, // Wallet-aware helpers: distinguish the watched address // (".params" in the template data) from the counterparty when // rendering tx notifications. @@ -254,6 +257,43 @@ func firstItemField(field string, slice any) string { return fmt.Sprintf("%v", v) } +// votingProcedureFor returns a single-element slice holding the voting +// procedure cast by a followed DRep (matched on voterId or voterHash +// against params), so `field` renders the RIGHT vote when a governance +// event carries several. Falls back to the original slice when params is +// empty (e.g. Monitor Everything) or nothing matches, preserving the +// first-item behaviour. The []any shape keeps `field` (firstItemField) +// working unchanged. +func votingProcedureFor(procedures any, params any) any { + s, ok := procedures.([]any) + if !ok || len(s) == 0 { + return procedures + } + ps, ok := params.([]string) + if !ok || len(ps) == 0 { + return procedures + } + set := make(map[string]struct{}, len(ps)) + for _, p := range ps { + set[strings.ToLower(p)] = struct{}{} + } + for _, e := range s { + m, ok := e.(map[string]any) + if !ok { + continue + } + id, _ := m["voterId"].(string) + if _, ok := set[strings.ToLower(id)]; ok { + return []any{m} + } + h, _ := m["voterHash"].(string) + if _, ok := set[strings.ToLower(h)]; ok { + return []any{m} + } + } + return procedures +} + // truncMiddle shortens a long identifier (address, hash, DRep/pool ID) // to its first 8 and last 4 characters joined by an ellipsis, e.g. // "addr1qxy…wxyz". Strings short enough to need no truncation are diff --git a/tray/notifications/notify.go b/tray/notifications/notify.go index e14619b8..cc3e20c9 100644 --- a/tray/notifications/notify.go +++ b/tray/notifications/notify.go @@ -43,6 +43,7 @@ func Dispatch( n Notifier, currentEpoch func() int64, recordDrop func(), + onDelivered ...func(Request), ) { for req := range reqs { if currentEpoch != nil && req.Epoch < currentEpoch() { @@ -65,5 +66,10 @@ func Dispatch( "batched", req.Batched, "count", req.Count) n.SendNotification(fyne.NewNotification(title, req.Body)) + for _, fn := range onDelivered { + if fn != nil { + fn(req) + } + } } } diff --git a/tray/notifications/notify_test.go b/tray/notifications/notify_test.go index 117d2955..68e2c115 100644 --- a/tray/notifications/notify_test.go +++ b/tray/notifications/notify_test.go @@ -182,6 +182,23 @@ func TestDispatch_DropsStaleEpochRequests(t *testing.T) { "the unified Stats().Dropped reflects dispatch-side losses") } +func TestDispatch_OnDeliveredOnlyReceivesFreshNotifications(t *testing.T) { + n := &recordingNotifier{} + reqs := make(chan Request, 2) + currentEpoch := func() int64 { return 1 } + reqs <- Request{RuleID: "stale", Title: "stale", Epoch: 0} + reqs <- Request{RuleID: "fresh", Title: "fresh", Epoch: 1} + close(reqs) + + var delivered []Request + Dispatch(reqs, n, currentEpoch, nil, func(req Request) { + delivered = append(delivered, req) + }) + + require.Len(t, delivered, 1) + require.Equal(t, "fresh", delivered[0].RuleID) +} + // TestEngine_SetRulesBumpsEpochAndDispatcherDropsInFlightStale exercises // the end-to-end path: an event fires under the old rule set, then // SetRules is called before Dispatch consumes the buffered Request; diff --git a/tray/notifications/render_test.go b/tray/notifications/render_test.go index 21e68fd5..9fad9b65 100644 --- a/tray/notifications/render_test.go +++ b/tray/notifications/render_test.go @@ -157,6 +157,31 @@ func TestRenderTemplates_RealPhrasings(t *testing.T) { }, want: "DRep drep1abc…wxyz voted Yes on proposal #42.", }, + { + // Regression: an event carrying several votes must render + // the FOLLOWED DRep's vote, not whichever is first. + name: "drep vote picks followed drep among many", + tmpl: tmplGovVote, + params: []string{"drep1followedAAAAwxyz"}, + evt: event.Event{ + Type: EventTypeGovernance, + Payload: map[string]any{ + "votingProcedures": []any{ + map[string]any{ + "voterId": "drep1otherCCCCDDDDzzzz", + "vote": "No", + "govActionIndex": float64(7), + }, + map[string]any{ + "voterId": "drep1followedAAAAwxyz", + "vote": "Yes", + "govActionIndex": float64(42), + }, + }, + }, + }, + want: "DRep drep1fol…wxyz voted Yes on proposal #42.", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/tray/notifications/rules.go b/tray/notifications/rules.go index a5fd5491..29338ba0 100644 --- a/tray/notifications/rules.go +++ b/tray/notifications/rules.go @@ -21,6 +21,7 @@ package notifications import ( + "encoding/hex" "fmt" "strconv" "strings" @@ -28,6 +29,7 @@ import ( "github.com/blinklabs-io/adder/event" "github.com/blinklabs-io/adder/tray/setup" + lcommon "github.com/blinklabs-io/gouroboros/ledger/common" ) // Event type constants. The input.* values mirror the event types @@ -71,11 +73,14 @@ const ( tmplTxToken = "Token transfer at " + "{{or (mine .payload.outputs .params) " + "(other .payload.outputs .params)}}." + // voteFor selects the voting procedure cast by a followed DRep + // (.params) so an event carrying several votes renders the followed + // DRep's vote, not whichever happens to be first. tmplGovVote = "DRep " + - "{{trunc (field \"voterId\" .payload.votingProcedures)}} " + - "voted {{field \"vote\" .payload.votingProcedures}} " + + "{{trunc (field \"voterId\" (voteFor .payload.votingProcedures .params))}} " + + "voted {{field \"vote\" (voteFor .payload.votingProcedures .params)}} " + "on proposal " + - "#{{field \"govActionIndex\" .payload.votingProcedures}}." + "#{{field \"govActionIndex\" (voteFor .payload.votingProcedures .params)}}." tmplGovProposal = "New governance proposal detected." tmplGovReg = "A registration change was detected." tmplAssetActivity = "Asset activity detected in tx " + @@ -219,11 +224,11 @@ func valueToString(v any) string { // tray/wizard/step4_notifications.go) and the existing inline dispatch // logic in tray/app.go, so this engine can replace that inline path. // -// The wizard lets the user populate the three per-target lists -// (Wallets, DReps, Pools) independently — and this function fans out a -// rule per entry in each list, giving natural OR semantics across the -// kinds. When MonitorEverything is on the per-target lists are ignored -// and a single coarse rule per event type is emitted instead. +// Values inside each target group OR together. The configured connectors join +// adjacent populated groups with AND or OR. Event families that cannot carry +// the same fields naturally fail expressions requiring those fields. When +// MonitorEverything is on, targets are ignored and a single coarse rule per +// event type is emitted. // // Assumption (documented): several preferences are finer-grained than // the event types adder emits. Incoming/Outgoing/Token-transfer all @@ -242,16 +247,22 @@ func RulesFromPlan(plan setup.SetupPlan) []Rule { if plan.Filter.MonitorEverything { rules = append(rules, everythingRules(prefs)...) } else { - rules = append(rules, + targetRules := make([]Rule, 0, + len(plan.Filter.Wallets)+len(plan.Filter.DReps)+ + len(plan.Filter.Pools)+len(plan.Filter.Assets)+ + len(plan.Filter.Policies)) + targetRules = append(targetRules, walletRules(prefs, plan.Filter.Wallets)...) - rules = append(rules, + targetRules = append(targetRules, drepRules(prefs, plan.Filter.DReps)...) - rules = append(rules, + targetRules = append(targetRules, poolRules(prefs, plan.Filter.Pools)...) - rules = append(rules, + targetRules = append(targetRules, assetRules(prefs, plan.Filter.Assets)...) - rules = append(rules, + targetRules = append(targetRules, policyRules(prefs, plan.Filter.Policies)...) + rules = append(rules, + applyStandardExpression(targetRules, plan.Filter)...) } // Rollback fires for fork resolutions regardless of monitored @@ -278,10 +289,103 @@ func RulesFromPlan(plan setup.SetupPlan) []Rule { return rules } -// templatedRule describes one (preference, suffix, title, body) tuple -// that is fanned out per parameter value by perParam. -type templatedRule struct { - pref, suffix, title, body string +func applyStandardExpression( + rules []Rule, + filter setup.FilterConfig, +) []Rule { + expression := standardFilterMatcher(filter) + if expression == nil { + return rules + } + for i := range rules { + ownMatch := rules[i].CustomMatch + if ownMatch == nil { + // Defensive: every current target rule sets CustomMatch, but a + // future rule without one would panic in the closure below. + // Fall back to the combined expression alone. + rules[i].CustomMatch = expression + continue + } + rules[i].CustomMatch = func(evt event.Event) bool { + return ownMatch(evt) && expression(evt) + } + } + return rules +} + +func standardFilterMatcher( + filter setup.FilterConfig, +) func(event.Event) bool { + type joinedCondition struct { + match func(event.Event) bool + join setup.AdvancedMatchMode + } + var conditions []joinedCondition + if len(filter.Wallets) > 0 { + addresses := stringSet(filter.Wallets) + conditions = append(conditions, joinedCondition{ + match: func(evt event.Event) bool { + return matchAnyOutputAddress(addresses)(evt) || + matchAnyResolvedInputAddress(addresses)(evt) + }, + join: setup.AdvancedMatchAny, + }) + } + if len(filter.DReps) > 0 { + conditions = append(conditions, joinedCondition{ + match: matchDRepActivity(filter.DReps), + join: filter.ResolvedDRepMatch(), + }) + } + if len(filter.Pools) > 0 { + conditions = append(conditions, joinedCondition{ + match: matchBlockIssuer(filter.Pools), + join: filter.ResolvedPoolMatch(), + }) + } + if len(filter.Assets) > 0 { + conditions = append(conditions, joinedCondition{ + match: matchAnyAsset(filter.Assets), + join: filter.ResolvedAssetMatch(), + }) + } + if len(filter.Policies) > 0 { + conditions = append(conditions, joinedCondition{ + match: matchAnyPolicy(filter.Policies), + join: filter.ResolvedPolicyMatch(), + }) + } + if len(conditions) == 0 { + return nil + } + return func(evt event.Event) bool { + groupMatches := conditions[0].match(evt) + for _, condition := range conditions[1:] { + if condition.join == setup.AdvancedMatchAny { + if groupMatches { + return true + } + groupMatches = condition.match(evt) + } else { + groupMatches = groupMatches && condition.match(evt) + } + } + return groupMatches + } +} + +func matchDRepActivity(dreps []string) func(event.Event) bool { + set := lowerSet(dreps) + votes := matchGovIdentity( + "votingProcedures", "voterId", "voterHash", set, + ) + certificates := matchGovIdentity( + "drepCertificates", "drepId", "drepHash", set, + ) + proposals := matchHasEntries("proposalProcedures") + return func(evt event.Event) bool { + return votes(evt) || certificates(evt) || proposals(evt) + } } // coarseRule describes one of the broad event-family rules emitted in @@ -354,44 +458,6 @@ func everythingRules(prefs setup.NotificationPrefs) []Rule { return out } -// perParam emits one Rule per pref-def for the supplied params. The -// full params list is preserved verbatim on Rule.Params so a later -// address-aware filter can refine matching without reshaping the rule -// model, but the rule itself is single — a chain event therefore fires -// at most one notification per pref no matter how many wallets/DReps/ -// pools the user is monitoring. Empty params returns nil so an -// unconfigured target section never produces a catch-all rule. -func perParam( - idPrefix, eventType string, - prefs setup.NotificationPrefs, - params []string, - defs []templatedRule, -) []Rule { - if len(params) == 0 { - return nil - } - // Defensive copy of params so a downstream Rule consumer cannot - // mutate the wizard plan's slice through the rule. - paramsCopy := append([]string(nil), params...) - rules := make([]Rule, 0, len(defs)) - for _, d := range defs { - rules = append(rules, Rule{ - ID: fmt.Sprintf("%s-%s", idPrefix, d.suffix), - Enabled: prefs[d.pref], - EventType: eventType, - Params: paramsCopy, - NotifyTitle: d.title, - NotifyBody: d.body, - // Phase 1: coarse type match. Address-aware matching - // (consuming Params) is a future enhancement; without it - // every rule with MatchExpr=="" matches every event of - // the right EventType, so emitting per-param rules would - // only multiply identical notifications. - }) - } - return rules -} - // walletRules builds transaction rules for the followed wallets. Each // rule uses a CustomMatch closure so the three classes only fire on // genuinely-matching transactions: @@ -546,46 +612,158 @@ func anyMapEntry( return false } -// drepRules builds governance rules for the Track DRep template: one -// rule per DRep ID per enabled governance preference, so each (DRep ID, -// pref) pair is independently addressable by a later address-aware -// filter. +// drepRules builds governance rules scoped to the followed DReps. A +// governance event is a single "input.governance" type carrying any of +// proposals, votes, and DRep certificates, so each rule uses a +// CustomMatch that (a) checks the event actually contains its subtype +// and (b) for votes/registrations, that a followed DRep is the actor — +// otherwise every rule fired on every governance event regardless of +// DRep, notifying the user about the whole network's activity. +// +// Proposals are not attributable to a specific DRep (anyone may create +// one), so the proposals rule fires on any governance event that carries +// a proposal — a call to action for the DReps the user follows. func drepRules(prefs setup.NotificationPrefs, params []string) []Rule { - return perParam("drep", EventTypeGovernance, prefs, params, - []templatedRule{ - { - setup.NotifyPrefGovProposals, "proposals", - "🗳️ Governance Proposal", tmplGovProposal, - }, - { - setup.NotifyPrefVotesCast, "votes", - "🗳️ Vote Cast", tmplGovVote, - }, - { - setup.NotifyPrefRegChanges, "reg", - "🗳️ Registration Change", tmplGovReg, - }, + if len(params) == 0 { + return nil + } + snap := append([]string(nil), params...) + set := lowerSet(snap) + return []Rule{ + { + ID: "drep-proposals", + Enabled: prefs[setup.NotifyPrefGovProposals], + EventType: EventTypeGovernance, + Params: snap, + NotifyTitle: "🗳️ Governance Proposal", + NotifyBody: tmplGovProposal, + CustomMatch: matchHasEntries("proposalProcedures"), }, - ) + { + ID: "drep-votes", + Enabled: prefs[setup.NotifyPrefVotesCast], + EventType: EventTypeGovernance, + Params: snap, + NotifyTitle: "🗳️ Vote Cast", + NotifyBody: tmplGovVote, + CustomMatch: matchGovIdentity( + "votingProcedures", "voterId", "voterHash", set), + }, + { + ID: "drep-reg", + Enabled: prefs[setup.NotifyPrefRegChanges], + EventType: EventTypeGovernance, + Params: snap, + NotifyTitle: "🗳️ Registration Change", + NotifyBody: tmplGovReg, + CustomMatch: matchGovIdentity( + "drepCertificates", "drepId", "drepHash", set), + }, + } } -// poolRules builds block-event rules for the Monitor Pool template: one -// rule per pool ID per enabled pool preference, so each (pool ID, pref) -// pair is independently addressable by a later address-aware filter. +// poolRules builds a block rule scoped to the followed pools: it fires +// only when the block's issuer is one of them. Without the issuer match +// the rule fired on every block on the chain, not just the user's pool. +// +// There is intentionally no pool-parameter rule: adder emits no +// pool-parameter-change event, so the old "params" rule was mapped onto +// block events and simply fired (mislabeled) on every block. func poolRules(prefs setup.NotificationPrefs, params []string) []Rule { - return perParam("pool", EventTypeBlock, prefs, params, - []templatedRule{ - { - setup.NotifyPrefBlocksMinted, "blocks", - "🧱 Block Minted", tmplPoolBlock, - }, - { - setup.NotifyPrefPoolParams, "params", - "🧱 Pool Parameter Change", - "A pool parameter change was detected.", - }, - }, - ) + if len(params) == 0 { + return nil + } + snap := append([]string(nil), params...) + return []Rule{{ + ID: "pool-blocks", + Enabled: prefs[setup.NotifyPrefBlocksMinted], + EventType: EventTypeBlock, + Params: snap, + NotifyTitle: "🧱 Block Minted", + NotifyBody: tmplPoolBlock, + CustomMatch: matchBlockIssuer(snap), + }} +} + +// lowerSet returns a set of the lowercased input strings. +func lowerSet(vals []string) map[string]struct{} { + set := make(map[string]struct{}, len(vals)) + for _, v := range vals { + set[strings.ToLower(v)] = struct{}{} + } + return set +} + +// stringSet returns a set of the input strings matched exactly, without +// case folding. Used for wallet addresses and asset fingerprints, which +// are bech32 and lowercase-only by spec. Pool/DRep IDs (lowerSet) and +// policy IDs (matchAnyPolicy) fold case instead, since they may be +// entered as mixed-case hex. +func stringSet(values []string) map[string]struct{} { + out := make(map[string]struct{}, len(values)) + for _, v := range values { + out[v] = struct{}{} + } + return out +} + +// matchBlockIssuer fires when a block's issuer is one of the followed +// pools. Block events carry payload.issuerVkey as the hex pool key-hash +// (block.IssuerVkey().Hash()); configured pool IDs may be bech32 +// ("pool1…") or that same hex, so bech32 IDs are decoded to hex before +// comparison. +func matchBlockIssuer(pools []string) func(event.Event) bool { + set := make(map[string]struct{}, len(pools)) + for _, p := range pools { + if strings.HasPrefix(p, "pool1") { + if id, err := lcommon.NewPoolIdFromBech32(p); err == nil { + set[hex.EncodeToString(id[:])] = struct{}{} + } + continue + } + set[strings.ToLower(p)] = struct{}{} + } + return func(evt event.Event) bool { + payload, ok := evt.Payload.(map[string]any) + if !ok { + return false + } + issuer, _ := payload["issuerVkey"].(string) + _, ok = set[strings.ToLower(issuer)] + return ok + } +} + +// matchGovIdentity fires when any entry under payload[key] names a +// followed target in either its bech32 id field or its hex hash field +// (the user may configure DReps as "drep1…" or as hex). +func matchGovIdentity( + key, idField, hashField string, set map[string]struct{}, +) func(event.Event) bool { + return func(evt event.Event) bool { + return anyMapEntry(evt, key, func(m map[string]any) bool { + id, _ := m[idField].(string) + if _, ok := set[strings.ToLower(id)]; ok { + return true + } + h, _ := m[hashField].(string) + _, ok := set[strings.ToLower(h)] + return ok + }) + } +} + +// matchHasEntries fires when payload[key] is a non-empty list, i.e. the +// governance event actually carries that subtype. +func matchHasEntries(key string) func(event.Event) bool { + return func(evt event.Event) bool { + payload, ok := evt.Payload.(map[string]any) + if !ok { + return false + } + entries, ok := payload[key].([]any) + return ok && len(entries) > 0 + } } // assetRules builds one rule that fires on any transaction touching @@ -644,16 +822,20 @@ func matchAnyAsset(assets []string) func(event.Event) bool { } } -// matchAnyPolicy is the policy-ID counterpart of matchAnyAsset. +// matchAnyPolicy is the policy-ID counterpart of matchAnyAsset. Policy +// IDs are hex script hashes and hex.DecodeString is case-insensitive, so +// a validly-entered uppercase/mixed-case ID must still match the chain's +// lowercase policy field. Fold both sides to lower, as matchBlockIssuer +// and matchGovIdentity do for pool/DRep IDs. func matchAnyPolicy(policies []string) func(event.Event) bool { set := make(map[string]struct{}, len(policies)) for _, p := range policies { - set[p] = struct{}{} + set[strings.ToLower(p)] = struct{}{} } return func(evt event.Event) bool { return anyOutputToken(evt, func(tok map[string]any) bool { pol, _ := tok["policy"].(string) - _, ok := set[pol] + _, ok := set[strings.ToLower(pol)] return ok }) } diff --git a/tray/notifications/rules_test.go b/tray/notifications/rules_test.go index 4cf58d87..9e42576f 100644 --- a/tray/notifications/rules_test.go +++ b/tray/notifications/rules_test.go @@ -77,10 +77,54 @@ func blockEvent(hash string) event.Event { } } +func poolBlockEvent(hash, pool string) event.Event { + return event.Event{ + Type: EventTypeBlock, + Payload: map[string]any{ + "blockHash": hash, + "issuerVkey": pool, + }, + } +} + func govEvent() event.Event { + return govEventForDRep("drep1abc") +} + +func govEventForDRep(drep string) event.Event { + return event.Event{ + Type: EventTypeGovernance, + Payload: map[string]any{ + "votingProcedures": []any{ + map[string]any{ + "voterId": drep, + "vote": "yes", + "govActionIndex": float64(1), + }, + }, + }, + } +} + +func govProposal() event.Event { + return event.Event{ + Type: EventTypeGovernance, + Payload: map[string]any{ + "proposalProcedures": []any{ + map[string]any{"deposit": float64(1)}, + }, + }, + } +} + +func govDRepCert(drep string) event.Event { return event.Event{ - Type: EventTypeGovernance, - Payload: map[string]any{}, + Type: EventTypeGovernance, + Payload: map[string]any{ + "drepCertificates": []any{ + map[string]any{"drepId": drep}, + }, + }, } } @@ -208,6 +252,35 @@ func TestRulesFromPlan_WatchWallet(t *testing.T) { "wallet plan must not match block events") } +func TestRulesFromPlan_StandardPoolAssetConnector(t *testing.T) { + plan := setup.SetupPlan{ + Filter: setup.FilterConfig{ + Pools: []string{"pool-hash"}, + Assets: []string{"asset1wanted"}, + AssetMatch: setup.AdvancedMatchAll, + }, + Notify: setup.NotificationPrefs{ + setup.NotifyPrefBlocksMinted: true, + setup.NotifyPrefAssetActivity: true, + }, + } + poolBlock := poolBlockEvent("block-hash", "pool-hash") + assetTx := txWithTokens([2]string{"other", "asset1wanted"}) + + andRules := RulesFromPlan(plan) + require.False(t, anyRuleMatches(andRules, poolBlock), + "Pool AND Asset cannot match a block event") + require.False(t, anyRuleMatches(andRules, assetTx), + "Pool AND Asset cannot match a transaction without issuer data") + + plan.Filter.AssetMatch = setup.AdvancedMatchAny + orRules := RulesFromPlan(plan) + require.True(t, anyRuleMatches(orRules, poolBlock), + "Pool OR Asset should match a followed pool block") + require.True(t, anyRuleMatches(orRules, assetTx), + "Pool OR Asset should match followed asset activity") +} + // TestWalletRules_DirectionFiltering pins the three wallet matchers' // per-direction behaviour: a pure-ADA incoming tx fires Incoming // (not Outgoing, not TokenTransfer); a pure-ADA outgoing tx fires @@ -321,23 +394,34 @@ func TestRulesFromPlan_TrackDRep(t *testing.T) { Notify: setup.NotificationPrefs{ setup.NotifyPrefGovProposals: true, setup.NotifyPrefVotesCast: true, - setup.NotifyPrefRegChanges: false, + setup.NotifyPrefRegChanges: true, }, } rules := RulesFromPlan(plan) - // Positive: governance matches. - require.True(t, anyRuleMatches(rules, govEvent()), - "drep gov rule should match a governance event") + // Positive: a proposal matches the target-independent proposals rule. + require.True(t, anyRuleMatches(rules, govProposal()), + "proposals rule should match a proposal event") + // Positive: governance for the selected DRep matches. + require.True(t, anyRuleMatches(rules, govEventForDRep("drep1abc")), + "drep gov rule should match a governance event for the selected DRep") + require.True(t, anyRuleMatches(rules, govDRepCert("drep1abc")), + "drep reg rule should match a certificate for the selected DRep") + // Negative: a governance event for a different DRep must NOT match. + require.False(t, anyRuleMatches(rules, govEventForDRep("drep1other")), + "drep gov rule must not match a governance event for another DRep") + require.False(t, anyRuleMatches(rules, govDRepCert("drep1other")), + "drep reg rule must not match a certificate for another DRep") // Negative: a transaction must NOT match a DRep plan. require.False(t, anyRuleMatches(rules, txEvent("h", 1)), "drep plan must not match transaction events") } func TestRulesFromPlan_MonitorPool(t *testing.T) { + const poolHex = "aabbccddeeff00112233" plan := setup.SetupPlan{ Filter: setup.FilterConfig{ - Pools: []string{"pool1abc"}, + Pools: []string{poolHex}, }, Notify: setup.NotificationPrefs{ setup.NotifyPrefBlocksMinted: true, @@ -346,14 +430,35 @@ func TestRulesFromPlan_MonitorPool(t *testing.T) { } rules := RulesFromPlan(plan) - // Positive: block matches. - require.True(t, anyRuleMatches(rules, blockEvent("h")), - "pool block rule should match a block event") + // Positive: block from the selected pool matches. + require.True(t, anyRuleMatches(rules, poolBlockEvent("h", poolHex)), + "pool block rule should match a block event from the selected pool") + // Negative: a block from a different pool must NOT match. + require.False(t, anyRuleMatches(rules, poolBlockEvent("h", "ffffffffffffffffffff")), + "pool block rule must not match a block event from another pool") // Negative: governance must NOT match a pool plan. require.False(t, anyRuleMatches(rules, govEvent()), "pool plan must not match governance events") } +func TestRulesFromPlan_PoolBech32MatchesOnlyOwnBlocks(t *testing.T) { + const ( + poolBech32 = "pool16cdtqyk0fvxzfkhjg3esjcuty4tnlpds5lj0lkmqmwdjyzaj7p8" + poolHash = "d61ab012cf4b0c24daf2447309638b25573f85b0a7e4ffdb60db9b22" + ) + plan := setup.SetupPlan{ + Filter: setup.FilterConfig{Pools: []string{poolBech32}}, + Notify: setup.NotificationPrefs{setup.NotifyPrefBlocksMinted: true}, + } + rules := RulesFromPlan(plan) + + require.True(t, anyRuleMatches(rules, poolBlockEvent("h", poolHash)), + "a block minted by the followed pool must match") + require.False(t, anyRuleMatches(rules, poolBlockEvent("h", + "0000000000000000000000000000000000000000000000000000000000")), + "a block minted by any other pool must NOT match") +} + func TestRulesFromPlan_AllDisabled(t *testing.T) { plan := setup.SetupPlan{ Filter: setup.FilterConfig{MonitorEverything: true}, @@ -621,6 +726,13 @@ func txWithTokens(tokens ...[2]string) event.Event { } } +func txToWithTokens(outputAddr string, tokens ...[2]string) event.Event { + evt := txWithTokens(tokens...) + outputs := evt.Payload.(map[string]any)["outputs"].([]any) + outputs[0].(map[string]any)["address"] = outputAddr + return evt +} + func TestRulesFromPlan_FollowAsset(t *testing.T) { plan := setup.SetupPlan{ Filter: setup.FilterConfig{ @@ -664,6 +776,30 @@ func TestRulesFromPlan_FollowPolicy(t *testing.T) { "policy rule must not match unrelated policy ID") } +// TestRulesFromPlan_FollowPolicy_CaseInsensitive guards the fix for the +// uppercase-policy silent no-match: ValidatePolicyID accepts mixed-case +// hex (hex.DecodeString is case-insensitive), but the ledger emits the +// policy field lowercase. The matcher must fold case on both sides, as it +// does for pool/DRep IDs — otherwise a validly-entered uppercase policy +// ID would never match anything, with no error. +func TestRulesFromPlan_FollowPolicy_CaseInsensitive(t *testing.T) { + const upper = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF01" + const lower = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef01" + plan := setup.SetupPlan{ + Filter: setup.FilterConfig{ + Policies: []string{upper}, + }, + Notify: setup.NotificationPrefs{ + setup.NotifyPrefPolicyActivity: true, + }, + } + rules := RulesFromPlan(plan) + + require.True(t, + anyRuleMatches(rules, txWithTokens([2]string{lower, "asset1abc"})), + "uppercase-configured policy must match lowercase chain policy") +} + // TestMatchAnyAsset_PayloadEdgeCases locks in the documented // "no panic, no false match" behaviour of the asset matcher when the // payload is malformed, missing fields, or schema-drifted. Each case @@ -715,44 +851,65 @@ func TestMatchAnyAsset_PayloadEdgeCases(t *testing.T) { } } -// TestRulesFromPlan_MultiKindORSemantics is the regression guard for -// the AND-bug rationale described in codec.go: a user watching one of -// each kind must receive notifications for any event matching any -// kind, with no AND-style cross-filtering between them. -func TestRulesFromPlan_MultiKindORSemantics(t *testing.T) { +// TestRulesFromPlan_SimpleTargetGroupsORSemantics guards the default UI +// promise: independent target sections OR together. Users opt into compound +// AND behavior by creating an advanced rule. +func TestRulesFromPlan_SimpleTargetGroupsORSemantics(t *testing.T) { plan := setup.SetupPlan{ Filter: setup.FilterConfig{ - Wallets: []string{"addr1qxy"}, - DReps: []string{"drep1abc"}, - Pools: []string{"pool1xyz"}, - Assets: []string{"asset1abc"}, - Policies: []string{"polA"}, + Wallets: []string{"addr1watch"}, + Assets: []string{"asset1abc"}, }, Notify: setup.NotificationPrefs{ - setup.NotifyPrefBlocksMinted: true, - setup.NotifyPrefIncomingTx: true, - setup.NotifyPrefVotesCast: true, - setup.NotifyPrefAssetActivity: true, - setup.NotifyPrefPolicyActivity: true, + setup.NotifyPrefIncomingTx: true, + setup.NotifyPrefAssetActivity: true, }, } rules := RulesFromPlan(plan) - // Wallet-only tx (incoming to the watched address) still fires. - require.True(t, anyRuleMatches(rules, txEventTo("addr1qxy")), - "wallet rule fires on a tx sending to the watched address") - // Block-only event (no tx, no gov, no assets) still fires. - require.True(t, anyRuleMatches(rules, blockEvent("h")), - "pool block rule fires on a block event") - // Governance event still fires. - require.True(t, anyRuleMatches(rules, govEvent()), - "governance rule fires on a governance event") - // Asset-only tx (no wallet match in particular) still fires — - // proves the asset rule does not require the tx to also match - // another kind. + require.True(t, anyRuleMatches(rules, txEventTo("addr1watch")), + "wallet-only tx should match its independent target") require.True(t, anyRuleMatches(rules, txWithTokens([2]string{"polA", "asset1abc"})), - "asset rule fires independent of other kinds") + "asset-only tx should match its independent target") + require.True(t, + anyRuleMatches( + rules, + txToWithTokens("addr1watch", [2]string{"polA", "asset1abc"}), + ), + "tx carrying a configured wallet and asset should match") +} + +func TestRulesFromPlan_StakeTargetDoesNotBlockAssetActivity(t *testing.T) { + plan := setup.SetupPlan{ + Filter: setup.FilterConfig{ + Wallets: []string{"stake1uwatched"}, + Assets: []string{"asset1abc"}, + }, + Notify: setup.NotificationPrefs{ + setup.NotifyPrefAssetActivity: true, + }, + } + + require.True(t, + anyRuleMatches( + RulesFromPlan(plan), + txWithTokens([2]string{"polA", "asset1abc"}), + ), + "stake credentials are not present in transaction payment addresses") +} + +func TestRulesFromPlan_StakeTargetDoesNotMatchEveryWalletTransaction(t *testing.T) { + plan := setup.SetupPlan{ + Filter: setup.FilterConfig{Wallets: []string{"stake1uwatched"}}, + Notify: setup.NotificationPrefs{ + setup.NotifyPrefIncomingTx: true, + }, + } + + require.False(t, + anyRuleMatches(RulesFromPlan(plan), txEventTo("addr1unrelated")), + "an unsupported stake target must not turn wallet rules into catch-all rules") } // TestAllNotifyPrefsHaveEngineRule pins setup.AllNotifyPrefs to the @@ -774,6 +931,9 @@ func TestAllNotifyPrefsHaveEngineRule(t *testing.T) { } for _, pref := range setup.AllNotifyPrefs() { t.Run(pref, func(t *testing.T) { + if pref == setup.NotifyPrefPoolParams { + t.Skip("adder does not emit a pool-parameter event yet") + } plan := setup.SetupPlan{ Filter: baseFilter, Notify: setup.NotificationPrefs{pref: true}, diff --git a/tray/setup/codec.go b/tray/setup/codec.go index dcd68289..497a837a 100644 --- a/tray/setup/codec.go +++ b/tray/setup/codec.go @@ -53,11 +53,10 @@ func (p SetupPlan) ToEngineConfig(base config.Config) config.Config { delete(c.Plugin["input"]["chainsync"], "address") } - // 3. Filter Configuration — the cardano filter applies AND - // semantics across its address/drep/pool/asset/policy knobs on - // transaction events. The tray's engine matches per-rule (OR) on - // the firehose, so none of those knobs may be written. Delete any - // stale ones (hand-edited configs, older tray versions). + // 3. Filter Configuration — the tray owns filter semantics and + // persists them on TrayConfig, so none of the UI target lists may + // be written into the sidecar config. Delete any stale sidecar + // knobs from hand-edited configs or older tray versions. if cardano, ok := c.Plugin["filter"]["cardano"]; ok { delete(cardano, "address") delete(cardano, "drep") @@ -126,11 +125,10 @@ func SetupPlanFromEngineConfig(c config.Config, tray TrayConfig) SetupPlan { } } - // Filter lives on the tray config (engine side carries no - // per-target lists — that would AND-combine kinds in the cardano - // filter). Empty tray config → MonitorEverything so the wizard - // advances. CloneFilter detaches the slices so plan mutations - // don't leak into tray.Filter. + // Filter lives on the tray config so the tray notification engine + // owns target matching semantics. Empty tray config → + // MonitorEverything so the wizard advances. CloneFilter detaches + // the slices so plan mutations don't leak into tray.Filter. plan.Filter = CloneFilter(tray.Filter) if !plan.Filter.MonitorEverything && len(plan.Filter.Wallets) == 0 && diff --git a/tray/setup/codec_test.go b/tray/setup/codec_test.go index 475a1c68..ce2ac1b9 100644 --- a/tray/setup/codec_test.go +++ b/tray/setup/codec_test.go @@ -154,7 +154,7 @@ func TestSetupPlanRoundTrip(t *testing.T) { assert.Equal(t, tc.plan.App.AutoStart, got.App.AutoStart) assert.Equal(t, tc.plan.Notify, got.Notify) // Engine config must NOT carry the per-target filter - // knobs — that's the AND-bug guard. + // knobs; tray config is the matching authority. if cardano, ok := engine.Plugin["filter"]["cardano"]; ok { assert.NotContains(t, cardano, "address", "engine filter must not carry address knob") @@ -257,14 +257,11 @@ func TestSetupPlanFromEngineConfigHandlesSparseConfig(t *testing.T) { } // TestToEngineConfigClearsCardanoFilterKnobs is the regression guard -// for the AND-bug: writing wallet/DRep/pool into the sidecar's -// cardano-filter block makes them mutually restrictive on -// TransactionEvent (the filter ANDs across knobs), silently dropping -// events that match one configured kind but not the others. The -// engine now does per-rule OR matching at dispatch time, so -// ToEngineConfig must defensively scrub any stale knobs left over -// from earlier tray versions and must NOT write the plan's Filter -// into the engine config. +// for keeping UI targets out of the sidecar's cardano-filter block. +// The tray notification engine owns target matching semantics, so +// ToEngineConfig must defensively scrub any stale knobs left over from +// earlier tray versions and must NOT write the plan's Filter into the +// engine config. func TestToEngineConfigClearsCardanoFilterKnobs(t *testing.T) { base := config.Config{ Plugin: map[string]map[string]map[any]any{ @@ -299,7 +296,7 @@ func TestToEngineConfigClearsCardanoFilterKnobs(t *testing.T) { // All five legacy knobs must be gone — regardless of whether // the plan has matching targets. Writing any of them back would - // re-introduce the AND bug on transaction events. + // split target semantics between the tray and sidecar configs. assert.NotContains(t, cardano, "address", "address knob must not be written by ToEngineConfig") assert.NotContains(t, cardano, "drep", @@ -313,14 +310,11 @@ func TestToEngineConfigClearsCardanoFilterKnobs(t *testing.T) { } // TestToEngineConfig_CombinedWalletDRepPoolNeverWritesFilterKnobs is -// the regression guard for the reviewer-flagged "looks like a bug" -// scenario: a user who configures wallet + DRep + pool together must -// NOT have those values written into the sidecar's cardano filter -// (which AND-combines them on transaction events, silently dropping -// normal wallet txs that don't also match the DRep + pool filters). -// After the fix, ToEngineConfig leaves the filter knobs unset -// regardless of plan content; OR-style matching happens at the tray -// engine layer. +// the regression guard for keeping a multi-section filter in one +// authority: a user who configures wallet + DRep + pool together must +// NOT have those values written into the sidecar's cardano filter. +// ToEngineConfig leaves the filter knobs unset regardless of plan +// content; target matching happens at the tray engine layer. func TestToEngineConfig_CombinedWalletDRepPoolNeverWritesFilterKnobs( t *testing.T, ) { @@ -449,6 +443,8 @@ func TestSetupPlanFromEngineConfigDoesNotAliasFilterSlices(t *testing.T) { "Assets backing array must be detached") assert.Equal(t, "pol1", got.Filter.Policies[0], "Policies backing array must be detached") + assert.False(t, got.Filter.MonitorEverything, + "configured targets must prevent fallback to MonitorEverything") } func TestToEngineConfigWritesCustomNodeAddress(t *testing.T) { diff --git a/tray/setup/finder.go b/tray/setup/finder.go index 0d17e9b4..d391b35f 100644 --- a/tray/setup/finder.go +++ b/tray/setup/finder.go @@ -25,7 +25,7 @@ import ( // AppBinaryFinder locates a trusted `adder` binary to register and run as a // system service. The resolved path is embedded into the OS service -// definition (systemd ExecStart, launchd ProgramArguments, schtasks /TR) and +// definition (systemd ExecStart, launchd ProgramArguments, Windows command) and // executed, so resolution must never trust an attacker-influenceable location. type AppBinaryFinder struct { // DevLookup, when true, lets Find() fall back to the current working @@ -80,7 +80,10 @@ func (f *AppBinaryFinder) Find() (string, error) { } } - return "", fmt.Errorf("could not find a trusted %s next to the executable", name) + return "", fmt.Errorf( + "could not find a trusted %s next to the executable", + name, + ) } // validateTrustedBinary verifies that path is safe to register as a service diff --git a/tray/setup/plan.go b/tray/setup/plan.go index f3c79e85..6efcc99a 100644 --- a/tray/setup/plan.go +++ b/tray/setup/plan.go @@ -98,19 +98,57 @@ type NetworkConfig struct { CustomPort uint } -// FilterConfig defines the user's monitoring targets. The five lists -// are independent — the engine emits one rule per kind and OR-matches -// across them. MonitorEverything ignores the per-target lists and -// emits one coarse rule per event type. Persisted on TrayConfig (the -// sidecar engine config carries no per-target lists, since the -// cardano filter would AND-combine them on transaction events). +// AdvancedMatchMode selects how a populated target group joins the +// preceding populated group when the filter expression is built: AND +// ("all") or OR ("any"). The name is historical — it drives the standard +// per-group connectors, not a separate "advanced" mode. +type AdvancedMatchMode string + +const ( + AdvancedMatchAll AdvancedMatchMode = "all" + AdvancedMatchAny AdvancedMatchMode = "any" +) + +// FilterConfig defines the user's monitoring targets. Values within a +// target group OR together; the per-group *Match connectors join adjacent +// populated groups with AND or OR. MonitorEverything ignores all target +// lists and emits one coarse rule per event type. Persisted on TrayConfig +// so the tray notification engine owns the matching semantics instead of +// leaking UI state into the sidecar config. type FilterConfig struct { - MonitorEverything bool `yaml:"monitor_everything"` - Wallets []string `yaml:"wallets,omitempty"` - DReps []string `yaml:"dreps,omitempty"` - Pools []string `yaml:"pools,omitempty"` - Assets []string `yaml:"assets,omitempty"` - Policies []string `yaml:"policies,omitempty"` + MonitorEverything bool `yaml:"monitor_everything"` + DRepMatch AdvancedMatchMode `yaml:"drep_match,omitempty"` + PoolMatch AdvancedMatchMode `yaml:"pool_match,omitempty"` + AssetMatch AdvancedMatchMode `yaml:"asset_match,omitempty"` + PolicyMatch AdvancedMatchMode `yaml:"policy_match,omitempty"` + Wallets []string `yaml:"wallets,omitempty"` + DReps []string `yaml:"dreps,omitempty"` + Pools []string `yaml:"pools,omitempty"` + Assets []string `yaml:"assets,omitempty"` + Policies []string `yaml:"policies,omitempty"` +} + +func resolvedStandardMatch(mode AdvancedMatchMode) AdvancedMatchMode { + if mode == AdvancedMatchAll { + return AdvancedMatchAll + } + return AdvancedMatchAny +} + +func (f FilterConfig) ResolvedDRepMatch() AdvancedMatchMode { + return resolvedStandardMatch(f.DRepMatch) +} + +func (f FilterConfig) ResolvedPoolMatch() AdvancedMatchMode { + return resolvedStandardMatch(f.PoolMatch) +} + +func (f FilterConfig) ResolvedAssetMatch() AdvancedMatchMode { + return resolvedStandardMatch(f.AssetMatch) +} + +func (f FilterConfig) ResolvedPolicyMatch() AdvancedMatchMode { + return resolvedStandardMatch(f.PolicyMatch) } // CloneFilter returns a deep copy of f with fresh slice backing @@ -182,38 +220,122 @@ const ( templatePolicy = "Follow Policy" ) -// SummarizeFilter returns a human-readable one-line description of a -// FilterConfig for use in dialogs, notifications, and the wizard's -// "Current configuration" line. Examples: +// SummarizeFilter returns a human-readable description of the active matching +// expression for use in dialogs, notifications, and the wizard's current +// configuration line. Examples: // -// "everything" -// "2 wallets, 1 DRep, 1 pool" -// "2 wallets, 3 assets, 1 policy" -// "nothing configured" +// "Monitor everything" +// "Standard: 2 wallets OR 1 DRep OR 1 pool" +// "No monitoring targets configured" func SummarizeFilter(f FilterConfig) string { if f.MonitorEverything { - return "everything" + return "Monitor everything" } - var parts []string + type summaryPart struct { + text string + join AdvancedMatchMode + } + var parts []summaryPart if n := len(f.Wallets); n > 0 { - parts = append(parts, plural(n, "wallet", "wallets")) + parts = append(parts, summaryPart{ + text: plural(n, "wallet", "wallets"), + join: AdvancedMatchAny, + }) } if n := len(f.DReps); n > 0 { - parts = append(parts, plural(n, "DRep", "DReps")) + parts = append(parts, summaryPart{ + text: plural(n, "DRep", "DReps"), + join: f.ResolvedDRepMatch(), + }) } if n := len(f.Pools); n > 0 { - parts = append(parts, plural(n, "pool", "pools")) + parts = append(parts, summaryPart{ + text: plural(n, "pool", "pools"), + join: f.ResolvedPoolMatch(), + }) } if n := len(f.Assets); n > 0 { - parts = append(parts, plural(n, "asset", "assets")) + parts = append(parts, summaryPart{ + text: plural(n, "asset", "assets"), + join: f.ResolvedAssetMatch(), + }) } if n := len(f.Policies); n > 0 { - parts = append(parts, plural(n, "policy", "policies")) + parts = append(parts, summaryPart{ + text: plural(n, "policy", "policies"), + join: f.ResolvedPolicyMatch(), + }) } if len(parts) == 0 { - return "nothing configured" + return "No monitoring targets configured" + } + var expression strings.Builder + expression.WriteString(parts[0].text) + for _, part := range parts[1:] { + if part.join == AdvancedMatchAll { + expression.WriteString(" AND ") + } else { + expression.WriteString(" OR ") + } + expression.WriteString(part.text) + } + return "Standard: " + expression.String() +} + +// MatchesNothing reports whether f's standard target expression can never +// match any event because every AND-term joins target groups from +// incompatible event families. Wallets/Assets/Policies match transaction +// events, Pools match block events, and DReps match governance events, so +// an AND across two families (e.g. Pool AND Wallet) is a term no single +// event can satisfy. The expression is a disjunction (OR) of such AND- +// terms — see standardFilterMatcher — so it is dead only when EVERY term +// is dead. MonitorEverything and single-group filters are never dead. +func (f FilterConfig) MatchesNothing() bool { + if f.MonitorEverything { + return false + } + type group struct { + family string + join AdvancedMatchMode + } + var groups []group + if len(f.Wallets) > 0 { + groups = append(groups, group{"tx", AdvancedMatchAny}) + } + if len(f.DReps) > 0 { + groups = append(groups, group{"gov", f.ResolvedDRepMatch()}) + } + if len(f.Pools) > 0 { + groups = append(groups, group{"block", f.ResolvedPoolMatch()}) + } + if len(f.Assets) > 0 { + groups = append(groups, group{"tx", f.ResolvedAssetMatch()}) + } + if len(f.Policies) > 0 { + groups = append(groups, group{"tx", f.ResolvedPolicyMatch()}) + } + if len(groups) == 0 { + return false + } + // Split into AND-terms at each OR boundary, mirroring the fold in + // standardFilterMatcher, and check whether every term spans more than + // one event family. + fam := make(map[string]struct{}) + allDead := true + flush := func() { + if len(fam) <= 1 { + allDead = false + } + fam = make(map[string]struct{}) + } + for i, g := range groups { + if i > 0 && g.join == AdvancedMatchAny { + flush() + } + fam[g.family] = struct{}{} } - return strings.Join(parts, ", ") + flush() + return allDead } func plural(n int, singular, plural string) string { diff --git a/tray/setup/plan_paths_service_test.go b/tray/setup/plan_paths_service_test.go index 4ffb2f11..e6867892 100644 --- a/tray/setup/plan_paths_service_test.go +++ b/tray/setup/plan_paths_service_test.go @@ -260,21 +260,21 @@ func TestSummarizeFilter(t *testing.T) { filter FilterConfig want string }{ - {"empty", FilterConfig{}, "nothing configured"}, + {"empty", FilterConfig{}, "No monitoring targets configured"}, { "everything", FilterConfig{MonitorEverything: true}, - "everything", + "Monitor everything", }, { "single wallet", FilterConfig{Wallets: []string{"addr1"}}, - "1 wallet", + "Standard: 1 wallet", }, { "two wallets", FilterConfig{Wallets: []string{"a", "b"}}, - "2 wallets", + "Standard: 2 wallets", }, { "combined", @@ -283,17 +283,17 @@ func TestSummarizeFilter(t *testing.T) { DReps: []string{"d"}, Pools: []string{"p1", "p2", "p3"}, }, - "2 wallets, 1 DRep, 3 pools", + "Standard: 2 wallets OR 1 DRep OR 3 pools", }, { "single asset", FilterConfig{Assets: []string{"asset1"}}, - "1 asset", + "Standard: 1 asset", }, { "two policies", FilterConfig{Policies: []string{"a", "b"}}, - "2 policies", + "Standard: 2 policies", }, { // All five kinds populated — the order is wallet → DRep @@ -307,7 +307,22 @@ func TestSummarizeFilter(t *testing.T) { Assets: []string{"x", "y"}, Policies: []string{"q"}, }, - "1 wallet, 1 DRep, 1 pool, 2 assets, 1 policy", + "Standard: 1 wallet OR 1 DRep OR 1 pool OR 2 assets OR 1 policy", + }, + { + "mixed target connectors", + FilterConfig{ + Wallets: []string{"addr1"}, + DReps: []string{"drep1"}, + Pools: []string{"pool1"}, + Assets: []string{"asset1"}, + Policies: []string{"policy1"}, + DRepMatch: AdvancedMatchAll, + PoolMatch: AdvancedMatchAny, + AssetMatch: AdvancedMatchAll, + PolicyMatch: AdvancedMatchAll, + }, + "Standard: 1 wallet AND 1 DRep OR 1 pool AND 1 asset AND 1 policy", }, } for _, tc := range cases { @@ -317,6 +332,93 @@ func TestSummarizeFilter(t *testing.T) { } } +// TestMatchesNothing locks in the never-match guard: the standard filter +// expression is an OR of AND-terms (see standardFilterMatcher), and an +// AND-term joining groups from different event families (tx / block / gov) +// can never match a single event. The whole expression is dead only when +// every term is dead; a live single-family term reachable via OR rescues +// it. +func TestMatchesNothing(t *testing.T) { + const all = AdvancedMatchAll + const any = AdvancedMatchAny + cases := []struct { + name string + f FilterConfig + want bool + }{ + {"empty", FilterConfig{}, false}, + { + "monitor everything", + FilterConfig{MonitorEverything: true}, + false, + }, + {"single wallet", FilterConfig{Wallets: []string{"a"}}, false}, + { + "wallet OR pool (default OR)", + FilterConfig{Wallets: []string{"a"}, Pools: []string{"p"}}, + false, + }, + { + "wallet AND pool (cross-family, dead)", + FilterConfig{ + Wallets: []string{"a"}, + Pools: []string{"p"}, + PoolMatch: all, + }, + true, + }, + { + "wallet AND asset (same tx family, live)", + FilterConfig{ + Wallets: []string{"a"}, + Assets: []string{"x"}, + AssetMatch: all, + }, + false, + }, + { + "wallet AND DRep (cross-family, dead)", + FilterConfig{ + Wallets: []string{"a"}, + DReps: []string{"d"}, + DRepMatch: all, + }, + true, + }, + { + "dead AND-term rescued by OR to a live term", + FilterConfig{ + Wallets: []string{"a"}, // tx + DReps: []string{"d"}, // gov, AND -> dead term + Assets: []string{"x"}, // tx, OR -> live single term + DRepMatch: all, + AssetMatch: any, + }, + false, + }, + { + "every term cross-family (all dead)", + FilterConfig{ + Wallets: []string{"a"}, + DReps: []string{"d"}, + Pools: []string{"p"}, + Assets: []string{"x"}, + Policies: []string{"q"}, + DRepMatch: all, + PoolMatch: any, + AssetMatch: all, + PolicyMatch: all, + }, + true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.f.MatchesNothing()) + }) + } +} + func TestPathsUseOverridesAndExpandTilde(t *testing.T) { configDir := filepath.Join(t.TempDir(), "config") logDir := filepath.Join(t.TempDir(), "logs") @@ -328,6 +430,8 @@ func TestPathsUseOverridesAndExpandTilde(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + // os.UserHomeDir reads USERPROFILE on Windows, HOME elsewhere. + t.Setenv("USERPROFILE", home) got, err := ExpandTildePath("~/adder.log") require.NoError(t, err) @@ -431,7 +535,7 @@ func TestServiceRenderingAndSafeStatusPaths(t *testing.T) { ConfigPath: "/tmp/config .yaml", LogDir: "/tmp/logs", } - rendered, err := renderServiceUnit(cfg) + rendered, err := renderUnit(cfg) require.NoError(t, err) text := string(rendered) switch runtime.GOOS { @@ -446,7 +550,7 @@ func TestServiceRenderingAndSafeStatusPaths(t *testing.T) { assert.Empty(t, text) } - assert.NotEmpty(t, serviceUnitFilePath()) + assert.NotEmpty(t, serviceUnitPath()) status, err := ServiceStatusCheck() require.NoError(t, err) @@ -533,7 +637,7 @@ func TestServiceLifecycleWithFakePlatformCommand(t *testing.T) { require.NoError( t, - os.WriteFile(serviceUnitFilePath(), []byte("stale"), 0o644), + os.WriteFile(serviceUnitPath(), []byte("stale"), 0o644), ) require.NoError( t, diff --git a/tray/setup/plan_test.go b/tray/setup/plan_test.go index d10d8649..68657ded 100644 --- a/tray/setup/plan_test.go +++ b/tray/setup/plan_test.go @@ -36,12 +36,14 @@ func TestClonePlanIsDeep(t *testing.T) { clone := ClonePlan(orig) clone.Filter.Wallets = append(clone.Filter.Wallets, "addr1b") clone.Filter.DReps[0] = "drep1z" + clone.Filter.Assets[0] = "asset1z" clone.Notify[NotifyPrefIncomingTx] = false clone.Notify[NotifyPrefOutgoingTx] = true clone.Output.Config["k"] = "other" assert.Equal(t, []string{"addr1a"}, orig.Filter.Wallets) assert.Equal(t, "drep1a", orig.Filter.DReps[0]) + assert.Equal(t, "asset1a", orig.Filter.Assets[0]) assert.True(t, orig.Notify[NotifyPrefIncomingTx]) _, hasOutgoing := orig.Notify[NotifyPrefOutgoingTx] assert.False(t, hasOutgoing) diff --git a/tray/setup/runner.go b/tray/setup/runner.go index 04f8f690..49c573f8 100644 --- a/tray/setup/runner.go +++ b/tray/setup/runner.go @@ -98,8 +98,8 @@ func (r *SetupRunner) Apply( return result, fmt.Errorf("saving engine config: %w", err) } - // 3. Save Tray Config — Filter lives here, not in engine config - // (the cardano filter would AND-combine kinds on tx events). + // 3. Save Tray Config — Filter lives here, not in engine config, + // so the tray notification engine owns target matching semantics. // Notify map and Filter slices are deep-copied so later mutations // of plan don't leak into the persisted TrayConfig. notify := make(map[string]bool, len(plan.Notify)) @@ -129,13 +129,22 @@ func (r *SetupRunner) Apply( slog.Error("could not find adder binary for service registration", "stage", "binary-find", "error", err) - } else { - if err := r.Service.RestartIfConfigChanged(binPath, engineCfgPath); err != nil { - result.ServiceRestartErr = err - slog.Error("failed to ensure service is running", - "stage", "service-restart", - "error", err) - } + } else if err := r.Service.EnsureRegistered( + binPath, + engineCfgPath, + ); err != nil { + result.ServiceRestartErr = err + slog.Error("failed to register service", + "stage", "service-register", + "error", err) + } else if err := r.Service.RestartIfConfigChanged( + binPath, + engineCfgPath, + ); err != nil { + result.ServiceRestartErr = err + slog.Error("failed to ensure service is running", + "stage", "service-restart", + "error", err) } // 5. Connection Update diff --git a/tray/setup/runner_test.go b/tray/setup/runner_test.go index 0c345c10..50bdcc22 100644 --- a/tray/setup/runner_test.go +++ b/tray/setup/runner_test.go @@ -58,19 +58,32 @@ func (m *mockStore) SaveTrayAtomic(cfg TrayConfig) error { } type mockService struct { - registered bool - running bool - restarts int + registered bool + running bool + restarts int + ensureRegisteredErr error } -func (m *mockService) EnsureRegistered(bin, cfg string) error { m.registered = true; return nil } -func (m *mockService) EnsureRunning() error { m.running = true; return nil } +func (m *mockService) EnsureRegistered(bin, cfg string) error { + if m.ensureRegisteredErr != nil { + return m.ensureRegisteredErr + } + m.registered = true + return nil +} +func (m *mockService) EnsureRunning() error { m.running = true; return nil } func (m *mockService) RestartIfConfigChanged(bin, cfg string) error { m.restarts++ return nil } -func (m *mockService) Stop() error { m.running = false; return nil } -func (m *mockService) Status() (ServiceStatus, error) { return ServiceRunning, nil } +func (m *mockService) Stop() error { + m.running = false + return nil +} + +func (m *mockService) Status() (ServiceStatus, error) { + return ServiceRunning, nil +} type mockConnector struct { connected bool @@ -120,12 +133,40 @@ func TestApplyDoesNotTouchHostServicesWithFakeManager(t *testing.T) { require.NoError(t, err) assert.True(t, store.saved) + assert.True(t, svc.registered, "service must be registered before restart") assert.Equal(t, 1, svc.restarts) assert.True(t, conn.connected) assert.Equal(t, "127.0.0.1", conn.address) assert.Equal(t, uint(8080), conn.port) } +// TestApplySurfacesRegisterErrorAndSkipsRestart guards the wizard flow +// that previously failed on Windows: EnsureRegistered must run before +// RestartIfConfigChanged, and a registration failure is a soft error +// (config still persisted) that skips the restart attempt. +func TestApplySurfacesRegisterErrorAndSkipsRestart(t *testing.T) { + store := &mockStore{} + svc := &mockService{ensureRegisteredErr: errors.New("register failed")} + conn := &mockConnector{} + runner := &SetupRunner{ + Store: store, + Service: svc, + Conn: conn, + Finder: &mockFinder{path: "/tmp/adder"}, + } + + result, err := runner.Apply(context.Background(), SetupPlan{ + Network: NetworkConfig{Name: "mainnet"}, + Filter: FilterConfig{MonitorEverything: true}, + }) + require.NoError(t, err) + + assert.True(t, store.saved) + require.Error(t, result.ServiceRestartErr) + assert.ErrorIs(t, result.ServiceRestartErr, svc.ensureRegisteredErr) + assert.Equal(t, 0, svc.restarts, "restart must be skipped when register fails") +} + func TestApplyReturnsStoreErrorsBeforeServiceWork(t *testing.T) { wantErr := errors.New("boom") tests := []struct { diff --git a/tray/setup/service.go b/tray/setup/service.go index 9e3bdb79..269fa188 100644 --- a/tray/setup/service.go +++ b/tray/setup/service.go @@ -16,10 +16,12 @@ package setup import ( "bytes" + "crypto/sha256" + "errors" "fmt" "log/slog" "os" - "strings" + "path/filepath" ) // ServiceManager defines the interface for idempotent service lifecycle @@ -32,9 +34,39 @@ type ServiceManager interface { Status() (ServiceStatus, error) } +// registrar seams the platform registration primitives so EnsureRegistered's +// idempotent skip/repair decision is testable on any host OS. +type registrar interface { + existingUnit() []byte + registerService(cfg ServiceConfig) error +} + +// osRegistrar is the default backend: it delegates to the per-OS free +// functions (existingUnit/registerService in service_.go). +type osRegistrar struct{} + +func (osRegistrar) existingUnit() []byte { return existingUnit() } + +func (osRegistrar) registerService(c ServiceConfig) error { + return registerService(c) +} + // OSManager implements ServiceManager by delegating to platform-specific // logic, ensuring operations are idempotent. -type OSManager struct{} +type OSManager struct { + // reg is the registration backend; nil means the real platform functions + // (osRegistrar). Tests inject a fake to observe skip vs repair. + reg registrar +} + +// registrar returns the injected backend, or the real OS-backed default so a +// zero-value &OSManager{} keeps working. +func (m *OSManager) registrar() registrar { + if m.reg != nil { + return m.reg + } + return osRegistrar{} +} func (m *OSManager) EnsureRegistered(binPath, cfgPath string) error { cfg := ServiceConfig{ @@ -43,28 +75,22 @@ func (m *OSManager) EnsureRegistered(binPath, cfgPath string) error { LogDir: LogDir(), } - // 1. Render desired state - desired, err := renderServiceUnit(cfg) + desired, err := renderUnit(cfg) if err != nil { return fmt.Errorf("rendering service unit: %w", err) } - // 2. Check existing state - path := serviceUnitFilePath() - var existing []byte - if !strings.HasPrefix(path, "schtasks://") { - existing, _ = os.ReadFile(path) - } - - if existing != nil && bytes.Equal(existing, desired) { - slog.Debug("service already registered with identical configuration", - "path", path) + // Re-register unless the platform registration already matches the + // desired state. Each OS owns how to inspect that registration. + r := m.registrar() + if existing := r.existingUnit(); existing != nil && + bytes.Equal(existing, desired) { + slog.Debug("service already registered with identical configuration") return nil } - // 3. Update only if different - slog.Info("registering/updating system service", "path", path) - return registerService(cfg) + slog.Info("registering/updating system service") + return r.registerService(cfg) } func (m *OSManager) EnsureRunning() error { @@ -85,23 +111,43 @@ func (m *OSManager) RestartIfConfigChanged(binPath, cfgPath string) error { LogDir: LogDir(), } - desired, err := renderServiceUnit(cfg) + desired, err := renderUnit(cfg) if err != nil { return err } - path := serviceUnitFilePath() - var existing []byte - if !strings.HasPrefix(path, "schtasks://") { - existing, _ = os.ReadFile(path) + // Restart when the rendered unit OR the engine config.yaml contents have + // changed since the last successful apply. The unit references the config + // by path, not contents, so hashing the config bytes is what makes a + // same-path config edit (network, filters, …) take effect. Registration + // itself is owned by EnsureRegistered, which the caller runs first. + want, err := configFingerprint(desired, cfgPath) + if err != nil { + return err } - - if len(existing) > 0 && !bytes.Equal(existing, desired) { - slog.Info("service configuration changed, restarting", "path", path) - if err := registerService(cfg); err != nil { + got, _ := os.ReadFile(serviceStatePath()) + + if !bytes.Equal(got, want) { + slog.Info("service configuration changed, restarting") + // Stop before start so the engine actually reloads; service managers + // can otherwise leave the old instance running. Best-effort: a + // not-running service is not an error here. + _ = stopService() + if err := startService(); err != nil { return err } - return startService() + // Persist only after a successful restart so a failed start is retried + // on the next apply instead of being masked as already-applied. + if err := os.MkdirAll(filepath.Dir(serviceStatePath()), 0o755); err == nil { + if werr := os.WriteFile(serviceStatePath(), want, 0o600); werr != nil { + slog.Warn( + "could not persist service fingerprint", + "error", + werr, + ) + } + } + return nil } return m.EnsureRunning() @@ -115,13 +161,24 @@ func (m *OSManager) Status() (ServiceStatus, error) { return serviceStatusCheck() } -// Helper to provide uniform access to platform-specific unit rendering -func renderServiceUnit(cfg ServiceConfig) ([]byte, error) { - // These are defined in service_.go - return renderUnit(cfg) +// configFingerprint hashes the rendered service unit together with the engine +// config.yaml contents, so a change to either is detected. The unit only +// references the config by path, so hashing the contents is what catches a +// same-path config edit. A missing config file hashes as empty (deterministic). +func configFingerprint(unit []byte, cfgPath string) ([]byte, error) { + h := sha256.New() + h.Write(unit) + if cfgPath != "" { + content, err := os.ReadFile(cfgPath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("reading config for fingerprint: %w", err) + } + h.Write(content) + } + return h.Sum(nil), nil } -func serviceUnitFilePath() string { - // This is defined in service_.go - return serviceUnitPath() +// serviceStatePath is where the last-applied restart fingerprint is stored. +func serviceStatePath() string { + return filepath.Join(ConfigDir(), "service-state") } diff --git a/tray/setup/service_darwin.go b/tray/setup/service_darwin.go index 6eae9b48..460d1ec6 100644 --- a/tray/setup/service_darwin.go +++ b/tray/setup/service_darwin.go @@ -33,8 +33,9 @@ const ( launchAgentFile = "io.blinklabs.adder.plist" ) -const plistTemplate = ` - +const plistTemplate = `` + "\n" + + `` + ` Label @@ -113,20 +114,31 @@ func registerService(cfg ServiceConfig) error { return err } - if err := os.WriteFile(serviceUnitPath(), data, 0o644); err != nil { //nolint:gosec // plist files need 0644 permissions + //nolint:gosec // plist files need 0644 permissions + if err := os.WriteFile(serviceUnitPath(), data, 0o644); err != nil { return fmt.Errorf("writing plist file: %w", err) } + // launchctl bootstrap can transiently fail with "Bootstrap failed: 5: + // Input/output error" when it races a just-completed bootout (launchd is + // still tearing down the old job). Retry that specific case a few times. target := fmt.Sprintf("gui/%d", os.Getuid()) - if out, err := exec.Command( //nolint:gosec // paths are generated internally - "launchctl", "bootstrap", target, serviceUnitPath(), - ).CombinedOutput(); err != nil { - if !strings.Contains(string(out), "service already bootstrapped") { - return fmt.Errorf("loading launch agent: %s: %w", strings.TrimSpace(string(out)), err) + var out []byte + for attempt := range 3 { + out, err = exec.Command( //nolint:gosec // paths are generated internally + "launchctl", "bootstrap", target, serviceUnitPath(), + ).CombinedOutput() + if err == nil || + strings.Contains(string(out), "service already bootstrapped") { + return nil + } + if !strings.Contains(string(out), "Bootstrap failed: 5") { + break } + time.Sleep(time.Duration(attempt+1) * 300 * time.Millisecond) } - - return nil + return fmt.Errorf("loading launch agent: %s: %w", + strings.TrimSpace(string(out)), err) } func unregisterService() error { @@ -135,7 +147,8 @@ func unregisterService() error { "launchctl", "bootout", target, ).CombinedOutput(); err != nil { if !strings.Contains(string(out), "Could not find service") { - return fmt.Errorf("unloading launch agent: %s: %w", strings.TrimSpace(string(out)), err) + return fmt.Errorf("unloading launch agent: %s: %w", + strings.TrimSpace(string(out)), err) } } @@ -151,7 +164,9 @@ func serviceStatusCheck() (ServiceStatus, error) { return ServiceNotRegistered, nil } - if err := exec.Command("launchctl", "list", launchAgentLabel).Run(); err == nil { + if err := exec.Command( + "launchctl", "list", launchAgentLabel, + ).Run(); err == nil { return ServiceRunning, nil } @@ -170,7 +185,9 @@ func startService() error { } // Check if already running to avoid "Bootstrap failed: 5" errors - if err := exec.Command("launchctl", "list", launchAgentLabel).Run(); err == nil { + if err := exec.Command( + "launchctl", "list", launchAgentLabel, + ).Run(); err == nil { return nil // Already running } @@ -212,3 +229,8 @@ func stopService() error { } return nil } + +func existingUnit() []byte { + data, _ := os.ReadFile(serviceUnitPath()) + return data +} diff --git a/tray/setup/service_freebsd.go b/tray/setup/service_freebsd.go index a1d34274..a15b701e 100644 --- a/tray/setup/service_freebsd.go +++ b/tray/setup/service_freebsd.go @@ -50,3 +50,7 @@ func startService() error { func stopService() error { return errFreeBSDServiceUnsupported } + +func existingUnit() []byte { + return nil +} diff --git a/tray/setup/service_impl.go b/tray/setup/service_impl.go index 87c7d8c3..eb47b24f 100644 --- a/tray/setup/service_impl.go +++ b/tray/setup/service_impl.go @@ -63,7 +63,7 @@ func (c ServiceConfig) Validate() error { // RegisterService installs adder as a system service using the // platform-specific mechanism (systemd user unit, launchd plist, -// or Windows Task Scheduler). +// or Windows HKCU Run). func RegisterService(cfg ServiceConfig) error { if err := cfg.Validate(); err != nil { return err diff --git a/tray/setup/service_linux.go b/tray/setup/service_linux.go index e2d5a985..6c4d5212 100644 --- a/tray/setup/service_linux.go +++ b/tray/setup/service_linux.go @@ -140,3 +140,8 @@ func stopService() error { } return nil } + +func existingUnit() []byte { + data, _ := os.ReadFile(serviceUnitPath()) + return data +} diff --git a/tray/setup/service_test.go b/tray/setup/service_test.go new file mode 100644 index 00000000..f7aff59a --- /dev/null +++ b/tray/setup/service_test.go @@ -0,0 +1,93 @@ +// Copyright 2026 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package setup + +import ( + "path/filepath" + "testing" +) + +// spyRegistrar records whether EnsureRegistered took the repair action +// (registerService) so the test can assert the skip/repair decision itself, +// not merely the existingUnit predicate that feeds it. Runs on any host OS. +type spyRegistrar struct { + unit []byte + registerCalls int +} + +func (s *spyRegistrar) existingUnit() []byte { return s.unit } + +func (s *spyRegistrar) registerService(ServiceConfig) error { + s.registerCalls++ + return nil +} + +// TestEnsureRegisteredAction verifies EnsureRegistered skips vs repairs based on +// the current registration state, asserting the observable action +// (registerService call count) rather than the existingUnit return value. +func TestEnsureRegisteredAction(t *testing.T) { + tmp := t.TempDir() + // Mirror the cfg EnsureRegistered builds internally (it uses LogDir(), not a + // caller-supplied LogDir), so the skip-case "existing" equals the desired + // unit EnsureRegistered computes. + cfg := ServiceConfig{ + BinaryPath: filepath.Join(tmp, "adder"), + ConfigPath: filepath.Join(tmp, "config.yaml"), + LogDir: LogDir(), + } + desired, err := renderUnit(cfg) + if err != nil { + t.Fatalf("renderUnit: %v", err) + } + + tests := []struct { + name string + existing []byte + wantRegisters int + }{ + { + name: "correct registration is skipped", + existing: desired, + wantRegisters: 0, + }, + { + name: "missing registration is repaired", + existing: nil, + wantRegisters: 1, + }, + { + name: "stale registration is repaired", + existing: []byte("stale-unit"), + wantRegisters: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spy := &spyRegistrar{unit: tt.existing} + mgr := &OSManager{reg: spy} + if err := mgr.EnsureRegistered( + cfg.BinaryPath, cfg.ConfigPath, + ); err != nil { + t.Fatalf("EnsureRegistered: %v", err) + } + if spy.registerCalls != tt.wantRegisters { + t.Fatalf( + "registerService calls = %d, want %d", + spy.registerCalls, tt.wantRegisters, + ) + } + }) + } +} diff --git a/tray/setup/service_windows.go b/tray/setup/service_windows.go index e80c7bb7..34843c28 100644 --- a/tray/setup/service_windows.go +++ b/tray/setup/service_windows.go @@ -16,127 +16,628 @@ package setup +// Windows service management for a *per-user, non-elevated* tray app. +// +// We deliberately do NOT use Task Scheduler: `schtasks /Create` writes to the +// root Task Scheduler folder, which per Microsoft only Administrators may do +// ("Only Administrators can schedule tasks."), so a non-elevated tray would get +// "Access is denied". +// +// Autostart model (mirrors mainstream tray apps like Slack/Dropbox): +// +// - The per-user HKCU\...\Run value autostarts the TRAY (adder-tray.exe), a +// GUI-subsystem binary (-H=windowsgui) that starts silently, no console. +// - The engine (adder.exe) is a console binary. It is never placed in the +// Run key directly, because Explorer would allocate a visible console +// window for it at every logon. Instead the tray launches it as a +// DETACHED, windowless child and manages its lifecycle by the PID it +// recorded at launch. +// - The engine command line is mirrored to a file so service.go can diff +// config changes and startService knows exactly what to launch. +// +// None of this requires elevation. + import ( + "errors" "fmt" + "log/slog" + "os" "os/exec" + "path/filepath" + "strconv" "strings" + "sync" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +const ( + // runKeyPath is the per-user "run at logon" registry key. + runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run` + // runValueName is the value we own under runKeyPath. It launches the TRAY. + runValueName = "Adder" + // stopWaitTimeout bounds how long we wait for a terminated engine to + // actually exit before proceeding, so a restart does not race the old + // process for the API port. + stopWaitTimeout = 10 * time.Second +) + +// serviceUnitPath returns a virtual identifier: there is no on-disk unit file +// behind a registry Run value. +// +//nolint:unused // used in plan_paths_service_test.go +func serviceUnitPath() string { + return "runkey://" + runValueName +} + +var ( + runKeyRegistryHive = registry.CURRENT_USER + runKeyRegistryPath = runKeyPath + enginePIDMu sync.Mutex ) -const taskName = "Adder" +// existingUnit reads and verifies both the registry autostart value pointing +// to the tray, and the mirrored engine command file. If either is missing, +// stale, or points to the wrong target, it returns nil to trigger a repair. +func existingUnit() []byte { + k, err := registry.OpenKey( + runKeyRegistryHive, runKeyRegistryPath, registry.QUERY_VALUE, + ) + if err != nil { + return nil + } + defer k.Close() + val, _, err := k.GetStringValue(runValueName) + if err != nil { + return nil + } + + trayExe, err := trayExecutable() + if err != nil { + return nil + } + wantCommand := windows.ComposeCommandLine([]string{trayExe}) + if !strings.EqualFold(val, wantCommand) { + return nil + } -// renderUnit returns the command line string as the "unit definition" for -// comparison. + data, err := os.ReadFile(serviceCommandFile()) + if err != nil { + return nil + } + return data +} + +// serviceCommandFile mirrors the engine command. There is no readable unit +// behind a Run value, so this file (a) records exactly what startService must +// launch and (b) lets configFingerprint detect command changes. +func serviceCommandFile() string { + return filepath.Join(ConfigDir(), "service-command.txt") +} + +// engineLogFile is where the detached engine's stdout/stderr are redirected. A +// DETACHED_PROCESS has no console, so without this its logs would be lost. +func engineLogFile() string { + return filepath.Join(LogDir(), "adder-engine.log") +} + +// enginePIDFile records the PID of the engine the tray launched, so stop can +// terminate that exact process even after its on-disk image is renamed. +func enginePIDFile() string { + return filepath.Join(ConfigDir(), "engine.pid") +} + +// renderUnit returns the canonical, correctly-quoted engine command line. +// ComposeCommandLine quoting round-trips through DecomposeCommandLine and +// CreateProcess, including paths with spaces such as %ProgramFiles%\Adder. func renderUnit(cfg ServiceConfig) ([]byte, error) { - command := fmt.Sprintf(`"%s"`, cfg.BinaryPath) + args := []string{cfg.BinaryPath} if cfg.ConfigPath != "" { - command = fmt.Sprintf(`"%s" --config "%s"`, cfg.BinaryPath, cfg.ConfigPath) + args = append(args, "--config", cfg.ConfigPath) } - return []byte(command), nil + return []byte(windows.ComposeCommandLine(args)), nil } -// serviceUnitPath is not applicable for Windows Task Scheduler in the same way, -// but we'll use a virtual path or just return a constant for consistency. -func serviceUnitPath() string { - return "schtasks://" + taskName +// trayExecutable returns the running tray executable path, which the Run value +// autostarts. registerService is only ever called from adder-tray, so +// os.Executable() resolves to adder-tray.exe. +func trayExecutable() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("resolving tray executable path: %w", err) + } + return exe, nil } func registerService(cfg ServiceConfig) error { - data, _ := renderUnit(cfg) - command := string(data) - - out, err := exec.Command( //nolint:gosec // command args constructed from validated config - "schtasks.exe", - "/Create", - "/TN", taskName, - "/SC", "ONLOGON", - "/TR", command, - "/RL", "LIMITED", - "/F", - ).CombinedOutput() + data, err := renderUnit(cfg) if err != nil { - return fmt.Errorf( - "creating scheduled task: %s: %w", - strings.TrimSpace(string(out)), - err, - ) + return err + } + + // 1. Autostart the tray at logon via the per-user Run key (GUI subsystem = + // silent; no elevation needed). + trayExe, err := trayExecutable() + if err != nil { + return err + } + runCommand := windows.ComposeCommandLine([]string{trayExe}) + k, _, err := registry.CreateKey( + runKeyRegistryHive, runKeyRegistryPath, registry.SET_VALUE, + ) + if err != nil { + return fmt.Errorf("opening Run registry key: %w", err) + } + defer k.Close() + if err := k.SetStringValue(runValueName, runCommand); err != nil { + return fmt.Errorf("writing Run registry value: %w", err) } + // 2. Record the engine command so startService knows what to launch. + if err := os.MkdirAll( + filepath.Dir(serviceCommandFile()), 0o755, + ); err != nil { + return fmt.Errorf("creating service state directory: %w", err) + } + if err := os.WriteFile(serviceCommandFile(), data, 0o600); err != nil { + return fmt.Errorf("writing service command file: %w", err) + } return nil } func unregisterService() error { - out, err := exec.Command( - "schtasks.exe", - "/Delete", - "/TN", taskName, - "/F", - ).CombinedOutput() - if err != nil { - return fmt.Errorf( - "deleting scheduled task: %s: %w", - strings.TrimSpace(string(out)), - err, - ) + // Best-effort stop of any running engine first. + _ = stopService() + + k, err := registry.OpenKey( + runKeyRegistryHive, runKeyRegistryPath, registry.SET_VALUE, + ) + switch { + case err == nil: + defer k.Close() + if derr := k.DeleteValue(runValueName); derr != nil && + !errors.Is(derr, registry.ErrNotExist) { + return fmt.Errorf("deleting Run registry value: %w", derr) + } + case errors.Is(err, registry.ErrNotExist): + // Key absent: nothing registered, treat as success. + default: + return fmt.Errorf("opening Run registry key: %w", err) } + if err := os.Remove(serviceCommandFile()); err != nil && + !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("removing service command file: %w", err) + } return nil } func serviceStatusCheck() (ServiceStatus, error) { - out, err := exec.Command( - "schtasks.exe", - "/Query", - "/TN", taskName, - "/FO", "CSV", - "/NH", - ).CombinedOutput() - if err != nil { - outStr := strings.ToLower(strings.TrimSpace(string(out))) - // schtasks returns exit code 1 when the task does not exist; - // treat only that case as "not registered" and propagate all - // other errors (access denied, I/O failures, etc.). - if strings.Contains(outStr, "does not exist") || - strings.Contains(outStr, "cannot find") { - return ServiceNotRegistered, nil + if pid, ok := readEnginePID(); ok { + expected, expectedErr := registeredEnginePath() + actual, alive, actualErr := runningProcessPath(pid) + if expectedErr == nil && actualErr == nil && + alive && sameExecutablePath(expected, actual) { + return ServiceRunning, nil } - return ServiceNotRegistered, fmt.Errorf( - "querying scheduled task: %s: %w", - strings.TrimSpace(string(out)), - err, + slog.Warn( + "ignoring stale engine pid", + "pid", pid, + "expected", expected, + "actual", actual, + "expected_error", expectedErr, + "actual_error", actualErr, ) + removeEnginePIDIf(pid) } - - if strings.Contains(string(out), "Running") { - return ServiceRunning, nil + if serviceRegistered() { + return ServiceRegistered, nil } - - return ServiceRegistered, nil + return ServiceNotRegistered, nil } +// startService (re)starts the engine: it terminates any engine we previously +// launched AND waits for it to exit (so a restart cannot leave two instances +// racing for the API port), then launches the recorded command detached and +// windowless with output redirected to the engine log. func startService() error { - out, err := exec.Command( - "schtasks.exe", "/Run", "/TN", taskName, - ).CombinedOutput() + command, err := registeredCommand() if err != nil { - return fmt.Errorf( - "starting scheduled task: %s: %w", - strings.TrimSpace(string(out)), - err, + return err + } + argv, err := windows.DecomposeCommandLine(command) + if err != nil { + return fmt.Errorf("parsing engine command %q: %w", command, err) + } + if len(argv) == 0 { + return errors.New("recorded engine command is empty") + } + + // Ensure the previous engine is fully gone before starting a new one. + if err := stopService(); err != nil { + return fmt.Errorf("stopping existing engine before start: %w", err) + } + + // Redirect the detached engine's output to a log file; failure to open it + // is non-fatal (the engine still runs). + var logHandle *os.File + if err := os.MkdirAll(filepath.Dir(engineLogFile()), 0o755); err == nil { + logHandle, _ = os.OpenFile( + engineLogFile(), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, + 0o600, ) } + if logHandle != nil { + defer logHandle.Close() + } + + cmd := exec.Command( + argv[0], + argv[1:]...) //nolint:gosec // argv from our own recorded command + // DETACHED_PROCESS: the engine is a console app; give it no console so it + // runs windowless and independent of the (windowsgui) tray. HideWindow + // guards any incidental GUI window. + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: windows.DETACHED_PROCESS, + } + if logHandle != nil { + cmd.Stdout = logHandle + cmd.Stderr = logHandle + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("starting adder engine: %w", err) + } + // Record the PID and detach: the engine outlives the tray. + if cmd.Process != nil { + if err := writeEnginePID(cmd.Process.Pid); err != nil { + if killErr := cmd.Process.Kill(); killErr != nil { + return fmt.Errorf( + "recording engine pid: %w (cleanup failed: %v)", + err, killErr, + ) + } + _, _ = cmd.Process.Wait() + return fmt.Errorf("recording engine pid: %w", err) + } + _ = cmd.Process.Release() + } return nil } +// stopService terminates the engine the tray launched (by recorded PID) and +// waits for it to exit. A missing PID file or already-exited process is a +// no-op success. func stopService() error { - out, err := exec.Command( - "schtasks.exe", "/End", "/TN", taskName, - ).CombinedOutput() + pid, ok := readEnginePID() + if !ok { + return terminateUntrackedEngines() + } + if err := stopTrackedEngine(pid, terminatePID); err != nil { + return err + } + return terminateUntrackedEngines() +} + +func stopTrackedEngine( + pid uint32, + terminate func(uint32, string) error, +) error { + if err := terminate(pid, expectedEngineImage()); err != nil { + return err + } + removeEnginePID() + return nil +} + +// registeredCommand returns the engine command recorded by registerService, or +// an error if the service is not registered. +func registeredCommand() (string, error) { + data, err := os.ReadFile(serviceCommandFile()) if err != nil { - return fmt.Errorf( - "stopping scheduled task: %s: %w", - strings.TrimSpace(string(out)), - err, + if errors.Is(err, os.ErrNotExist) { + return "", errors.New("adder service is not registered") + } + return "", fmt.Errorf("reading service command file: %w", err) + } + command := strings.TrimSpace(string(data)) + if command == "" { + return "", errors.New("recorded engine command is empty") + } + return command, nil +} + +// serviceRegistered reports whether the tray autostart and command mirror are +// both present and current. +func serviceRegistered() bool { + return existingUnit() != nil +} + +func writeEnginePID(pid int) error { + enginePIDMu.Lock() + defer enginePIDMu.Unlock() + if err := os.MkdirAll(filepath.Dir(enginePIDFile()), 0o755); err != nil { + return fmt.Errorf("creating engine pid directory: %w", err) + } + if err := os.WriteFile( + enginePIDFile(), []byte(strconv.Itoa(pid)), 0o600, + ); err != nil { + return fmt.Errorf("writing engine pid file: %w", err) + } + return nil +} + +func readEnginePID() (uint32, bool) { + data, err := os.ReadFile(enginePIDFile()) + if err != nil { + return 0, false + } + n, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil || n <= 0 { + return 0, false + } + return uint32(n), true +} + +func removeEnginePID() { + enginePIDMu.Lock() + defer enginePIDMu.Unlock() + removeEnginePIDUnlocked() +} + +func removeEnginePIDUnlocked() { + if err := os.Remove(enginePIDFile()); err != nil && + !errors.Is(err, os.ErrNotExist) { + slog.Warn("could not remove engine pid file", "error", err) + } +} + +func removeEnginePIDIf(pid uint32) { + enginePIDMu.Lock() + defer enginePIDMu.Unlock() + current, ok := readEnginePID() + if !ok || current != pid { + return + } + removeEnginePIDUnlocked() +} + +// terminatePID terminates a single process and waits (bounded) for it to exit. +// An already-gone process is success. +// +// If the process cannot be opened for termination but is still alive, we must +// distinguish two cases. Our own engine (same user, same session) is always +// openable for PROCESS_TERMINATE, so an ERROR_ACCESS_DENIED means the recorded +// PID is stale and has been reused by a protected or other-user process (e.g. +// after a hard reboot). We only treat an unopenable-but-alive PID as a survivor +// we must not stack on when we can positively confirm its running image is our +// engine (expectImage). Otherwise we proceed, so a reused stale PID cannot +// permanently block engine startup. +func terminatePID(pid uint32, expectImage string) error { + h, err := windows.OpenProcess( + windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pid, + ) + if err != nil { + if !processAlive(pid) { + return nil // already exited + } + actual, known := pidImageName(pid) + if isOurEngine(expectImage, actual, known) { + return fmt.Errorf( + "cannot terminate engine process %d (still running): %w", + pid, err) + } + slog.Warn( + "stale engine pid reused by another process; ignoring", + "pid", pid, "image", actual, "error", err, ) + return nil + } + defer func() { _ = windows.CloseHandle(h) }() + if terr := windows.TerminateProcess(h, 1); terr != nil { + return fmt.Errorf("terminating engine process %d: %w", pid, terr) + } + res, werr := windows.WaitForSingleObject( + h, uint32(stopWaitTimeout.Milliseconds()), + ) + if werr != nil { + return fmt.Errorf( + "waiting for engine process %d to exit: %w", pid, werr) + } + if res == uint32(windows.WAIT_TIMEOUT) { + return fmt.Errorf( + "engine process %d did not exit within %s", pid, stopWaitTimeout) } return nil } + +// processAlive reports whether pid refers to a live process. An access-denied +// on open means the process exists but is protected, so it counts as alive. +func processAlive(pid uint32) bool { + h, err := windows.OpenProcess( + windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid, + ) + if err != nil { + return errors.Is(err, windows.ERROR_ACCESS_DENIED) + } + defer func() { _ = windows.CloseHandle(h) }() + var code uint32 + if err := windows.GetExitCodeProcess(h, &code); err != nil { + return true + } + const stillActive = 259 // STILL_ACTIVE + return code == stillActive +} + +// isOurEngine reports whether an unopenable-but-alive PID should be treated as +// the engine we launched. It returns true only on positive confirmation that +// the running image (actual, present only when known) matches our recorded +// engine binary (expectImage). An unknown or mismatched image means the stale +// PID was reused by a foreign process, so the caller may safely proceed. +func isOurEngine(expectImage, actual string, known bool) bool { + if !known || expectImage == "" { + return false + } + return strings.EqualFold(expectImage, actual) +} + +// expectedEngineImage returns the base name of the engine executable recorded +// by registerService (e.g. "adder.exe"), or "" if it cannot be determined. +func expectedEngineImage() string { + path, err := registeredEnginePath() + if err != nil { + return "" + } + return filepath.Base(path) +} + +func registeredEnginePath() (string, error) { + command, err := registeredCommand() + if err != nil { + return "", err + } + argv, err := windows.DecomposeCommandLine(command) + if err != nil { + return "", fmt.Errorf("parsing engine command %q: %w", command, err) + } + if len(argv) == 0 || strings.TrimSpace(argv[0]) == "" { + return "", errors.New("recorded engine command has no executable") + } + return argv[0], nil +} + +func sameExecutablePath(expected, actual string) bool { + if strings.TrimSpace(expected) == "" || strings.TrimSpace(actual) == "" { + return false + } + return strings.EqualFold(filepath.Clean(expected), filepath.Clean(actual)) +} + +// terminateUntrackedEngines recovers from a missing PID file by terminating +// engine processes whose executable resides in the registered install +// directory. Full-path matching avoids touching an unrelated adder.exe. +func terminateUntrackedEngines() error { + command, err := registeredCommand() + if err != nil { + return nil + } + argv, err := windows.DecomposeCommandLine(command) + if err != nil || len(argv) == 0 { + return nil + } + installDir := filepath.Dir(argv[0]) + self := uint32(os.Getpid()) + return forEachProcess(func(pid uint32) error { + if pid == self { + return nil + } + path, err := fullProcessPath(pid) + if err != nil || !isUntrackedEnginePath(path, installDir) { + return nil + } + return terminatePID(pid, filepath.Base(path)) + }) +} + +func isUntrackedEnginePath(path, installDir string) bool { + return pathUnderDir(path, installDir) && + !strings.EqualFold(filepath.Base(path), "adder-tray.exe") +} + +func forEachProcess(fn func(uint32) error) error { + snap, err := windows.CreateToolhelp32Snapshot( + windows.TH32CS_SNAPPROCESS, 0, + ) + if err != nil { + return fmt.Errorf("snapshotting processes: %w", err) + } + defer func() { _ = windows.CloseHandle(snap) }() + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + for err = windows.Process32First(snap, &entry); err == nil; { + if visitErr := fn(entry.ProcessID); visitErr != nil { + return visitErr + } + err = windows.Process32Next(snap, &entry) + } + if errors.Is(err, windows.ERROR_NO_MORE_FILES) { + return nil + } + return err +} + +func fullProcessPath(pid uint32) (string, error) { + handle, err := windows.OpenProcess( + windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid, + ) + if err != nil { + return "", err + } + defer func() { _ = windows.CloseHandle(handle) }() + return processPath(handle) +} + +func runningProcessPath(pid uint32) (string, bool, error) { + handle, err := windows.OpenProcess( + windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid, + ) + if err != nil { + return "", false, err + } + defer func() { _ = windows.CloseHandle(handle) }() + path, err := processPath(handle) + if err != nil { + return "", false, err + } + var code uint32 + if err := windows.GetExitCodeProcess(handle, &code); err != nil { + return "", false, err + } + const stillActive = 259 // STILL_ACTIVE + return path, code == stillActive, nil +} + +func processPath(handle windows.Handle) (string, error) { + buf := make([]uint16, 1024) + size := uint32(len(buf)) + if err := windows.QueryFullProcessImageName( + handle, 0, &buf[0], &size, + ); err != nil { + return "", err + } + return windows.UTF16ToString(buf[:size]), nil +} + +func pathUnderDir(path, dir string) bool { + path = strings.ToLower(filepath.Clean(path)) + dir = strings.ToLower(filepath.Clean(dir)) + return strings.HasPrefix(path, strings.TrimRight(dir, `\/`)+`\`) +} + +// pidImageName returns the executable base name of pid via a process snapshot. +// Unlike OpenProcess, a snapshot does not require any access right on the +// target process, so it can name protected or other-user processes that the +// terminate path would be denied. The bool reports whether pid was found. +func pidImageName(pid uint32) (string, bool) { + snap, err := windows.CreateToolhelp32Snapshot( + windows.TH32CS_SNAPPROCESS, 0, + ) + if err != nil { + return "", false + } + defer func() { _ = windows.CloseHandle(snap) }() + var e windows.ProcessEntry32 + e.Size = uint32(unsafe.Sizeof(e)) + for err = windows.Process32First(snap, &e); err == nil; { + if e.ProcessID == pid { + return windows.UTF16ToString(e.ExeFile[:]), true + } + err = windows.Process32Next(snap, &e) + } + return "", false +} diff --git a/tray/setup/service_windows_test.go b/tray/setup/service_windows_test.go new file mode 100644 index 00000000..37fe8366 --- /dev/null +++ b/tray/setup/service_windows_test.go @@ -0,0 +1,400 @@ +// Copyright 2026 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package setup + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows/registry" +) + +// TestIsOurEngine covers the decision that gates whether an +// unopenable-but-alive PID blocks startup. A stale PID reused by a +// protected/foreign process (image unknown or mismatched) must NOT be treated +// as our engine, so startService can recover instead of deadlocking on a +// "cannot terminate" error. +func TestIsOurEngine(t *testing.T) { + tests := []struct { + name string + expectImage string + actual string + known bool + want bool + }{ + { + name: "match is our engine", + expectImage: "adder.exe", + actual: "adder.exe", + known: true, + want: true, + }, + { + name: "case-insensitive match", + expectImage: "adder.exe", + actual: "ADDER.EXE", + known: true, + want: true, + }, + { + name: "stale pid reused by foreign process", + expectImage: "adder.exe", + actual: "svchost.exe", + known: true, + want: false, + }, + { + name: "image not found in snapshot", + expectImage: "adder.exe", + actual: "", + known: false, + want: false, + }, + { + name: "expected image unknown", + expectImage: "", + actual: "adder.exe", + known: true, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isOurEngine(tt.expectImage, tt.actual, tt.known) + if got != tt.want { + t.Fatalf( + "isOurEngine(%q, %q, %v) = %v, want %v", + tt.expectImage, tt.actual, tt.known, got, tt.want, + ) + } + }) + } +} + +func TestSameExecutablePath(t *testing.T) { + tests := []struct { + name string + expected string + actual string + want bool + }{ + { + name: "exact path", + expected: `C:\Program Files\Adder\adder.exe`, + actual: `C:\Program Files\Adder\adder.exe`, + want: true, + }, + { + name: "case insensitive", + expected: `C:\Program Files\Adder\adder.exe`, + actual: `c:\PROGRAM FILES\ADDER\ADDER.EXE`, + want: true, + }, + { + name: "cleaned equivalent path", + expected: `C:\Program Files\Adder\bin\..\adder.exe`, + actual: `C:\Program Files\Adder\adder.exe`, + want: true, + }, + { + name: "same image in another directory", + expected: `C:\Program Files\Adder\adder.exe`, + actual: `C:\Other\adder.exe`, + want: false, + }, + { + name: "missing expected path", + expected: "", + actual: `C:\Program Files\Adder\adder.exe`, + want: false, + }, + { + name: "missing actual path", + expected: `C:\Program Files\Adder\adder.exe`, + actual: "", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sameExecutablePath(tt.expected, tt.actual); got != tt.want { + t.Fatalf( + "sameExecutablePath(%q, %q) = %v, want %v", + tt.expected, tt.actual, got, tt.want, + ) + } + }) + } +} + +func TestServiceStatusValidatesTrackedProcessPath(t *testing.T) { + tempPath := `Software\BlinkLabs\TestAdderStatus_` + t.Name() + _ = registry.DeleteKey(registry.CURRENT_USER, tempPath) + defer func() { _ = registry.DeleteKey(registry.CURRENT_USER, tempPath) }() + + oldHive := runKeyRegistryHive + oldPath := runKeyRegistryPath + runKeyRegistryHive = registry.CURRENT_USER + runKeyRegistryPath = tempPath + defer func() { + runKeyRegistryHive = oldHive + runKeyRegistryPath = oldPath + }() + + tmpDir := t.TempDir() + t.Setenv("ADDER_TRAY_CONFIG_DIR", filepath.Join(tmpDir, "config")) + t.Setenv("ADDER_TRAY_LOG_DIR", filepath.Join(tmpDir, "logs")) + + pid := uint32(os.Getpid()) + actualPath, err := fullProcessPath(pid) + if err != nil { + t.Fatalf("querying current process path: %v", err) + } + if !processAlive(pid) { + t.Fatal("current test process should be alive") + } + if err := writeEnginePID(int(pid)); err != nil { + t.Fatalf("writing live unregistered pid: %v", err) + } + status, err := serviceStatusCheck() + if err != nil { + t.Fatalf("checking unregistered pid status: %v", err) + } + if status != ServiceNotRegistered { + t.Fatalf("unregistered pid status = %s, want not registered", status) + } + if _, err := os.Stat(enginePIDFile()); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unverified pid file was not removed: %v", err) + } + + foreignPath := filepath.Join( + tmpDir, "other-install", filepath.Base(actualPath), + ) + if filepath.Base(foreignPath) != filepath.Base(actualPath) { + t.Fatal("test setup must use the same executable image name") + } + if sameExecutablePath(foreignPath, actualPath) { + t.Fatal("test setup must use different full executable paths") + } + + if err := registerService(ServiceConfig{BinaryPath: foreignPath}); err != nil { + t.Fatalf("registering foreign-path engine command: %v", err) + } + if err := writeEnginePID(int(pid)); err != nil { + t.Fatalf("writing live foreign pid: %v", err) + } + + status, err = serviceStatusCheck() + if err != nil { + t.Fatalf("checking reused pid status: %v", err) + } + if status != ServiceRegistered { + t.Fatalf("reused pid status = %s, want registered", status) + } + if _, err := os.Stat(enginePIDFile()); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stale pid file was not removed: %v", err) + } + if !processAlive(pid) { + t.Fatal("status check terminated the unrelated test process") + } + + if err := registerService(ServiceConfig{BinaryPath: actualPath}); err != nil { + t.Fatalf("registering matching engine command: %v", err) + } + if err := writeEnginePID(int(pid)); err != nil { + t.Fatalf("writing matching engine pid: %v", err) + } + + status, err = serviceStatusCheck() + if err != nil { + t.Fatalf("checking matching pid status: %v", err) + } + if status != ServiceRunning { + t.Fatalf("matching pid status = %s, want running", status) + } +} + +func TestWriteEnginePIDReturnsError(t *testing.T) { + blocked := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ADDER_TRAY_CONFIG_DIR", blocked) + + if err := writeEnginePID(42); err == nil { + t.Fatal("expected PID write failure") + } +} + +func TestRemoveEnginePIDIfPreservesReplacement(t *testing.T) { + t.Setenv("ADDER_TRAY_CONFIG_DIR", t.TempDir()) + if err := writeEnginePID(84); err != nil { + t.Fatal(err) + } + + removeEnginePIDIf(42) + + pid, ok := readEnginePID() + if !ok || pid != 84 { + t.Fatalf("replacement pid = %d, %v; want 84, true", pid, ok) + } + removeEnginePIDIf(84) + if _, err := os.Stat(enginePIDFile()); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("matching stale pid file was not removed: %v", err) + } +} + +func TestStopTrackedEngineRetainsPIDOnFailure(t *testing.T) { + configDir := t.TempDir() + t.Setenv("ADDER_TRAY_CONFIG_DIR", configDir) + if err := writeEnginePID(42); err != nil { + t.Fatal(err) + } + wantErr := errors.New("termination failed") + err := stopTrackedEngine(42, func(uint32, string) error { + return wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("got %v, want %v", err, wantErr) + } + if _, err := os.Stat(enginePIDFile()); err != nil { + t.Fatalf("PID file was removed after failed termination: %v", err) + } +} + +func TestIsUntrackedEnginePath(t *testing.T) { + installDir := `C:\Program Files\Adder` + if !isUntrackedEnginePath( + `C:\Program Files\Adder\adder.exe`, installDir, + ) { + t.Fatal("engine under install directory should be recovered") + } + if isUntrackedEnginePath( + `C:\Program Files\Adder\adder-tray.exe`, installDir, + ) { + t.Fatal("tray process must not be terminated") + } + if isUntrackedEnginePath(`C:\Other\adder.exe`, installDir) { + t.Fatal("unrelated engine path must not be terminated") + } +} + +// TestWindowsRegistryRegistrationState verifies that existingUnit on Windows +// integrates both the tray's HKCU Run autostart key and the engine command +// mirror file. If either is missing or stale, it triggers a repair. +func TestWindowsRegistryRegistrationState(t *testing.T) { + // Redirect registry access to a temporary HKCU path so the test does not + // modify the user's real Run key. + tempPath := `Software\BlinkLabs\TestAdderRegistry_` + t.Name() + + // Ensure we start with a clean slate + _ = registry.DeleteKey(registry.CURRENT_USER, tempPath) + defer func() { _ = registry.DeleteKey(registry.CURRENT_USER, tempPath) }() + + // Back up and restore registry package variables + oldHive := runKeyRegistryHive + oldPath := runKeyRegistryPath + runKeyRegistryHive = registry.CURRENT_USER + runKeyRegistryPath = tempPath + defer func() { + runKeyRegistryHive = oldHive + runKeyRegistryPath = oldPath + }() + + // Create the temporary key so Opens will succeed + k, _, err := registry.CreateKey( + registry.CURRENT_USER, + tempPath, + registry.SET_VALUE, + ) + if err != nil { + t.Fatalf("failed to create temporary registry key: %v", err) + } + k.Close() + + // Redirect home directory and other paths to temp dir for test isolation + tmpDir := t.TempDir() + t.Setenv("ADDER_TRAY_LOG_DIR", filepath.Join(tmpDir, "logs")) + t.Setenv("ADDER_TRAY_CONFIG_DIR", filepath.Join(tmpDir, "config")) + + // Initialize setup variables + cfg := ServiceConfig{ + BinaryPath: filepath.Join(tmpDir, "adder.exe"), + ConfigPath: filepath.Join(tmpDir, "config.yaml"), + LogDir: filepath.Join(tmpDir, "logs"), + } + + desiredUnit, err := renderUnit(cfg) + if err != nil { + t.Fatalf("renderUnit failed: %v", err) + } + + // 1. Run value missing -> existingUnit() returns nil (repair registration) + t.Run("missing run value", func(t *testing.T) { + got := existingUnit() + if got != nil { + t.Fatalf( + "expected nil existingUnit when Run key is missing, got %s", + got, + ) + } + }) + + // 2. Perform a registerService + if err := registerService(cfg); err != nil { + t.Fatalf("registerService failed: %v", err) + } + + // 3. Run value correct -> existingUnit() returns mirror command bytes + t.Run("correct run value", func(t *testing.T) { + got := existingUnit() + if got == nil { + t.Fatal("expected non-nil existingUnit when Run key is correct") + } + if !bytes.Equal(got, desiredUnit) { + t.Fatalf("expected existingUnit %s, got %s", desiredUnit, got) + } + }) + + // 4. Run value points elsewhere -> existingUnit() returns nil (repair) + t.Run("stale run value pointing elsewhere", func(t *testing.T) { + k, err := registry.OpenKey( + registry.CURRENT_USER, + tempPath, + registry.SET_VALUE, + ) + if err != nil { + t.Fatalf("failed to open temp key: %v", err) + } + defer k.Close() + err = k.SetStringValue(runValueName, `C:\Windows\System32\cmd.exe`) + if err != nil { + t.Fatalf("failed to set fake Run value: %v", err) + } + + got := existingUnit() + if got != nil { + t.Fatalf( + "expected nil existingUnit when Run key points elsewhere, got %s", + got, + ) + } + }) +} diff --git a/tray/setup/store.go b/tray/setup/store.go index b5b45d8c..42697be8 100644 --- a/tray/setup/store.go +++ b/tray/setup/store.go @@ -26,8 +26,8 @@ import ( // TrayConfig holds the configuration for the adder-tray application. // Filter is the authoritative source of monitoring targets — the -// sidecar engine config carries no per-target lists, because the -// cardano filter would AND-combine them on transaction events. +// sidecar engine config carries no per-target lists because the tray +// notification engine owns target matching semantics. type TrayConfig struct { APIAddress string `yaml:"api_address"` APIPort uint `yaml:"api_port"` diff --git a/tray/status.go b/tray/status.go index 0e1dfa85..3bbc3b43 100644 --- a/tray/status.go +++ b/tray/status.go @@ -48,19 +48,57 @@ func (s Status) String() string { } } +// statusQueueSize bounds the pending-transition buffer. Transitions are +// infrequent (a handful per apply/reconnect) but the observer can sleep +// between deliveries, so buffer generously to keep Set non-blocking. +const statusQueueSize = 64 + // StatusTracker provides thread-safe status tracking with change // notification. It notifies an optional observer when the status -// changes. +// changes. Transitions are delivered to the observer by a single +// long-lived worker goroutine, so the observer always sees them in the +// order they occurred (rapid flips can no longer race). type StatusTracker struct { mu sync.RWMutex status Status observer func(Status) + queue chan Status } // NewStatusTracker creates a StatusTracker with initial status -// StatusStopped. +// StatusStopped and starts its ordered-delivery worker. func NewStatusTracker() *StatusTracker { - return &StatusTracker{} + t := &StatusTracker{queue: make(chan Status, statusQueueSize)} + go t.deliverLoop() + return t +} + +// deliverLoop drains queued transitions in order, invoking the current +// observer for each. One goroutine owns delivery, so observer calls are +// serialized and ordered. +func (t *StatusTracker) deliverLoop() { + for s := range t.queue { + t.mu.RLock() + obs := t.observer + t.mu.RUnlock() + if obs != nil { + t.invoke(obs, s) + } + } +} + +// invoke calls the observer with panic recovery so a misbehaving +// observer cannot kill the delivery worker. +func (t *StatusTracker) invoke(obs func(Status), s Status) { + defer func() { + if r := recover(); r != nil { + slog.Error("status observer panicked", + "status", s, + "panic", r, + ) + } + }() + obs(s) } // Status returns the current status. @@ -70,8 +108,8 @@ func (t *StatusTracker) Status() Status { return t.status } -// Set updates the status and notifies the observer asynchronously if -// the status changed. +// Set updates the status and enqueues an ordered observer notification +// if the status changed. func (t *StatusTracker) Set(s Status) { t.mu.Lock() if t.status == s { @@ -79,22 +117,11 @@ func (t *StatusTracker) Set(s Status) { return } t.status = s - obs := t.observer t.mu.Unlock() - if obs != nil { - go func() { - defer func() { - if r := recover(); r != nil { - slog.Error("status observer panicked", - "status", s, - "panic", r, - ) - } - }() - obs(s) - }() - } + // Enqueue for ordered delivery. Buffered so this stays non-blocking + // for realistic transition rates. + t.queue <- s } // OnChange registers a callback that is invoked whenever the status @@ -107,14 +134,6 @@ func (t *StatusTracker) OnChange(fn func(Status)) { t.mu.Unlock() if fn != nil { - defer func() { - if r := recover(); r != nil { - slog.Error("status observer panicked on initial call", - "status", s, - "panic", r, - ) - } - }() - fn(s) + t.invoke(fn, s) } } diff --git a/tray/status_test.go b/tray/status_test.go index 27c26835..17c2be3e 100644 --- a/tray/status_test.go +++ b/tray/status_test.go @@ -156,6 +156,51 @@ func TestStatusTracker_ConcurrentAccess(t *testing.T) { assert.Equal(t, StatusConnected, tracker.Status()) } +func TestStatusTracker_OrderedDelivery(t *testing.T) { + tracker := NewStatusTracker() + + var mu sync.Mutex + var got []Status + const n = 5 + done := make(chan struct{}, n+1) + tracker.OnChange(func(s Status) { + mu.Lock() + got = append(got, s) + mu.Unlock() + done <- struct{}{} + }) + + // A rapid burst of alternating transitions. With per-transition + // goroutines these could arrive out of order; the single ordered + // worker must deliver them exactly as sent. + sent := []Status{ + StatusStarting, + StatusConnected, + StatusReconnecting, + StatusConnected, + StatusStopped, + } + for _, s := range sent { + tracker.Set(s) + } + + // n from Set + 1 immediate from OnChange. + for i := 0; i < n+1; i++ { + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for ordered delivery") + } + } + + mu.Lock() + defer mu.Unlock() + // First delivery is the synchronous OnChange call (StatusStopped), + // followed by the burst in exact send order. + want := append([]Status{StatusStopped}, sent...) + assert.Equal(t, want, got) +} + func TestStatus_String(t *testing.T) { tests := []struct { status Status diff --git a/tray/wizard/filter_logic.go b/tray/wizard/filter_logic.go new file mode 100644 index 00000000..1b5feba3 --- /dev/null +++ b/tray/wizard/filter_logic.go @@ -0,0 +1,66 @@ +// Copyright 2026 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + "github.com/blinklabs-io/adder/tray/setup" +) + +const ( + filterLogicDescription = "Within each group, any saved item can match. Use " + + "the controls between groups to require AND or allow OR." + connectorAndLabel = "AND" + connectorOrLabel = "OR" +) + +func newFilterLogicLabel(text string) *widget.Label { + label := widget.NewLabel(text) + label.Wrapping = fyne.TextWrapWord + return label +} + +func newTargetConnector( + mode setup.AdvancedMatchMode, + onChanged func(string), +) *widget.RadioGroup { + connector := widget.NewRadioGroup( + []string{connectorAndLabel, connectorOrLabel}, nil, + ) + connector.Horizontal = true + selected := connectorOrLabel + if mode == setup.AdvancedMatchAll { + selected = connectorAndLabel + } + connector.SetSelected(selected) + connector.OnChanged = onChanged + return connector +} + +func connectorMode(connector *widget.RadioGroup) setup.AdvancedMatchMode { + if connector != nil && connector.Selected == connectorAndLabel { + return setup.AdvancedMatchAll + } + return setup.AdvancedMatchAny +} + +func connectorRow(connector *widget.RadioGroup) fyne.CanvasObject { + return container.NewHBox( + widget.NewLabel("Combine with next group:"), + connector, + ) +} diff --git a/tray/wizard/rules_editor.go b/tray/wizard/rules_editor.go index bbf54fce..702d615d 100644 --- a/tray/wizard/rules_editor.go +++ b/tray/wizard/rules_editor.go @@ -46,6 +46,10 @@ type RulesEditor struct { cancel context.CancelFunc everythingCheck *widget.Check + drepConnector *widget.RadioGroup + poolConnector *widget.RadioGroup + assetConnector *widget.RadioGroup + policyConnector *widget.RadioGroup targetsBox *fyne.Container sections []*targetSection wallets *targetSection @@ -89,7 +93,7 @@ func NewRulesEditor( } e.window = a.NewWindow("Adder Notification Rules") - e.window.Resize(fyne.NewSize(560, 640)) + e.window.Resize(fyne.NewSize(surfaceWidth, surfaceHeight)) e.window.SetOnClosed(cancel) // Block the native window-close affordance while apply is in // flight so the warning dialog's parent cannot vanish under it. @@ -135,6 +139,7 @@ func NewRulesEditor( fyne.TextAlignLeading, fyne.TextStyle{Bold: true}, ), + newFilterLogicLabel(filterLogicDescription), e.everythingCheck, e.targetsBox, widget.NewSeparator(), @@ -194,7 +199,7 @@ func (e *RulesEditor) buildTargetSections() { validate func(string) error, ) *targetSection { sec := newTargetSection(label, placeholder, validate, - e.snapshotFilter) + e.onTargetsChanged) sec.onDelete = e.confirmDelete(label) return sec } @@ -223,18 +228,22 @@ func (e *RulesEditor) buildTargetSections() { e.assets.setValues(e.working.Filter.Assets) e.policies.setValues(e.working.Filter.Policies) - e.targetsBox = container.NewVBox( - e.wallets.canvasObject(), - widget.NewSeparator(), - e.dreps.canvasObject(), - widget.NewSeparator(), - e.pools.canvasObject(), - widget.NewSeparator(), - e.assets.canvasObject(), - widget.NewSeparator(), - e.policies.canvasObject(), + e.targetsBox = container.NewVBox() + e.drepConnector = newTargetConnector( + e.working.Filter.ResolvedDRepMatch(), e.connectorChanged, + ) + e.poolConnector = newTargetConnector( + e.working.Filter.ResolvedPoolMatch(), e.connectorChanged, + ) + e.assetConnector = newTargetConnector( + e.working.Filter.ResolvedAssetMatch(), e.connectorChanged, + ) + e.policyConnector = newTargetConnector( + e.working.Filter.ResolvedPolicyMatch(), e.connectorChanged, ) + e.refreshTargetMode() + // Hydrate Checked + initial visibility BEFORE wiring OnChanged so // hydration cannot trigger a spurious user-action side effect. // Same pattern as the pref-checks below. @@ -255,6 +264,50 @@ func (e *RulesEditor) buildTargetSections() { } } +func (e *RulesEditor) connectorChanged(string) { + e.snapshotFilter() +} + +// onTargetsChanged fires after any section add/remove: snapshot the values +// back into the working filter, then rebuild the layout so connector +// visibility tracks which groups are populated. +func (e *RulesEditor) onTargetsChanged() { + e.snapshotFilter() + e.refreshTargetMode() +} + +func (e *RulesEditor) refreshTargetMode() { + e.targetsBox.RemoveAll() + // A group's connector renders only when that group AND some earlier + // group are populated; the connector joins the group to the previous + // populated one (see standardFilterMatcher) and is meaningless + // otherwise. Sections always render so they can accept input. + rows := []struct { + sec *targetSection + connector *widget.RadioGroup + }{ + {e.wallets, nil}, + {e.dreps, e.drepConnector}, + {e.pools, e.poolConnector}, + {e.assets, e.assetConnector}, + {e.policies, e.policyConnector}, + } + earlier := false + for i, r := range rows { + populated := len(r.sec.values) > 0 + if r.connector != nil && populated && earlier { + e.targetsBox.Add(connectorRow(r.connector)) + } + if i > 0 { + e.targetsBox.Add(widget.NewSeparator()) + } + e.targetsBox.Add(r.sec.canvasObject()) + if populated { + earlier = true + } + } +} + // buildPrefBox builds one widget.Check per pref in setup.AllNotifyPrefs. // Toggling a check flips that pref on the working plan; the engine // derives one rule per category per target so flipping a pref @@ -330,6 +383,10 @@ func (e *RulesEditor) snapshotFilter() { e.working.Filter.Policies = append( []string(nil), e.policies.values..., ) + e.working.Filter.DRepMatch = connectorMode(e.drepConnector) + e.working.Filter.PoolMatch = connectorMode(e.poolConnector) + e.working.Filter.AssetMatch = connectorMode(e.assetConnector) + e.working.Filter.PolicyMatch = connectorMode(e.policyConnector) } // onApply snapshots the working plan, freezes every mutating input @@ -339,6 +396,19 @@ func (e *RulesEditor) snapshotFilter() { // race-free). func (e *RulesEditor) onApply() { e.snapshotFilter() + // Block a never-matching filter from restarting Adder into silence. + if !e.working.Filter.MonitorEverything && + e.working.Filter.MatchesNothing() { + dialog.ShowInformation( + "Filter matches nothing", + "This AND combination can never match: it joins targets "+ + "from different event types (pools match blocks, "+ + "wallets/assets/policies match transactions, DReps "+ + "match governance). Use OR between them, or remove one.", + e.window, + ) + return + } e.applying = true e.setInputsEnabled(false) if e.callback != nil { @@ -360,6 +430,10 @@ func (e *RulesEditor) setInputsEnabled(enabled bool) { toggle(e.applyBtn) toggle(e.closeBtn) toggle(e.everythingCheck) + toggle(e.drepConnector) + toggle(e.poolConnector) + toggle(e.assetConnector) + toggle(e.policyConnector) for _, c := range e.prefChecks { toggle(c) } diff --git a/tray/wizard/rules_editor_test.go b/tray/wizard/rules_editor_test.go index ce00369d..fd32a22d 100644 --- a/tray/wizard/rules_editor_test.go +++ b/tray/wizard/rules_editor_test.go @@ -163,6 +163,22 @@ func TestRulesEditorAddTargetSnapshotsFilter(t *testing.T) { "pool1ws7gpqkw4wpdj33lf3hcjy9zk5pxr8htnnxkxepe49p5gp3srcg") } +func TestRulesEditorHydratesAndSnapshotsTargetConnectors(t *testing.T) { + test.NewApp() + plan := samplePlan() + plan.Filter.DRepMatch = setup.AdvancedMatchAll + plan.Filter.AssetMatch = setup.AdvancedMatchAll + + e := NewRulesEditor(plan, nil) + assert.Equal(t, connectorAndLabel, e.drepConnector.Selected) + assert.Equal(t, connectorOrLabel, e.poolConnector.Selected) + assert.Equal(t, connectorAndLabel, e.assetConnector.Selected) + + e.policyConnector.SetSelected(connectorAndLabel) + assert.Equal(t, setup.AdvancedMatchAll, + e.working.Filter.PolicyMatch) +} + func TestRulesEditorOnApplyDisablesButtonAndPassesWorking(t *testing.T) { test.NewApp() var gotPlan setup.SetupPlan @@ -411,6 +427,21 @@ func TestTargetSectionAddRejectsDuplicate(t *testing.T) { assert.Len(t, sec.values, 1) } +func TestTargetSectionExplainsAnyMatchAndUpdatesCount(t *testing.T) { + test.NewApp() + sec := newTargetSection("Wallets", "addr", + setup.ValidateWalletAddr, nil) + + assert.Equal(t, "Any wallet saved here can match.", sec.matchHint.Text) + assert.False(t, sec.countLabel.Visible()) + + sec.setValues([]string{"addr1first", "addr1second"}) + assert.Equal(t, "2 saved", sec.countLabel.Text) + + sec.removeValue("addr1first", sec.list.Objects[0]) + assert.Equal(t, "1 saved", sec.countLabel.Text) +} + // TestTargetSectionAddDedupIsCaseInsensitive guards that two hex IDs // differing only in case are recognised as the same on-chain identity. // PolicyID/PoolID/DRepID/AssetFingerprint round-trip through diff --git a/tray/wizard/step3_template.go b/tray/wizard/step3_template.go index 7a1822df..d6f134fc 100644 --- a/tray/wizard/step3_template.go +++ b/tray/wizard/step3_template.go @@ -23,7 +23,6 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/widget" "github.com/blinklabs-io/adder/tray/setup" ) @@ -37,13 +36,16 @@ type templateStep struct { plan *setup.SetupPlan everythingCheck *widget.Check + drepConnector *widget.RadioGroup + poolConnector *widget.RadioGroup + assetConnector *widget.RadioGroup + policyConnector *widget.RadioGroup targetsBox *fyne.Container wallets *targetSection dreps *targetSection pools *targetSection assets *targetSection policies *targetSection - advanced *widget.Accordion summaryLabel *widget.Label // Output destination (unchanged from the previous wizard). @@ -94,30 +96,29 @@ func (s *templateStep) Content() fyne.CanvasObject { s.summaryLabel = widget.NewLabel("") s.summaryLabel.TextStyle = fyne.TextStyle{Italic: true} - // Assets + policies are the power-user fields; keep them - // behind an accordion so the default view stays simple. - advancedBody := container.NewVBox( - s.assets.canvasObject(), - widget.NewSeparator(), - s.policies.canvasObject(), + filter := setup.FilterConfig{} + if s.plan != nil { + filter = s.plan.Filter + } + s.wallets.setValues(filter.Wallets) + s.dreps.setValues(filter.DReps) + s.pools.setValues(filter.Pools) + s.assets.setValues(filter.Assets) + s.policies.setValues(filter.Policies) + + s.targetsBox = container.NewVBox() + s.drepConnector = newTargetConnector( + filter.ResolvedDRepMatch(), func(string) { s.refreshSummary() }, ) - s.advanced = widget.NewAccordion( - widget.NewAccordionItem( - "Advanced — Assets & Policies", - advancedBody, - ), + s.poolConnector = newTargetConnector( + filter.ResolvedPoolMatch(), func(string) { s.refreshSummary() }, ) - - s.targetsBox = container.NewVBox( - s.wallets.canvasObject(), - widget.NewSeparator(), - s.dreps.canvasObject(), - widget.NewSeparator(), - s.pools.canvasObject(), - widget.NewSeparator(), - s.advanced, + s.assetConnector = newTargetConnector( + filter.ResolvedAssetMatch(), func(string) { s.refreshSummary() }, + ) + s.policyConnector = newTargetConnector( + filter.ResolvedPolicyMatch(), func(string) { s.refreshSummary() }, ) - s.everythingCheck = widget.NewCheck( "Monitor Everything (ignore per-target lists)", func(checked bool) { @@ -130,22 +131,12 @@ func (s *templateStep) Content() fyne.CanvasObject { }, ) - // Hydrate from the plan. if s.plan != nil { - s.wallets.setValues(s.plan.Filter.Wallets) - s.dreps.setValues(s.plan.Filter.DReps) - s.pools.setValues(s.plan.Filter.Pools) - s.assets.setValues(s.plan.Filter.Assets) - s.policies.setValues(s.plan.Filter.Policies) s.everythingCheck.SetChecked( s.plan.Filter.MonitorEverything, ) - // Auto-open Advanced when assets/policies are configured. - if len(s.plan.Filter.Assets) > 0 || - len(s.plan.Filter.Policies) > 0 { - s.advanced.Open(0) - } } + s.refreshTargetMode() s.refreshSummary() } @@ -159,6 +150,7 @@ func (s *templateStep) Content() fyne.CanvasObject { fyne.TextAlignLeading, fyne.TextStyle{Bold: true}, ), + newFilterLogicLabel(filterLogicDescription), s.everythingCheck, s.targetsBox, s.summaryLabel, @@ -180,9 +172,12 @@ func (s *templateStep) Content() fyne.CanvasObject { s.outputContainer, ) + // No Spacer between the boxes: the outer content scrolls (see + // wizard.updateStep), and in a squeezed box layout a Spacer takes + // negative height, overlapping outputBox onto monitorBox. outputBox + // already opens with a separator for visual division. return container.NewVBox( monitorBox, - layout.NewSpacer(), outputBox, ) } @@ -194,24 +189,76 @@ func (s *templateStep) newSection( validate func(string) error, ) *targetSection { return newTargetSection( - label, placeholder, validate, s.refreshSummary, + label, placeholder, validate, s.onTargetsChanged, ) } +// onTargetsChanged fires after any section add/remove: rebuild the target +// layout (so connector visibility tracks which groups are populated) and +// refresh the summary line. +func (s *templateStep) onTargetsChanged() { + s.refreshTargetMode() + s.refreshSummary() +} + +func (s *templateStep) refreshTargetMode() { + s.targetsBox.RemoveAll() + // Sections always render (they must accept input even when empty). A + // group's connector renders only when that group AND some earlier group + // are populated, since the connector joins the group to the previous + // populated one (see standardFilterMatcher) and is meaningless + // otherwise — this hides orphan connectors framing empty sections. + rows := []struct { + sec *targetSection + connector *widget.RadioGroup + }{ + {s.wallets, nil}, + {s.dreps, s.drepConnector}, + {s.pools, s.poolConnector}, + {s.assets, s.assetConnector}, + {s.policies, s.policyConnector}, + } + earlier := false + for i, r := range rows { + populated := len(r.sec.values) > 0 + if r.connector != nil && populated && earlier { + s.targetsBox.Add(connectorRow(r.connector)) + } + if i > 0 { + s.targetsBox.Add(widget.NewSeparator()) + } + s.targetsBox.Add(r.sec.canvasObject()) + if populated { + earlier = true + } + } +} + +func (s *templateStep) currentFilter() setup.FilterConfig { + filter := setup.FilterConfig{ + MonitorEverything: s.everythingCheck.Checked, + Wallets: append([]string(nil), s.wallets.values...), + DReps: append([]string(nil), s.dreps.values...), + Pools: append([]string(nil), s.pools.values...), + Assets: append([]string(nil), s.assets.values...), + Policies: append([]string(nil), s.policies.values...), + DRepMatch: connectorMode(s.drepConnector), + PoolMatch: connectorMode(s.poolConnector), + AssetMatch: connectorMode(s.assetConnector), + PolicyMatch: connectorMode(s.policyConnector), + } + if filter.MonitorEverything { + return setup.FilterConfig{MonitorEverything: true} + } + return filter +} + func (s *templateStep) refreshSummary() { - if s.everythingCheck != nil && s.everythingCheck.Checked { - s.summaryLabel.SetText("Current configuration: everything") + if s.summaryLabel == nil || s.everythingCheck == nil { return } - f := setup.FilterConfig{ - Wallets: s.wallets.values, - DReps: s.dreps.values, - Pools: s.pools.values, - Assets: s.assets.values, - Policies: s.policies.values, - } s.summaryLabel.SetText( - "Current configuration: " + setup.SummarizeFilter(f), + "Current configuration: " + setup.SummarizeFilter(s.currentFilter()), ) } @@ -305,15 +352,22 @@ func (s *templateStep) onOutputChange(selected string) { // sub-validations are unchanged from the previous step3. func (s *templateStep) Validate() error { if !s.everythingCheck.Checked { - if len(s.wallets.values) == 0 && - len(s.dreps.values) == 0 && - len(s.pools.values) == 0 && - len(s.assets.values) == 0 && - len(s.policies.values) == 0 { + filter := s.currentFilter() + hasTarget := len(filter.Wallets) > 0 || len(filter.DReps) > 0 || + len(filter.Pools) > 0 || len(filter.Assets) > 0 || + len(filter.Policies) > 0 + if !hasTarget { + return errors.New( + "add at least one wallet, DRep, pool, asset, or policy " + + "or enable Monitor Everything", + ) + } + if filter.MatchesNothing() { return errors.New( - "add at least one wallet, DRep, pool, " + - "asset, or policy — or enable " + - "Monitor Everything", + "this AND combination can never match: it joins targets " + + "from different event types (pools match blocks, " + + "wallets/assets/policies match transactions, DReps " + + "match governance) — use OR between them, or remove one", ) } } @@ -360,25 +414,7 @@ func (s *templateStep) Validate() error { } func (s *templateStep) Apply(plan *setup.SetupPlan) { - plan.Filter.MonitorEverything = s.everythingCheck.Checked - if s.everythingCheck.Checked { - plan.Filter.Wallets = nil - plan.Filter.DReps = nil - plan.Filter.Pools = nil - plan.Filter.Assets = nil - plan.Filter.Policies = nil - } else { - plan.Filter.Wallets = append( - []string(nil), s.wallets.values...) - plan.Filter.DReps = append( - []string(nil), s.dreps.values...) - plan.Filter.Pools = append( - []string(nil), s.pools.values...) - plan.Filter.Assets = append( - []string(nil), s.assets.values...) - plan.Filter.Policies = append( - []string(nil), s.policies.values...) - } + plan.Filter = s.currentFilter() plan.Output.Config = make(map[string]string) switch s.outputSelect.Selected { diff --git a/tray/wizard/step4_notifications.go b/tray/wizard/step4_notifications.go index e755c181..b7bd7ab2 100644 --- a/tray/wizard/step4_notifications.go +++ b/tray/wizard/step4_notifications.go @@ -128,9 +128,9 @@ func (s *notificationsStep) createLayout() fyne.CanvasObject { }), widget.NewButton("No, it didn't show", func() { s.verified = false - s.verifyResult.SetText("⚠️ Please check 'System Settings > " + - "Notifications > AdderTray' and ensure 'Allow " + - "Notifications' is enabled.") + s.verifyResult.SetText("⚠️ Check your system's notification " + + "settings and ensure notifications are enabled for " + + "AdderTray.") }), ) s.verifyBox.Hide() @@ -161,7 +161,8 @@ func (s *notificationsStep) createLayout() fyne.CanvasObject { fyne.TextStyle{Bold: true}, ), widget.NewLabel( - "macOS requires explicit permission for notifications.", + "Send a test notification to confirm alerts appear. "+ + "Grant permission if your system prompts you.", ), permBtn, s.verifyResult, diff --git a/tray/wizard/steps_test.go b/tray/wizard/steps_test.go index c8e86fdd..6dfbce29 100644 --- a/tray/wizard/steps_test.go +++ b/tray/wizard/steps_test.go @@ -260,7 +260,7 @@ func TestTemplateStepAddRemoveAndSummary(t *testing.T) { step.Content() // Initially: nothing configured, MonitorEverything off. - assert.Equal(t, "Current configuration: nothing configured", + assert.Equal(t, "Current configuration: No monitoring targets configured", step.summaryLabel.Text) // Add two wallets, one DRep, one pool. @@ -274,27 +274,30 @@ func TestTemplateStepAddRemoveAndSummary(t *testing.T) { step.pools.add(step.pools.entry.Text) assert.Equal(t, - "Current configuration: 2 wallets, 1 DRep, 1 pool", + "Current configuration: Standard: "+ + "2 wallets OR 1 DRep OR 1 pool", step.summaryLabel.Text) // Remove one wallet via the section's bookkeeping. step.wallets.removeValue("stake1b", step.wallets.list.Objects[1]) assert.Equal(t, []string{"addr1a"}, step.wallets.values) assert.Equal(t, - "Current configuration: 1 wallet, 1 DRep, 1 pool", + "Current configuration: Standard: "+ + "1 wallet OR 1 DRep OR 1 pool", step.summaryLabel.Text) // Toggling MonitorEverything on hides the summary detail; the // section values are preserved so toggling off restores them. step.everythingCheck.SetChecked(true) - assert.Equal(t, "Current configuration: everything", + assert.Equal(t, "Current configuration: Monitor everything", step.summaryLabel.Text) assert.Equal(t, []string{"addr1a"}, step.wallets.values, "toggling Monitor Everything must preserve the lists") step.everythingCheck.SetChecked(false) assert.Equal(t, - "Current configuration: 1 wallet, 1 DRep, 1 pool", + "Current configuration: Standard: "+ + "1 wallet OR 1 DRep OR 1 pool", step.summaryLabel.Text) // Apply with MonitorEverything off persists the lists; Apply with @@ -314,6 +317,28 @@ func TestTemplateStepAddRemoveAndSummary(t *testing.T) { assert.Empty(t, got.Filter.Pools) } +func TestTemplateStepAppliesTargetConnectors(t *testing.T) { + test.NewApp() + step := &templateStep{ + plan: &setup.SetupPlan{ + Filter: setup.FilterConfig{ + Wallets: []string{"addr1abc"}, + DReps: []string{"drep1abc"}, + DRepMatch: setup.AdvancedMatchAll, + }, + Output: setup.OutputConfig{Config: map[string]string{}}, + }, + } + step.Content() + + assert.Equal(t, connectorAndLabel, step.drepConnector.Selected) + step.poolConnector.SetSelected(connectorAndLabel) + got := &setup.SetupPlan{} + step.Apply(got) + assert.Equal(t, setup.AdvancedMatchAll, got.Filter.DRepMatch) + assert.Equal(t, setup.AdvancedMatchAll, got.Filter.PoolMatch) +} + // TestTemplateStepAddSplitsCSV is the regression guard for the silent // under-notification bug where a user pastes a comma-separated list // (the pre-rewrite wizard's documented format) into a section. The @@ -689,7 +714,7 @@ func TestNotificationsStepVerificationButtons(t *testing.T) { noButton := step.verifyBox.Objects[1].(*widget.Button) noButton.OnTapped() assert.False(t, step.verified) - assert.Contains(t, step.verifyResult.Text, "Please check") + assert.Contains(t, step.verifyResult.Text, "notification settings") yesButton := step.verifyBox.Objects[0].(*widget.Button) yesButton.OnTapped() diff --git a/tray/wizard/target_section.go b/tray/wizard/target_section.go index 8a347446..10b6f942 100644 --- a/tray/wizard/target_section.go +++ b/tray/wizard/target_section.go @@ -15,6 +15,7 @@ package wizard import ( + "fmt" "strings" "fyne.io/fyne/v2" @@ -33,12 +34,14 @@ type targetSection struct { placeholder string validate func(string) error // ValidateWalletAddr etc. - entry *widget.Entry - addBtn *widget.Button - errLabel *widget.Label - list *fyne.Container // VBox of entry rows - values []string - rowBtns []*widget.Button // trash buttons parallel to values + entry *widget.Entry + addBtn *widget.Button + errLabel *widget.Label + matchHint *widget.Label + countLabel *widget.Label + list *fyne.Container // VBox of entry rows + values []string + rowBtns []*widget.Button // trash buttons parallel to values // onChange is invoked after every add/remove so the parent surface // can update derived UI (summary line, rule list rebuild). @@ -72,6 +75,12 @@ func newTargetSection( sec.errLabel = widget.NewLabel("") sec.errLabel.Wrapping = fyne.TextWrapWord sec.errLabel.Hide() + sec.matchHint = widget.NewLabel( + "Any " + targetNoun(label) + " saved here can match.", + ) + sec.matchHint.Importance = widget.LowImportance + sec.countLabel = widget.NewLabel("") + sec.countLabel.Importance = widget.LowImportance sec.list = container.NewVBox() sec.addBtn = widget.NewButtonWithIcon( "Add", @@ -79,9 +88,36 @@ func newTargetSection( func() { sec.add(sec.entry.Text) }, ) sec.entry.OnSubmitted = func(string) { sec.add(sec.entry.Text) } + sec.refreshCount() return sec } +func targetNoun(label string) string { + switch label { + case "Wallets": + return "wallet" + case "DReps": + return "DRep" + case "Pools": + return "pool" + case "Assets": + return "asset" + case "Policies": + return "policy" + default: + return "item" + } +} + +func (sec *targetSection) refreshCount() { + if len(sec.values) == 0 { + sec.countLabel.Hide() + return + } + sec.countLabel.SetText(fmt.Sprintf("%d saved", len(sec.values))) + sec.countLabel.Show() +} + // add validates v (splitting on commas first so pasted CSV produces // multiple rows) and appends each piece. All-or-nothing: if any piece // fails validation, nothing is added and the input is preserved. @@ -133,6 +169,7 @@ func (sec *targetSection) add(v string) { for _, p := range parts { sec.appendRow(p) } + sec.refreshCount() sec.entry.SetText("") if sec.onChange != nil { sec.onChange() @@ -197,6 +234,7 @@ func (sec *targetSection) removeValue(v string, row fyne.CanvasObject) { } } sec.list.Remove(row) + sec.refreshCount() if sec.onChange != nil { sec.onChange() } @@ -218,6 +256,7 @@ func (sec *targetSection) setValues(vs []string) { seen[k] = struct{}{} sec.appendRow(v) } + sec.refreshCount() } // setEnabled toggles every input the user can interact with on this @@ -242,10 +281,14 @@ func (sec *targetSection) setEnabled(enabled bool) { func (sec *targetSection) canvasObject() fyne.CanvasObject { return container.NewVBox( - widget.NewLabelWithStyle( - sec.label, fyne.TextAlignLeading, - fyne.TextStyle{Bold: true}, + container.NewBorder( + nil, nil, nil, sec.countLabel, + widget.NewLabelWithStyle( + sec.label, fyne.TextAlignLeading, + fyne.TextStyle{Bold: true}, + ), ), + sec.matchHint, container.NewBorder( nil, nil, nil, sec.addBtn, sec.entry, ), diff --git a/tray/wizard/wizard.go b/tray/wizard/wizard.go index 1503f153..84637951 100644 --- a/tray/wizard/wizard.go +++ b/tray/wizard/wizard.go @@ -28,6 +28,17 @@ import ( "github.com/blinklabs-io/adder/tray/setup" ) +const ( + // surfaceWidth/surfaceHeight are the default window size for the + // setup surfaces (wizard + RulesEditor, which share the same target + // sections). Wide enough for the entry+Add rows and word-wrapped + // help text without hugging the edge; tall enough to show early + // steps without scrolling. Content scrolls beyond this — the window + // is deliberately not fixed-size (see NewWizard). + surfaceWidth = 1024 + surfaceHeight = 800 +) + // Step defines the interface that each wizard step must implement. type Step interface { // Title returns the title of the step shown in the header. @@ -68,9 +79,11 @@ func (w *WizardController) updateStep() { w.titleLabel.SetText(step.Title()) w.descLabel.SetText(step.Description()) - // Update content + // Update content. Wrap in a vertical scroll so tall steps (e.g. + // "Events & Outputs") scroll instead of forcing the window to grow + // or clipping the Back/Next footer. Matches RulesEditor. w.stepContent.Objects = []fyne.CanvasObject{ - container.NewPadded(step.Content()), + container.NewVScroll(container.NewPadded(step.Content())), } w.stepContent.Refresh() @@ -187,8 +200,10 @@ func NewWizard( } w.window = w.app.NewWindow("Adder Setup") - w.window.Resize(fyne.NewSize(500, 560)) - w.window.SetFixedSize(true) + // Constant, resizable window; step content scrolls (see updateStep). + // Not SetFixedSize: a fixed window is content-min-driven, so it + // balloons for tall steps and jumps size when target chips are added. + w.window.Resize(fyne.NewSize(surfaceWidth, surfaceHeight)) w.window.SetOnClosed(w.cancel) w.steps = []Step{ diff --git a/tray/wizard/wizard_capture_test.go b/tray/wizard/wizard_capture_test.go new file mode 100644 index 00000000..faf5a9ce --- /dev/null +++ b/tray/wizard/wizard_capture_test.go @@ -0,0 +1,117 @@ +// Copyright 2026 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "fmt" + "image/png" + "os" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/test" + "github.com/blinklabs-io/adder/tray/setup" + "github.com/stretchr/testify/require" +) + +// TestCaptureSteps renders every wizard step at its natural full height +// (no scroll clipping, no fixed header to composite over) and writes one +// PNG per step for manual/visual inspection. Dev-only: skipped unless +// WIZARD_CAPTURE=1 so it never runs in CI. Output dir defaults to /tmp +// (override with WIZARD_CAPTURE_DIR); files are wizard_step1.png ... N. +func TestCaptureSteps(t *testing.T) { + if os.Getenv("WIZARD_CAPTURE") != "1" { + t.Skip("set WIZARD_CAPTURE=1 to render wizard steps to PNGs") + } + + test.NewApp() + w := NewWizard(nil, nil) + + // Populate step 3's target sections so its chips are visible in the + // capture (matches the reported repro). + s3 := w.steps[2].(*templateStep) + s3.Content() + s3.wallets.add( + "addr1q9hlrf6lmtgu7mqupwlysw7qcvexmjkmwfynqlfh33dz87xy3y67g6" + + "0shkajwfsewt2tjs85a3xkpkmcafpwwzpevlcsmwzj82", + ) + s3.dreps.add("drep1y2cvruq6syfa4w7uksw9jur9q06lwlc60p9kjcxnxd9ww7gh8gtt0") + s3.pools.add("pool1ws7gpqkw4wpdj33lf3hcjy9zk5pxr8htnnxkxepe49p5gp3srcg") + + dir := os.Getenv("WIZARD_CAPTURE_DIR") + if dir == "" { + dir = "/tmp" + } + + for i, step := range w.steps { + content := container.NewPadded(step.Content()) + + // Height = the taller of the default window and the step's own + // natural height, so nothing is clipped for tall steps. + h := content.MinSize().Height + if h < surfaceHeight { + h = surfaceHeight + } + + win := test.NewWindow(content) + win.Resize(fyne.NewSize(surfaceWidth, h)) + win.Content().Refresh() + + path := fmt.Sprintf("%s/wizard_step%d.png", dir, i+1) + f, err := os.Create(path) + require.NoError(t, err) + require.NoError(t, png.Encode(f, win.Canvas().Capture())) + require.NoError(t, f.Close()) + win.Close() + t.Logf("wrote %s (%s)", path, step.Title()) + } + + captureEditor := func(name, title string, plan setup.SetupPlan) { + editor := NewRulesEditor(plan, nil) + editor.window.Resize(fyne.NewSize(surfaceWidth, surfaceHeight)) + editor.window.Content().Refresh() + path := fmt.Sprintf("%s/%s", dir, name) + f, err := os.Create(path) + require.NoError(t, err) + require.NoError(t, png.Encode(f, editor.window.Canvas().Capture())) + require.NoError(t, f.Close()) + editor.Close() + t.Logf("wrote %s (%s)", path, title) + } + + captureEditor("rules_editor_standard.png", "Standard notification rules", + setup.SetupPlan{ + Filter: setup.FilterConfig{ + DRepMatch: setup.AdvancedMatchAll, + PoolMatch: setup.AdvancedMatchAny, + AssetMatch: setup.AdvancedMatchAll, + PolicyMatch: setup.AdvancedMatchAll, + Wallets: []string{ + "addr1q9hlrf6lmtgu7mqupwlysw7qcvexmjkmwfynqlfh33dz87xy3y67g" + + "60shkajwfsewt2tjs85a3xkpkmcafpwwzpevlcsmwzj82", + "stake1u9ylzsgvtepmc7g7ecm4sskn5f8t8k4r5xs6z4t70w4n25g4n7v9x", + }, + DReps: []string{ + "drep1y2cvruq6syfa4w7uksw9jur9q06lwlc60p9kjcxnxd9ww7gh8gtt0", + }, + Pools: []string{ + "pool1ws7gpqkw4wpdj33lf3hcjy9zk5pxr8htnnxkxepe49p5gp3srcg", + }, + }, + }, + ) + +} diff --git a/tray/wizard/wizard_test.go b/tray/wizard/wizard_test.go index 7f0eaf68..fb622809 100644 --- a/tray/wizard/wizard_test.go +++ b/tray/wizard/wizard_test.go @@ -18,9 +18,11 @@ import ( "context" "testing" + "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/test" "github.com/blinklabs-io/adder/tray/setup" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestWizard_Navigation(t *testing.T) { @@ -89,6 +91,27 @@ func TestWizard_Navigation(t *testing.T) { assert.Len(t, finishedPlan.Filter.Wallets, 1) } +// TestWizard_StepContentScrolls guards the window-sizing regression: a +// fixed-size window is content-min-driven, so it balloons for the tall +// "Events & Outputs" step and jumps size every time a target chip is +// added, and without a scroll container the Back/Next footer becomes +// unreachable. The sibling RulesEditor (rules_editor.go) is the +// reference: resizable window + VScroll body. The wizard must match. +func TestWizard_StepContentScrolls(t *testing.T) { + test.NewApp() + + w := NewWizard(nil, nil) + + assert.False(t, w.window.FixedSize(), + "wizard window must not be fixed-size (balloons/jumps on tall steps)") + + require.Len(t, w.stepContent.Objects, 1) + _, ok := w.stepContent.Objects[0].(*container.Scroll) + assert.Truef(t, ok, + "step content must be wrapped in *container.Scroll, got %T", + w.stepContent.Objects[0]) +} + func TestWizardPlan_Initial(t *testing.T) { test.NewApp()