From 3dcf5f41bef183cade716673a3ad70c94e9e7564 Mon Sep 17 00:00:00 2001 From: Ales Verbic Date: Thu, 2 Jul 2026 14:55:54 -0400 Subject: [PATCH 01/15] feat(packaging): signed Windows .msi installer Build a signed Windows .msi containing both binaries (adder.exe CLI and adder-tray.exe GUI) installed under %ProgramFiles%\Adder\, with a Start Menu shortcut for the tray and full Add/Remove Programs metadata. - packaging/windows/adder.wxs: WiX v4 source. Fixed UpgradeCode + auto ProductCode, MajorUpgrade downgrade block, per-component KeyPaths, ARP metadata (publisher/contact/help/about/update URLs). Deliberately does NOT register a Scheduled Task; the adder-tray first-run wizard owns service registration. 64-bit components (Bitness="always64"), valid for both x64 and arm64. - packaging/windows/build-msi.ps1: builds both binaries (CGO for the Fyne tray), pins WiX 4.0.5, signs the MSI via jsign, and passes the WiX `-arch` platform per target ($WixArch: amd64->x64, arm64->arm64) so arm64 builds are stamped correctly. - packaging/windows/README.md: env-var contract and verification steps. - .github/workflows/publish.yml: wire the MSI into the release job for the windows matrix rows (sign binaries -> build+sign MSI -> signtool verify -> upload + attest). Also quiet pre-existing actionlint/shellcheck nits (quote $GITHUB_ENV, $() over backticks in the docker manifest job). - .github/workflows/test-msi.yml: PR-only workflow producing installable artifacts. Signing is optional and gated on a check_signing step output (the secrets context is not available in step-level `if:`), so forks still build an UNSIGNED msi. Signed-off-by: Ales Verbic --- .github/workflows/publish.yml | 202 +++++++++++++++++++---- .github/workflows/test-msi.yml | 248 ++++++++++++++++++++++++++++ packaging/windows/README.md | 202 +++++++++++++++++++++++ packaging/windows/adder.wxs | 138 ++++++++++++++++ packaging/windows/build-msi.ps1 | 280 ++++++++++++++++++++++++++++++++ 5 files changed, 1039 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/test-msi.yml create mode 100644 packaging/windows/README.md create mode 100644 packaging/windows/adder.wxs create mode 100644 packaging/windows/build-msi.ps1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bdda30b..4fe2f3f 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 "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)" - Write-Host "Signed Windows binary: $filename" - Write-Host "Cleaning up certificate chain" - Remove-Item -Path "codesign-chain.pem" -Force + $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' @@ -354,6 +477,20 @@ jobs: with: subject-path: '${{ env.APPLICATION_NAME }}.exe' + - 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.MSI_PATH }} + - name: Attest pkg (macOS) if: startsWith(github.ref, 'refs/tags/') && matrix.os == 'darwin' uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 https://github.com/actions/attest/releases/tag/v4.1.1 @@ -386,7 +523,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 +613,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 0000000..23af17d --- /dev/null +++ b/.github/workflows/test-msi.yml @@ -0,0 +1,248 @@ +name: test-msi + +on: + push: + branches: ['feat/windows-msi-689'] + workflow_dispatch: + +concurrency: + group: test-msi-${{ github.ref }} + cancel-in-progress: true + +env: + APPLICATION_NAME: 'adder' + +jobs: + build-msi: + strategy: + fail-fast: false + matrix: + include: + - runner: windows-latest + arch: amd64 + - runner: windows-11-arm + arch: arm64 + runs-on: ${{ matrix.runner }} + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: '0' + + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: 1.26.x + + - name: Set up MSYS2 MinGW64 (amd64 CGO for Fyne) + if: matrix.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: matrix.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 = '${{ matrix.arch }}' + + if ('${{ matrix.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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.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: ${{ matrix.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 ('${{ 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) { + $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-${{ matrix.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-${{ matrix.arch }}-run${{ github.run_number }} + path: dist\${{ env.APPLICATION_NAME }}-0.0.${{ github.run_number }}-windows-${{ matrix.arch }}.msi + if-no-files-found: error + retention-days: 1 diff --git a/packaging/windows/README.md b/packaging/windows/README.md new file mode 100644 index 0000000..7c6064e --- /dev/null +++ b/packaging/windows/README.md @@ -0,0 +1,202 @@ +# 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 +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 . + +## What the installer does NOT do + +- It does **not** register a Scheduled Task. Service registration is owned by + the `adder-tray` first-run wizard (which knows the user's chosen config and + uses `schtasks.exe` at `tray/setup/service_windows.go`). This keeps the + installer generic and 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`. | +| `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 0000000..93959aa --- /dev/null +++ b/packaging/windows/adder.wxs @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/build-msi.ps1 b/packaging/windows/build-msi.ps1 new file mode 100644 index 0000000..c732685 --- /dev/null +++ b/packaging/windows/build-msi.ps1 @@ -0,0 +1,280 @@ +# 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' } + +# 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. + Write-Log "Building adder-tray.exe (CGO_ENABLED=1)" + $env:CGO_ENABLED = '1' + & go build -ldflags $ldflags ` + -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)' } +} + +# --------------------------------------------------------------------------- +# 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" + } + + # 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" } + + & wix build (Join-Path $ScriptDir 'adder.wxs') ` + -arch $WixArch ` + -d "Version=$MsiVersion" ` + -d "AdderExe=$AdderExeSrc" ` + -d "AdderTrayExe=$AdderTrayExeSrc" ` + -d "AdderIco=$IconSrc" ` + -o $FinalMsi + 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 +} +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" +} From 7897a8813101aaa80fd20919b80d24011f959014 Mon Sep 17 00:00:00 2001 From: Ales Verbic Date: Fri, 3 Jul 2026 17:37:00 -0400 Subject: [PATCH 02/15] fix(tray): scrollable, constant-size setup wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wizard window used SetFixedSize with unscrolled step content, so tall steps ballooned the window, jumped size when target chips were added, and a squeezed box layout gave the step-3 Spacer negative height — overlapping the output section onto the target lists with no way to reach Next. Wrap step content in a VScroll (matching RulesEditor), drop SetFixedSize for a constant 1024x800 resizable window, and remove the Spacer. Add a dev-only screenshot harness + make wizard-screenshots. Signed-off-by: Ales Verbic --- Makefile | 8 ++- tray/wizard/rules_editor.go | 2 +- tray/wizard/step3_template.go | 6 ++- tray/wizard/wizard.go | 23 +++++++-- tray/wizard/wizard_capture_test.go | 80 ++++++++++++++++++++++++++++++ tray/wizard/wizard_test.go | 23 +++++++++ 6 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 tray/wizard/wizard_capture_test.go diff --git a/Makefile b/Makefile index 9a38ea4..9736f9d 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ GO_LDFLAGS=-ldflags "-s -w -X '$(GOMODULE)/internal/version.Version=$(shell git 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,6 +58,12 @@ 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) diff --git a/tray/wizard/rules_editor.go b/tray/wizard/rules_editor.go index bbf54fc..86ee4c6 100644 --- a/tray/wizard/rules_editor.go +++ b/tray/wizard/rules_editor.go @@ -89,7 +89,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. diff --git a/tray/wizard/step3_template.go b/tray/wizard/step3_template.go index 7a1822d..dfded13 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" ) @@ -180,9 +179,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, ) } diff --git a/tray/wizard/wizard.go b/tray/wizard/wizard.go index 1503f15..8463795 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 0000000..cf17675 --- /dev/null +++ b/tray/wizard/wizard_capture_test.go @@ -0,0 +1,80 @@ +// 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/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()) + } +} diff --git a/tray/wizard/wizard_test.go b/tray/wizard/wizard_test.go index 7f0eaf6..fb62280 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() From 8a664576029d207e2a89fc42975c7bf5f5935125 Mon Sep 17 00:00:00 2001 From: Ales Verbic Date: Fri, 3 Jul 2026 19:01:59 -0400 Subject: [PATCH 03/15] fix(tray): register service before restart and neutralize wizard wording The setup wizard finished with "Failed to (re)start the adder service: starting scheduled task: ERROR: The system cannot find the file specified." on a clean Windows install. SetupRunner.Apply only called RestartIfConfigChanged, which on first run skips to EnsureRunning -> startService (schtasks /Run) against a scheduled task that was never created. EnsureRegistered existed but was never wired into the apply flow. The same first-run gap affected macOS/Linux (start before the launchd/systemd unit is installed). Call EnsureRegistered before RestartIfConfigChanged; registration failures surface as the existing soft ServiceRestartErr and skip the restart attempt. Also reword the notifications step: the System Verification blurb and the "didn't show" hint were macOS-specific ("macOS requires...", "System Settings > Notifications"). Make both platform-neutral. Signed-off-by: Ales Verbic --- tray/setup/runner.go | 17 ++++++----- tray/setup/runner_test.go | 45 ++++++++++++++++++++++++++---- tray/wizard/step4_notifications.go | 9 +++--- tray/wizard/steps_test.go | 2 +- 4 files changed, 56 insertions(+), 17 deletions(-) diff --git a/tray/setup/runner.go b/tray/setup/runner.go index 04f8f69..18ec170 100644 --- a/tray/setup/runner.go +++ b/tray/setup/runner.go @@ -129,13 +129,16 @@ 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 0c345c1..e08b321 100644 --- a/tray/setup/runner_test.go +++ b/tray/setup/runner_test.go @@ -58,13 +58,20 @@ 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 @@ -120,12 +127,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("schtasks create 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/wizard/step4_notifications.go b/tray/wizard/step4_notifications.go index e755c18..b7bd7ab 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 c8e86fd..dd9489a 100644 --- a/tray/wizard/steps_test.go +++ b/tray/wizard/steps_test.go @@ -689,7 +689,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() From 7c22c8eee666a7b9a7a2640c43e1cd9668d6ad48 Mon Sep 17 00:00:00 2001 From: Ales Verbic Date: Fri, 3 Jul 2026 23:27:53 -0400 Subject: [PATCH 04/15] fix(tray): windowsgui subsystem and self-healing startup connect - Link adder-tray with -H=windowsgui on Windows (Makefile + build-msi.ps1) so it no longer opens a console window; the CLI keeps its console. - Connect to the engine unconditionally at startup so the tray reflects real state and reconnects with backoff; AutoStart now only gates (re)starting an already-registered engine. - Self-heal when a config exists but the service is not registered: register from the trusted binary and start it, or notify the user to run Reconfigure if that fails. Signed-off-by: Ales Verbic --- Makefile | 10 ++++-- packaging/windows/build-msi.ps1 | 7 ++-- tray/app.go | 61 +++++++++++++++++++++++++++------ 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index 9736f9d..9ba9302 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,13 @@ 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,)) @@ -68,7 +74,7 @@ wizard-screenshots: # 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/packaging/windows/build-msi.ps1 b/packaging/windows/build-msi.ps1 index c732685..e74aa65 100644 --- a/packaging/windows/build-msi.ps1 +++ b/packaging/windows/build-msi.ps1 @@ -160,9 +160,12 @@ function Build-Binaries { # 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. - Write-Log "Building adder-tray.exe (CGO_ENABLED=1)" + # -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' - & go build -ldflags $ldflags ` + $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)' } } diff --git a/tray/app.go b/tray/app.go index eb29ea7..11fe3cf 100644 --- a/tray/app.go +++ b/tray/app.go @@ -724,20 +724,59 @@ 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"). + // + // AutoStart controls whether the tray (re)starts an + // already-registered engine. Independently, if a config exists but + // the service is not registered (never installed, or removed), + // self-heal by registering it now — otherwise the tray would sit + // disconnected forever with no path back except a manual + // Reconfigure. If registration fails, tell the user to run it. + status, _ := setup.ServiceStatusCheck() + switch { + case status == setup.ServiceNotRegistered && ConfigExists(): + if err := a.autoRegisterService(); err != nil { + slog.Error("failed to auto-register adder service", "error", err) + a.fyneApp.SendNotification(fyne.NewNotification( + "Adder setup incomplete", + "Could not register the adder service. Open the tray "+ + "menu and choose Reconfigure… to finish setup.", + )) + } else if err := setup.StartService(); err != nil { + slog.Error("failed to start adder service", "error", err) } - - if err := a.conn.Connect(); err != nil { - slog.Error( - "failed to auto-connect to adder", - "error", err, - ) + case a.Config().AutoStart && status == setup.ServiceRegistered: + if err := setup.StartService(); err != nil { + slog.Error("failed to start adder service", "error", err) } } + + if err := a.conn.Connect(); err != nil { + slog.Error("failed to connect to adder", "error", err) + } +} + +// autoRegisterService installs the adder engine as a system service +// using the trusted binary located next to the tray executable and the +// configured engine config path. It self-heals a startup where a +// config exists but the service is not registered. +func (a *App) autoRegisterService() error { + 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") + } + return setup.RegisterService(setup.ServiceConfig{ + BinaryPath: binPath, + ConfigPath: cfgPath, + LogDir: setup.LogDir(), + }) } // Shutdown gracefully tears down the tray. Ordering: close quitChan, From e8cfcaec55b5f336ab36240e791f71b0f468d63b Mon Sep 17 00:00:00 2001 From: Ales Verbic Date: Sun, 5 Jul 2026 14:57:24 -0400 Subject: [PATCH 05/15] fix(tray): Windows autostart without elevation + software OpenGL Windows testing surfaced two failures: the setup wizard failed with "creating scheduled task: ERROR: Access is denied" and the GUI showed a black screen then closed on VMs. Service registration (tray/setup/service_windows.go): - Drop schtasks entirely. `schtasks /Create` writes the root Task Scheduler folder, which only Administrators may do, so the non-elevated tray was denied. Replace with a per-user HKCU\...\Run value (no elevation) that autostarts the *tray* (GUI subsystem, silent). The tray launches the *engine* (adder.exe, a console app) as a DETACHED, windowless child so no console window appears at logon. - Manage the engine over the Win32 API: enumerate/terminate by image name, wait for exit before restart (avoids racing the API port), record the engine command in a mirror file for config-change detection and relaunch. Redirect the detached engine's output to adder-engine.log. - Remove the now-dead schtasks:// branch in service.go. Diagnosability (cmd/adder-tray, internal/logging): - -H=windowsgui has no console, so slog->stderr was discarded. Tee tray logs to %LOCALAPPDATA%\Adder\Logs\adder-tray.log and route the stdlib logger there too; add a top-level panic recover so a GL/window init failure is logged instead of vanishing. - Single-instance mutex (Local\io.blinklabs.adder.tray) so the autostart and a manual launch don't run two trays fighting over the engine. Software OpenGL (packaging/windows): - Bundle Mesa3D llvmpipe (opengl32.dll + libgallium_wgl.dll, release-mingw) next to adder-tray.exe so the Fyne GUI renders on VM/headless/RDP hosts (e.g. VirtualBox) with no hardware OpenGL driver; pin GALLIUM_DRIVER= llvmpipe. Verified these two DLLs import only Windows in-box libraries; no extra runtime DLLs are shipped. amd64 only (no upstream arm64 build); toggle via BUNDLE_MESA/MESA_VERSION/MESA_OPENGL_DIR. Signed-off-by: Ales Verbic --- cmd/adder-tray/instance_other.go | 21 ++ cmd/adder-tray/instance_windows.go | 51 ++++ cmd/adder-tray/main.go | 68 ++++- internal/logging/logging.go | 12 +- packaging/windows/README.md | 53 +++- packaging/windows/adder.wxs | 32 +++ packaging/windows/build-msi.ps1 | 141 ++++++++++- tray/setup/service.go | 11 +- tray/setup/service_windows.go | 385 +++++++++++++++++++++++------ 9 files changed, 672 insertions(+), 102 deletions(-) create mode 100644 cmd/adder-tray/instance_other.go create mode 100644 cmd/adder-tray/instance_windows.go diff --git a/cmd/adder-tray/instance_other.go b/cmd/adder-tray/instance_other.go new file mode 100644 index 0000000..7ee5531 --- /dev/null +++ b/cmd/adder-tray/instance_other.go @@ -0,0 +1,21 @@ +// 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, where the tray is +// not autostarted via a registry Run key. Returns true (always allowed). +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 0000000..31db70f --- /dev/null +++ b/cmd/adder-tray/instance_windows.go @@ -0,0 +1,51 @@ +// 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. +// On Windows the tray autostarts from the HKCU Run key AND can be launched +// manually from the Start Menu; without this guard both would run, and since +// startService restarts the engine, the two trays would fight over it. +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 any unexpected error 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 d723d71..3d4a9e0 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,62 @@ 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 (e.g. + // VirtualBox). Pin the Gallium driver to llvmpipe so Mesa does not try the + // D3D12 (dozen) 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(os.Stderr, f) + } + 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 (Windows autostarts the + // tray from the Run key and it can also be launched from the Start Menu). + // 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 +116,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/internal/logging/logging.go b/internal/logging/logging.go index f8e47d3..77d962e 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/packaging/windows/README.md b/packaging/windows/README.md index 7c6064e..b2b8993 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -14,9 +14,12 @@ packaging/windows/ ## Installed layout ``` -%ProgramFiles%\Adder\adder.exe # CLI binary -%ProgramFiles%\Adder\adder-tray.exe # tray GUI binary -Start Menu\Programs\Adder # shortcut → adder-tray.exe +%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 @@ -24,12 +27,45 @@ 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 a Scheduled Task. Service registration is owned by - the `adder-tray` first-run wizard (which knows the user's chosen config and - uses `schtasks.exe` at `tray/setup/service_windows.go`). This keeps the - installer generic and mirrors the macOS installer's "no LaunchAgent" stance. +- It does **not** register a Scheduled Task or any autostart. Autostart is + owned by the `adder-tray` first-run wizard (`tray/setup/service_windows.go`), + which on Windows writes a per-user `HKCU\...\Run` value that launches the + **tray** at logon (GUI subsystem → silent). The tray in turn launches the + **engine** (`adder.exe`) as a detached, windowless child and manages its + lifecycle. The engine is never put in the Run key directly, because Explorer + would open a visible console window for it. No elevation is required; this + mirrors the macOS installer's "no LaunchAgent" stance. ## WiX version @@ -76,6 +112,9 @@ The artifact is written to `dist\adder--windows-.msi` | `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`, … | diff --git a/packaging/windows/adder.wxs b/packaging/windows/adder.wxs index 93959aa..def3ec5 100644 --- a/packaging/windows/adder.wxs +++ b/packaging/windows/adder.wxs @@ -101,6 +101,38 @@ KeyPath="yes" /> + + + + + + + + + + + + + + + - + + + + + diff --git a/packaging/windows/build-msi.ps1 b/packaging/windows/build-msi.ps1 index d1e6ab0..c0d3939 100644 --- a/packaging/windows/build-msi.ps1 +++ b/packaging/windows/build-msi.ps1 @@ -316,6 +316,12 @@ function Build-Msi { 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' } @@ -332,6 +338,7 @@ function Build-Msi { $wixArgs = @( 'build', (Join-Path $ScriptDir 'adder.wxs'), '-arch', $WixArch, + '-ext', 'WixToolset.Util.wixext', '-d', "Version=$MsiVersion", '-d', "AdderExe=$AdderExeSrc", '-d', "AdderTrayExe=$AdderTrayExeSrc", diff --git a/tray/app.go b/tray/app.go index 6ab67ff..64c0b5e 100644 --- a/tray/app.go +++ b/tray/app.go @@ -255,6 +255,9 @@ func (a *App) applyPlan( limit, window := result.TrayConfig.ResolvedNotifyRate() eng.SetRateLimit(limit, window) } + // A network switch invalidates cached events from the old chain; + // drop them so their explorer links do not resolve on the wrong network. + a.dropStaleRecentEvents(plan.Network.Name) return result, nil } @@ -604,10 +607,7 @@ func (a *App) setupTray() { var initialFire atomic.Bool initialFire.Store(true) a.conn.status.OnChange(func(s Status) { - icon := GetStatusIcon(s) - slog.Info("tray status changed", - "status", s.String(), - "icon_name", icon.Name()) + slog.Info("tray status changed", "status", s.String()) // Sleep outside fyne.Do so the UI thread is never blocked // while waiting for the OS to settle between rapid icon @@ -616,10 +616,15 @@ func (a *App) setupTray() { fyne.Do(func() { a.uiMu.Lock() defer a.uiMu.Unlock() - mStatus.Label = "Status: " + s.String() + // Apply the CURRENT status, not the captured s: Set spawns a + // goroutine per transition, so rapid transitions can run these + // callbacks out of order. Reading the latest status here makes + // every callback converge on it (e.g. a late "starting" no + // longer overwrites the final "connected" icon). + cur := a.conn.status.Status() + mStatus.Label = "Status: " + cur.String() menu.Refresh() - - desk.SetSystemTrayIcon(icon) + desk.SetSystemTrayIcon(GetStatusIcon(cur)) }) if suppressInitialFire(&initialFire, s) { @@ -647,28 +652,32 @@ func (a *App) setupTray() { } }) - // Startup watchdog: re-asserts the tray icon every 500ms for the - // first 10s to work around macOS dropping icon updates during - // rapid status transitions. + // Icon watchdog: re-asserts the tray icon whenever it no longer matches + // the current status. macOS can drop a SetSystemTrayIcon during rapid + // status transitions (e.g. a reconfigure's disconnect/reconnect), which + // would otherwise leave a stale icon until the next transition. Runs for + // the whole session and only re-sets on change, so it is cheap and self- + // heals a dropped update at any time, not just at startup. go func() { - ticker := time.NewTicker(500 * time.Millisecond) + ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() - timeout := time.After(10 * time.Second) + last := Status(-1) for { select { case <-ticker.C: + cur := a.conn.status.Status() + if cur == last { + continue + } + last = cur fyne.Do(func() { a.uiMu.Lock() - s := a.conn.status.Status() - desk.SetSystemTrayIcon(GetStatusIcon(s)) + desk.SetSystemTrayIcon(GetStatusIcon(cur)) a.uiMu.Unlock() }) case <-a.quitChan: return - case <-timeout: - slog.Debug("startup icon watchdog finished") - return } } }() @@ -729,17 +738,16 @@ func (a *App) setupTray() { // (the EventClient retries with backoff, so a down engine shows // "reconnecting" rather than a permanent gray "stopped"). // - // AutoStart controls whether the tray (re)starts an - // already-registered engine. Independently, if a config exists but - // the service is not registered (never installed, or removed), - // self-heal by registering it now — otherwise the tray would sit - // disconnected forever with no path back except a manual - // Reconfigure. If registration fails, tell the user to run it. - // Only self-heal / autostart on a CONFIRMED status. If the probe itself - // failed, the fallback value is ServiceNotRegistered — acting on that - // would spuriously re-register and (re)start the engine on a transient - // probe error. Log and skip; the unconditional Connect below still runs - // so the tray reflects real state and reconnects with backoff. + // The engine should run whenever the tray is up and configured, so + // launching the tray always brings monitoring up. If a config exists but + // the service is not registered (never installed, or removed), self-heal + // by registering it now; if it is registered, (re)start the engine + // (StartService is stop-then-start, so an already-running engine is not + // duplicated). Otherwise the tray would sit disconnected with no path back + // except a manual Reconfigure. Only act on a CONFIRMED status — a probe + // failure falls back to ServiceNotRegistered, and acting on that would + // spuriously re-register on a transient error; log and skip, the Connect + // below still runs so the tray reflects real state and reconnects. status, statusErr := setup.ServiceStatusCheck() switch { case statusErr != nil: @@ -756,7 +764,7 @@ func (a *App) setupTray() { } else if err := setup.StartService(); err != nil { slog.Error("failed to start adder service", "error", err) } - case a.Config().AutoStart && status == setup.ServiceRegistered: + case status == setup.ServiceRegistered && ConfigExists(): if err := setup.StartService(); err != nil { slog.Error("failed to start adder service", "error", err) } @@ -918,64 +926,132 @@ func (a *App) addRecentEvent(evt event.Event) { if len(a.recentEvents) > 10 { a.recentEvents = a.recentEvents[:10] } + a.refreshRecentMenuLocked() + }) +} - slog.Debug("updating recent events menu", "count", len(a.recentEvents)) +// refreshRecentMenuLocked rebuilds the Recent Events child menu from +// a.recentEvents. Callers must hold uiMu. +func (a *App) refreshRecentMenuLocked() { + slog.Debug("updating recent events menu", "count", len(a.recentEvents)) - // 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") + 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 := eventLinkHash(e) + + item := fyne.NewMenuItem(label, func() { + if hash != "" { + url := getExplorerURL(e, hash) + openURL(url) } - 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 - } + }) + items = append(items, item) + } + + a.mRecent.ChildMenu = fyne.NewMenu("Recent", items...) + if a.mMenu != nil { + a.mMenu.Refresh() + } +} + +// dropStaleRecentEvents removes recent events that belong to a network +// other than networkName, so links do not resolve on the wrong chain +// after a network switch. Custom/unknown networks leave history intact. +func (a *App) dropStaleRecentEvents(networkName string) { + want, ok := networkMagicForName(networkName) + if !ok { + return + } + fyne.Do(func() { + a.uiMu.Lock() + defer a.uiMu.Unlock() + kept := make([]event.Event, 0, len(a.recentEvents)) + for _, e := range a.recentEvents { + if m, ok := eventNetworkMagic(e); !ok || m == want { + kept = append(kept, e) } + } + if len(kept) == len(a.recentEvents) { + return + } + a.recentEvents = kept + a.refreshRecentMenuLocked() + }) +} - item := fyne.NewMenuItem(label, func() { - if hash != "" { - url := getExplorerURL(e, hash) - openURL(url) - } - }) - items = append(items, item) +// eventNetworkMagic returns the event's network magic from its Context. +func eventNetworkMagic(e event.Event) (uint64, bool) { + if ctx, ok := e.Context.(map[string]any); ok { + if m, ok := ctx["networkMagic"].(float64); ok { + return uint64(m), true } + } + return 0, false +} - a.mRecent.ChildMenu = fyne.NewMenu("Recent", items...) - if a.mMenu != nil { - a.mMenu.Refresh() +// networkMagicForName maps a well-known network name to its magic. +func networkMagicForName(name string) (uint64, bool) { + switch strings.ToLower(name) { + case "mainnet": + return 764824073, true + case "preprod": + return 1, true + case "preview": + return 2, true + } + return 0, false +} + +// eventLinkHash returns the hash to link in an explorer for an event. +// A transaction's hash lives in the event Context (transactionHash); a +// block's hash lives in the Payload (blockHash). Reading blockHash for a +// transaction produced a /transactions/ link (404), shared by +// every transaction in the same block. +func eventLinkHash(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 "" } func getExplorerURL(e event.Event, hash string) string { - baseURL := "https://cexplorer.io" + baseURL := "https://adastat.net" // Inspect context for network info 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" + baseURL = "https://preprod.adastat.net" case 2: - baseURL = "https://preview.cexplorer.io" + baseURL = "https://preview.adastat.net" } } } - if e.Type == "input.transaction" { - return fmt.Sprintf("%s/tx/%s", baseURL, hash) + if e.Type == "input.transaction" || e.Type == "input.governance" { + return fmt.Sprintf("%s/transactions/%s", baseURL, hash) } - return fmt.Sprintf("%s/block/%s", baseURL, hash) + return fmt.Sprintf("%s/blocks/%s", baseURL, hash) } // openFolder opens the given directory in the platform file manager. diff --git a/tray/app_helpers_test.go b/tray/app_helpers_test.go index be80569..c3c3b2b 100644 --- a/tray/app_helpers_test.go +++ b/tray/app_helpers_test.go @@ -95,7 +95,7 @@ func TestEmojiAndExplorerURLHelpers(t *testing.T) { name: "mainnet block by default", evt: event.Event{Type: "input.block"}, hash: "abc", - want: "https://cexplorer.io/block/abc", + want: "https://adastat.net/blocks/abc", }, { name: "preprod transaction", @@ -104,7 +104,7 @@ func TestEmojiAndExplorerURLHelpers(t *testing.T) { Context: map[string]any{"networkMagic": float64(1)}, }, hash: "def", - want: "https://preprod.cexplorer.io/tx/def", + want: "https://preprod.adastat.net/transactions/def", }, { name: "preview block", @@ -113,7 +113,7 @@ func TestEmojiAndExplorerURLHelpers(t *testing.T) { Context: map[string]any{"networkMagic": float64(2)}, }, hash: "123", - want: "https://preview.cexplorer.io/block/123", + want: "https://preview.adastat.net/blocks/123", }, } @@ -124,6 +124,32 @@ func TestEmojiAndExplorerURLHelpers(t *testing.T) { } } +func TestEventLinkHash(t *testing.T) { + // A transaction event carries its own hash in Context and the block + // hash in Payload; the link must use the transaction hash, not the + // block hash (which is shared by every tx in the block). + txEvt := event.Event{ + Type: "input.transaction", + Context: map[string]any{"transactionHash": "tx-hash"}, + Payload: map[string]any{"blockHash": "block-hash"}, + } + assert.Equal(t, "tx-hash", eventLinkHash(txEvt)) + + govEvt := event.Event{ + Type: "input.governance", + Context: map[string]any{"transactionHash": "gov-tx-hash"}, + } + assert.Equal(t, "gov-tx-hash", eventLinkHash(govEvt)) + + blockEvt := event.Event{ + Type: "input.block", + Payload: map[string]any{"blockHash": "block-hash"}, + } + assert.Equal(t, "block-hash", eventLinkHash(blockEvt)) + + assert.Empty(t, eventLinkHash(event.Event{Type: "input.transaction"})) +} + // TestDispatchNotificationUpdatesHistoryAndHonorsPreferences was // removed when the inline dispatchNotification function was replaced // with the notifications.Engine + notifications.Dispatch pipeline. The @@ -161,6 +187,38 @@ func TestAddRecentEventKeepsNewestTen(t *testing.T) { assert.Equal(t, 2, trayApp.recentEvents[9].Timestamp.Second()) } +func TestDropStaleRecentEvents(t *testing.T) { + test.NewApp() + trayApp := &App{ + mRecent: fyne.NewMenuItem("Recent Events", nil), + mMenu: fyne.NewMenu("Adder"), + } + + mainnet := event.Event{ + Type: "input.transaction", + Context: map[string]any{"networkMagic": float64(764824073)}, + } + preview := event.Event{ + Type: "input.transaction", + Context: map[string]any{"networkMagic": float64(2)}, + } + trayApp.recentEvents = []event.Event{preview, mainnet} + + // Switching to preview drops the mainnet event, keeps the preview one. + trayApp.dropStaleRecentEvents("preview") + require.Eventually(t, func() bool { + return len(trayApp.recentEvents) == 1 + }, time.Second, 10*time.Millisecond) + m, ok := eventNetworkMagic(trayApp.recentEvents[0]) + require.True(t, ok) + assert.Equal(t, uint64(2), m) + + // A custom/unknown network leaves history untouched. + trayApp.recentEvents = []event.Event{preview, mainnet} + trayApp.dropStaleRecentEvents("custom") + assert.Len(t, trayApp.recentEvents, 2) +} + func TestSetupTrayBuildsDesktopMenu(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell command fakes differ on Windows") @@ -390,8 +448,7 @@ 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) EnsureRunning() error { return nil } func (s *wizardFinishService) RestartIfConfigChanged(string, string) error { return nil } @@ -444,34 +501,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/setup/codec_test.go b/tray/setup/codec_test.go index 475a1c6..ed88e63 100644 --- a/tray/setup/codec_test.go +++ b/tray/setup/codec_test.go @@ -474,3 +474,25 @@ func TestToEngineConfigWritesCustomNodeAddress(t *testing.T) { got.Plugin["input"]["chainsync"]["address"], ) } + +// Notification prefs must not affect the engine config, so a notify-only edit +// leaves config.yaml unchanged and does not restart the engine. +func TestNotifyPrefsDoNotAffectEngineConfig(t *testing.T) { + base := config.Config{} + mk := func(notify NotificationPrefs) config.Config { + return SetupPlan{ + Network: NetworkConfig{Name: "mainnet"}, + Filter: FilterConfig{DReps: []string{"drep1abc"}}, + Output: OutputConfig{Type: "none", Config: make(map[string]string)}, + Notify: notify, + }.ToEngineConfig(base) + } + + a := mk(NotificationPrefs{NotifyPrefVotesCast: true}) + b := mk(NotificationPrefs{ + NotifyPrefVotesCast: false, + NotifyPrefGovProposals: true, + }) + assert.Equal(t, a, b, + "notification prefs must not change the engine config (no restart)") +} diff --git a/tray/setup/plan_paths_service_test.go b/tray/setup/plan_paths_service_test.go index 4ffb2f1..a74ed16 100644 --- a/tray/setup/plan_paths_service_test.go +++ b/tray/setup/plan_paths_service_test.go @@ -446,8 +446,6 @@ func TestServiceRenderingAndSafeStatusPaths(t *testing.T) { assert.Empty(t, text) } - assert.NotEmpty(t, serviceUnitFilePath()) - status, err := ServiceStatusCheck() require.NoError(t, err) assert.Equal(t, ServiceNotRegistered, status) @@ -459,16 +457,8 @@ func TestServiceRenderingAndSafeStatusPaths(t *testing.T) { strings.Contains(err.Error(), "service not registered") || strings.Contains(err.Error(), "not implemented"), ) - - err = (&OSManager{}).RestartIfConfigChanged( - "/tmp/adder", - "/tmp/config.yaml", - ) - require.Error(t, err) - assert.True(t, - strings.Contains(err.Error(), "service not registered") || - strings.Contains(err.Error(), "not implemented"), - ) + // RestartIfConfigChanged is not called here: it self-registers (real + // platform command). Covered by TestServiceLifecycleWithFakePlatformCommand. } } @@ -522,8 +512,9 @@ func TestServiceLifecycleWithFakePlatformCommand(t *testing.T) { } mgr := &OSManager{} - require.NoError(t, mgr.EnsureRegistered(cfg.BinaryPath, cfg.ConfigPath)) - require.NoError(t, mgr.EnsureRegistered(cfg.BinaryPath, cfg.ConfigPath)) + // Idempotent: first call registers + starts, second is a no-op restart. + require.NoError(t, mgr.RestartIfConfigChanged(cfg.BinaryPath, cfg.ConfigPath)) + require.NoError(t, mgr.RestartIfConfigChanged(cfg.BinaryPath, cfg.ConfigPath)) status, err := mgr.Status() require.NoError(t, err) @@ -531,10 +522,7 @@ func TestServiceLifecycleWithFakePlatformCommand(t *testing.T) { require.NoError(t, mgr.EnsureRunning()) - require.NoError( - t, - os.WriteFile(serviceUnitFilePath(), []byte("stale"), 0o644), - ) + // Re-apply with unchanged config: idempotent, no error. require.NoError( t, mgr.RestartIfConfigChanged(cfg.BinaryPath, cfg.ConfigPath), @@ -570,3 +558,145 @@ exit 0 ) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) } + +// The change key must cover both the service command and the config file +// contents (the latter is what makes a config-only reconfigure take effect). +func TestConfigFingerprint(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(cfg, []byte("network: preview\n"), 0o600)) + + base, err := configFingerprint([]byte("cmd"), cfg) + require.NoError(t, err) + require.NotEmpty(t, base) + + same, err := configFingerprint([]byte("cmd"), cfg) + require.NoError(t, err) + assert.Equal(t, base, same, "identical inputs must fingerprint the same") + + require.NoError(t, os.WriteFile(cfg, []byte("network: preprod\n"), 0o600)) + changedContent, err := configFingerprint([]byte("cmd"), cfg) + require.NoError(t, err) + assert.NotEqual(t, base, changedContent, + "config CONTENT change must change the fingerprint") + + changedCmd, err := configFingerprint([]byte("cmd2"), cfg) + require.NoError(t, err) + assert.NotEqual(t, changedContent, changedCmd, + "command change must change the fingerprint") + + _, err = configFingerprint([]byte("cmd"), filepath.Join(dir, "absent.yaml")) + assert.NoError(t, err, "missing config file must not error") +} + +// A config-content-only change updates the fingerprint (restart branch ran); +// re-applying an unchanged config does not. +func TestRestartIfConfigChangedDetectsContentChange(t *testing.T) { + if runtime.GOOS == "windows" || runtime.GOOS == "freebsd" { + t.Skip("platform service command differs or is intentionally unsupported") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("ADDER_TRAY_CONFIG_DIR", filepath.Join(home, "cfg")) + installFakeServiceCommand(t) + + cfgFile := filepath.Join(home, "config.yaml") + require.NoError(t, os.WriteFile(cfgFile, []byte("network: preview\n"), 0o600)) + mgr := &OSManager{} + const bin = "/tmp/adder" + + require.NoError(t, mgr.RestartIfConfigChanged(bin, cfgFile)) + fp1, err := os.ReadFile(serviceStatePath()) + require.NoError(t, err) + require.NotEmpty(t, fp1) + + // Re-apply unchanged config: fingerprint unchanged (no needless restart). + require.NoError(t, mgr.RestartIfConfigChanged(bin, cfgFile)) + fp2, err := os.ReadFile(serviceStatePath()) + require.NoError(t, err) + assert.Equal(t, fp1, fp2, "unchanged config must not update the fingerprint") + + // Change config CONTENTS only (same path): fingerprint must change. + require.NoError(t, os.WriteFile(cfgFile, []byte("network: preprod\n"), 0o600)) + require.NoError(t, mgr.RestartIfConfigChanged(bin, cfgFile)) + fp3, err := os.ReadFile(serviceStatePath()) + require.NoError(t, err) + assert.NotEqual(t, fp1, fp3, "config content change must update the fingerprint") +} + +// installFailingServiceCommand stubs launchctl/systemctl to always fail. +func installFailingServiceCommand(t *testing.T) { + t.Helper() + name := "systemctl" + if runtime.GOOS == "darwin" { + name = "launchctl" + } + binDir := t.TempDir() + script := "#!/bin/sh\nexit 1\n" + require.NoError(t, + os.WriteFile(filepath.Join(binDir, name), []byte(script), 0o755)) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +// A failed (re)start must not persist the fingerprint, so the next apply +// retries instead of treating the config as already applied. +func TestRestartIfConfigChangedFailureLeavesFingerprintUnset(t *testing.T) { + if runtime.GOOS == "windows" || runtime.GOOS == "freebsd" { + t.Skip("platform service command differs or is intentionally unsupported") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("ADDER_TRAY_CONFIG_DIR", filepath.Join(home, "cfg")) + installFailingServiceCommand(t) + + cfgFile := filepath.Join(home, "config.yaml") + require.NoError(t, os.WriteFile(cfgFile, []byte("network: preview\n"), 0o600)) + mgr := &OSManager{} + const bin = "/tmp/adder" + + // First apply fails at register/restart. + require.Error(t, mgr.RestartIfConfigChanged(bin, cfgFile)) + + // Fingerprint must not exist — nothing was successfully applied. + _, statErr := os.Stat(serviceStatePath()) + assert.True(t, os.IsNotExist(statErr), + "fingerprint must not be persisted when (re)start fails") + + // Next apply must retry (still errors), not silently pass as unchanged. + require.Error(t, mgr.RestartIfConfigChanged(bin, cfgFile)) +} + +// A transient "Bootstrap failed: 5" right after bootout must be retried (macOS). +func TestRegisterServiceRetriesBootstrapEIO(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("darwin launchctl bootstrap retry") + } + home := t.TempDir() + t.Setenv("HOME", home) + counter := filepath.Join(home, "n") + t.Setenv("COUNTER", counter) + + binDir := t.TempDir() + script := "#!/bin/sh\n" + + "if [ \"$1\" = bootstrap ]; then\n" + + " n=$(cat \"$COUNTER\" 2>/dev/null || echo 0); n=$((n+1)); echo $n > \"$COUNTER\"\n" + + " if [ $n -eq 1 ]; then echo 'Bootstrap failed: 5: Input/output error' >&2; exit 5; fi\n" + + "fi\n" + + "exit 0\n" + require.NoError(t, + os.WriteFile(filepath.Join(binDir, "launchctl"), []byte(script), 0o755)) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + err := registerService(ServiceConfig{ + BinaryPath: "/tmp/adder", + ConfigPath: "/tmp/config.yaml", + LogDir: filepath.Join(home, "logs"), + }) + require.NoError(t, err, "registerService must retry the transient EIO") + + n, _ := os.ReadFile(counter) + assert.Equal(t, "2", strings.TrimSpace(string(n)), + "bootstrap should have been retried exactly once") +} diff --git a/tray/setup/runner.go b/tray/setup/runner.go index 18ec170..f54d290 100644 --- a/tray/setup/runner.go +++ b/tray/setup/runner.go @@ -129,14 +129,10 @@ func (r *SetupRunner) Apply( slog.Error("could not find adder binary for service registration", "stage", "binary-find", "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 { + // RestartIfConfigChanged registers and restarts as needed. result.ServiceRestartErr = err - slog.Error("failed to ensure service is running", + slog.Error("failed to (re)start service", "stage", "service-restart", "error", err) } diff --git a/tray/setup/runner_test.go b/tray/setup/runner_test.go index e08b321..7d00748 100644 --- a/tray/setup/runner_test.go +++ b/tray/setup/runner_test.go @@ -58,21 +58,19 @@ func (m *mockStore) SaveTrayAtomic(cfg TrayConfig) error { } type mockService struct { - registered bool - running bool - restarts int - ensureRegisteredErr error + registered bool + running bool + restarts int + restartErr error } -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 { + if m.restartErr != nil { + return m.restartErr + } + m.registered = true m.restarts++ return nil } @@ -127,20 +125,19 @@ 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.True(t, svc.registered, "service must be registered during 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) { +// TestApplySurfacesRestartError guards the wizard flow: a failure to +// register/(re)start the service is a soft error (config still persisted) +// surfaced on ApplyResult so the caller can warn the user. +func TestApplySurfacesRestartError(t *testing.T) { store := &mockStore{} - svc := &mockService{ensureRegisteredErr: errors.New("schtasks create failed")} + svc := &mockService{restartErr: errors.New("schtasks create failed")} conn := &mockConnector{} runner := &SetupRunner{ Store: store, @@ -157,8 +154,8 @@ func TestApplySurfacesRegisterErrorAndSkipsRestart(t *testing.T) { 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") + assert.ErrorIs(t, result.ServiceRestartErr, svc.restartErr) + assert.Equal(t, 0, svc.restarts, "no restart counted when it fails") } func TestApplyReturnsStoreErrorsBeforeServiceWork(t *testing.T) { diff --git a/tray/setup/service.go b/tray/setup/service.go index e50a1eb..36a9a12 100644 --- a/tray/setup/service.go +++ b/tray/setup/service.go @@ -16,16 +16,21 @@ package setup import ( "bytes" + "crypto/sha256" + "errors" "fmt" "log/slog" "os" + "path/filepath" ) // ServiceManager defines the interface for idempotent service lifecycle // management. type ServiceManager interface { - EnsureRegistered(binPath, cfgPath string) error EnsureRunning() error + // RestartIfConfigChanged registers when missing and restarts when the + // service command or the engine config file contents changed; otherwise + // ensures it is running. It owns registration. RestartIfConfigChanged(binPath, cfgPath string) error Stop() error Status() (ServiceStatus, error) @@ -35,34 +40,6 @@ type ServiceManager interface { // logic, ensuring operations are idempotent. type OSManager struct{} -func (m *OSManager) EnsureRegistered(binPath, cfgPath string) error { - cfg := ServiceConfig{ - BinaryPath: binPath, - ConfigPath: cfgPath, - LogDir: LogDir(), - } - - // 1. Render desired state - desired, err := renderServiceUnit(cfg) - if err != nil { - return fmt.Errorf("rendering service unit: %w", err) - } - - // 2. Check existing state - path := serviceUnitFilePath() - existing, _ := os.ReadFile(path) - - if existing != nil && bytes.Equal(existing, desired) { - slog.Debug("service already registered with identical configuration", - "path", path) - return nil - } - - // 3. Update only if different - slog.Info("registering/updating system service", "path", path) - return registerService(cfg) -} - func (m *OSManager) EnsureRunning() error { status, err := serviceStatusCheck() if err != nil { @@ -86,20 +63,62 @@ func (m *OSManager) RestartIfConfigChanged(binPath, cfgPath string) error { return err } - path := serviceUnitFilePath() - existing, _ := os.ReadFile(path) - - if len(existing) > 0 && !bytes.Equal(existing, desired) { - slog.Info("service configuration changed, restarting", "path", path) + // Fingerprint = service command + engine config contents. Comparing + // contents (not just the command) is what makes a reconfigure that only + // rewrites config.yaml trigger a restart. + want, err := configFingerprint(desired, cfgPath) + if err != nil { + return err + } + fpPath := serviceStatePath() + got, _ := os.ReadFile(fpPath) + + if !bytes.Equal(got, want) { + slog.Info("service config changed, (re)registering and restarting") + // Stop before (re)registering: on macOS `launchctl bootstrap` fails + // ("Bootstrap failed: 5") if the agent is still loaded, so unload it + // first. Best-effort — a not-running service is not an error here. + _ = stopService() if err := registerService(cfg); err != nil { return err } - return startService() + if err := startService(); err != nil { + return err + } + // Persist the fingerprint only after a successful (re)start, so a + // failed start is retried on the next apply instead of being masked. + if err := os.MkdirAll(filepath.Dir(fpPath), 0o755); err == nil { + if werr := os.WriteFile(fpPath, want, 0o600); werr != nil { + slog.Warn("could not persist service fingerprint", "error", werr) + } + } + return nil } return m.EnsureRunning() } +// configFingerprint hashes the rendered service command together with the +// engine config file contents, so a change to either is detected. 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 +} + +// serviceStatePath is where the last-applied fingerprint is stored. +func serviceStatePath() string { + return filepath.Join(ConfigDir(), "service-state") +} + func (m *OSManager) Stop() error { return stopService() } @@ -108,13 +127,8 @@ func (m *OSManager) Status() (ServiceStatus, error) { return serviceStatusCheck() } -// Helper to provide uniform access to platform-specific unit rendering +// renderServiceUnit renders the platform-specific service unit (defined in +// service_.go). func renderServiceUnit(cfg ServiceConfig) ([]byte, error) { - // These are defined in service_.go return renderUnit(cfg) } - -func serviceUnitFilePath() string { - // This is defined in service_.go - return serviceUnitPath() -} diff --git a/tray/setup/service_darwin.go b/tray/setup/service_darwin.go index 6eae9b4..5b848f6 100644 --- a/tray/setup/service_darwin.go +++ b/tray/setup/service_darwin.go @@ -118,15 +118,31 @@ func registerService(cfg ServiceConfig) error { } 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) + + // bootstrap can race a just-completed bootout: launchd may still be tearing + // down the old job and returns "Bootstrap failed: 5: Input/output error". + // Retrying after a short settle succeeds, so retry the transient EIO. + var lastErr error + for attempt := range 5 { + out, err := exec.Command( //nolint:gosec // paths are generated internally + "launchctl", "bootstrap", target, serviceUnitPath(), + ).CombinedOutput() + if err == nil { + return nil } + msg := strings.TrimSpace(string(out)) + if strings.Contains(msg, "service already bootstrapped") { + return nil + } + lastErr = fmt.Errorf("loading launch agent: %s: %w", msg, err) + if strings.Contains(msg, "Bootstrap failed: 5") || + strings.Contains(msg, "Input/output error") { + time.Sleep(time.Duration(attempt+1) * 300 * time.Millisecond) + continue + } + return lastErr } - - return nil + return lastErr } func unregisterService() error { diff --git a/tray/setup/service_freebsd.go b/tray/setup/service_freebsd.go index a1d3427..43b2024 100644 --- a/tray/setup/service_freebsd.go +++ b/tray/setup/service_freebsd.go @@ -27,10 +27,6 @@ func renderUnit(cfg ServiceConfig) ([]byte, error) { return []byte{}, nil } -func serviceUnitPath() string { - return "unsupported://freebsd/adder" -} - func registerService(ServiceConfig) error { return errFreeBSDServiceUnsupported } diff --git a/tray/setup/service_windows.go b/tray/setup/service_windows.go index 6234be2..ea706c2 100644 --- a/tray/setup/service_windows.go +++ b/tray/setup/service_windows.go @@ -41,9 +41,11 @@ package setup import ( "errors" "fmt" + "log/slog" "os" "os/exec" "path/filepath" + "strconv" "strings" "syscall" "time" @@ -58,9 +60,11 @@ const ( runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run` // runValueName is the value we own under runKeyPath. It launches the TRAY. runValueName = "Adder" - // engineImage is the engine process image name, matched to determine - // running state and to stop the engine. + // engineImage is the engine process image name, used as a fallback match + // when a process image path cannot be read. engineImage = "adder.exe" + // trayImage is the tray process image name, excluded from engine matching. + trayImage = "adder-tray.exe" // 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). @@ -95,12 +99,6 @@ func renderUnit(cfg ServiceConfig) ([]byte, error) { return []byte(windows.ComposeCommandLine(args)), nil } -// serviceUnitPath returns the mirror-file path so service.go reads and diffs -// it like any other platform's unit file. -func serviceUnitPath() string { - return serviceCommandFile() -} - // trayExecutable returns the path of the running tray executable, which is what // the Run value autostarts. registerService is only ever called from within // adder-tray, so os.Executable() resolves to adder-tray.exe. @@ -245,8 +243,13 @@ func startService() error { if err := cmd.Start(); err != nil { return fmt.Errorf("starting adder engine: %w", err) } + // Record the launched PID so a later stop can terminate this exact + // process by PID even if its on-disk image is renamed (an MSI upgrade + // renames an in-use adder.exe to a .rbf rollback file, which would + // otherwise escape image-name matching). // Detach: do not Wait; the engine outlives the tray. if cmd.Process != nil { + writeEnginePID(cmd.Process.Pid) _ = cmd.Process.Release() } return nil @@ -294,11 +297,41 @@ func serviceRegistered() (bool, error) { return true, nil } -// engineRunning reports whether at least one engine process (engineImage) is +// enginePIDFile records the PID of the engine the tray launched, so it can be +// terminated by PID even after its on-disk image is renamed. +func enginePIDFile() string { + return filepath.Join(ConfigDir(), "engine.pid") +} + +func writeEnginePID(pid int) { + if err := os.MkdirAll(filepath.Dir(enginePIDFile()), 0o755); err != nil { + slog.Warn("could not create dir for engine pid file", "error", err) + return + } + if err := os.WriteFile( + enginePIDFile(), []byte(strconv.Itoa(pid)), 0o600, + ); err != nil { + slog.Warn("could not write engine pid file", "error", err) + } +} + +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 +} + +// engineRunning reports whether at least one of our engine processes is // currently running. func engineRunning() (bool, error) { found := false - err := forEachEngineProcess(engineExePath(), func(uint32) bool { + err := forEachEngineProcess(func(uint32) bool { found = true return false // stop at the first match }) @@ -306,64 +339,102 @@ func engineRunning() (bool, error) { } // terminateEngine terminates every running engine process and waits (bounded) -// for each to exit so a subsequent start does not race for the API port. -// Missing processes are not an error; it returns the first failure, if any. +// for each to exit so a subsequent start does not race for the API port. It +// kills both the PID we recorded at launch (robust to a renamed image) and any +// process whose image lives in our install directory (robust to the MSI .rbf +// rename). A process that cannot be terminated but is still alive is a hard +// failure, so the caller does not stack a second engine on a survivor. func terminateEngine() error { var firstErr error - err := forEachEngineProcess(engineExePath(), func(pid uint32) bool { - h, oerr := windows.OpenProcess( - windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pid, - ) - if oerr != nil { - // Non-fatal: the process either exited between enumeration and - // open, or is an unrelated adder.exe we cannot open (different - // user / protected process whose path we also couldn't read). - // Neither is our engine, so it must not block starting ours — - // recording it as a stop failure would abort startService. - return true - } - defer windows.CloseHandle(h) - if terr := windows.TerminateProcess(h, 1); terr != nil { - if firstErr == nil { - firstErr = fmt.Errorf( - "terminating engine process %d: %w", pid, terr, - ) - } - return true + killed := make(map[uint32]bool) + + // 1. The exact PID we launched, even if its image was renamed. + if pid, ok := readEnginePID(); ok { + killed[pid] = true + if err := terminatePID(pid); err != nil { + firstErr = err } - // Wait (bounded) for the process to actually exit. Surface a - // timeout/error as a stop failure so the caller does not launch a - // second engine that races the dying one for the API port. - waitResult, werr := windows.WaitForSingleObject( - h, uint32(stopWaitTimeout.Milliseconds()), - ) - if werr != nil { - if firstErr == nil { - firstErr = fmt.Errorf( - "waiting for engine process %d to exit: %w", pid, werr) - } + } + + // 2. Any remaining engine process (image under the install dir). + err := forEachEngineProcess(func(pid uint32) bool { + if killed[pid] { return true } - if waitResult == uint32(windows.WAIT_TIMEOUT) && firstErr == nil { - firstErr = fmt.Errorf( - "engine process %d did not exit within %s", - pid, stopWaitTimeout) + killed[pid] = true + if terr := terminatePID(pid); terr != nil && firstErr == nil { + firstErr = terr } return true }) + if rmErr := os.Remove(enginePIDFile()); rmErr != nil && + !errors.Is(rmErr, os.ErrNotExist) { + slog.Warn("could not remove engine pid file", "error", rmErr) + } if err != nil { return err } return firstErr } +// terminatePID terminates a single process and waits (bounded) for it to exit. +// An already-gone process is success. A process that cannot be opened but is +// still alive (e.g. a leftover started at higher integrity) is a hard failure. +func terminatePID(pid uint32) error { + h, err := windows.OpenProcess( + windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pid, + ) + if err != nil { + if processAlive(pid) { + return fmt.Errorf( + "cannot terminate engine process %d (still running): %w", + pid, err) + } + return nil // already exited + } + defer 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 windows.CloseHandle(h) + var code uint32 + if err := windows.GetExitCodeProcess(h, &code); err != nil { + return true + } + const stillActive = 259 // STILL_ACTIVE + return code == stillActive +} + // forEachEngineProcess walks the process table and invokes fn with the PID of -// every engine process. Candidates are matched by image name (engineImage); -// when targetPath is non-empty, a candidate is skipped only if its full image -// path can be read AND is confirmed to differ from targetPath — so an -// unrelated adder.exe elsewhere is left alone, while a query failure never -// causes us to lose track of our own engine. fn returns false to stop early. -func forEachEngineProcess(targetPath string, fn func(pid uint32) bool) error { +// every one of our engine processes (excluding this process). fn returns false +// to stop early. +func forEachEngineProcess(fn func(pid uint32) bool) error { + installDir := engineInstallDir() + self := uint32(os.Getpid()) + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) if err != nil { return fmt.Errorf("snapshotting processes: %w", err) @@ -374,10 +445,10 @@ func forEachEngineProcess(targetPath string, fn func(pid uint32) bool) error { entry.Size = uint32(unsafe.Sizeof(entry)) err = windows.Process32First(snap, &entry) for err == nil { + pid := entry.ProcessID name := windows.UTF16ToString(entry.ExeFile[:]) - if strings.EqualFold(name, engineImage) && - !isDifferentImage(entry.ProcessID, targetPath) { - if !fn(entry.ProcessID) { + if pid != self && isOwnEngineProcess(pid, name, installDir) { + if !fn(pid) { return nil } } @@ -390,6 +461,88 @@ func forEachEngineProcess(targetPath string, fn func(pid uint32) bool) error { return err } +// engineInstallDir returns the directory the engine binary lives in, or "". +func engineInstallDir() string { + if p := engineExePath(); p != "" { + return filepath.Dir(p) + } + return "" +} + +// isOwnEngineProcess reports whether pid is one of our engine processes: its +// image lives in our install directory. Matching by directory (not file name) +// also catches an engine whose exe an MSI upgrade renamed to a .rbf rollback +// file, since the rename stays in the same directory. The tray executable is +// excluded. When the image path cannot be read, fall back to the engine image +// name so an unreadable-path engine is still caught. +func isOwnEngineProcess(pid uint32, name, installDir string) bool { + if strings.EqualFold(name, trayImage) { + return false + } + full, err := fullProcessPath(pid) + if err != nil || full == "" { + return strings.EqualFold(name, engineImage) + } + if strings.EqualFold(filepath.Base(full), trayImage) { + return false + } + if installDir == "" { + return strings.EqualFold(name, engineImage) + } + return pathUnderDir(full, installDir) +} + +// pathUnderDir reports whether path is inside dir, comparing normalized +// (symlink-resolved, long-form, cleaned) case-insensitive paths. +func pathUnderDir(path, dir string) bool { + nd := normalizeWinPath(dir) + if nd == "" { + return false + } + np := normalizeWinPath(path) + prefix := strings.ToLower(strings.TrimRight(nd, `\/`)) + + string(filepath.Separator) + return strings.HasPrefix(strings.ToLower(np), prefix) +} + +// normalizeWinPath canonicalizes a Windows path so that short (8.3) vs long +// names, symlink/junction form, and separator/case differences do not cause a +// false mismatch. Steps that require the path to exist degrade gracefully. +func normalizeWinPath(p string) string { + if p == "" { + return "" + } + if r, err := filepath.EvalSymlinks(p); err == nil && r != "" { + p = r + } + if l, err := longPathName(p); err == nil && l != "" { + p = l + } + return filepath.Clean(p) +} + +// longPathName expands a path to its long (non-8.3) form. Requires the path to +// exist; returns an error otherwise. +func longPathName(p string) (string, error) { + from, err := windows.UTF16PtrFromString(p) + if err != nil { + return "", err + } + buf := make([]uint16, windows.MAX_PATH) + n, err := windows.GetLongPathName(from, &buf[0], uint32(len(buf))) + if err != nil { + return "", err + } + if n > uint32(len(buf)) { + buf = make([]uint16, n) + n, err = windows.GetLongPathName(from, &buf[0], uint32(len(buf))) + if err != nil { + return "", err + } + } + return windows.UTF16ToString(buf[:n]), nil +} + // engineExePath returns the engine's executable path from the recorded // command, or "" if it cannot be determined (in which case process matching // falls back to image-name only). @@ -405,25 +558,6 @@ func engineExePath() string { return argv[0] } -// isDifferentImage reports whether the process pid is CONFIRMED to be running -// from a different executable than targetPath. It returns false when -// targetPath is empty or the process image path cannot be read, so an -// unreadable path never causes us to skip our own engine. -func isDifferentImage(pid uint32, targetPath string) bool { - if targetPath == "" { - return false - } - full, err := fullProcessPath(pid) - if err != nil || full == "" { - return false - } - // Normalize before comparing: QueryFullProcessImageName and the recorded - // command path can be equivalent yet textually different (slash style, - // ./.. segments, trailing separators). A false "different" here would - // skip our own engine. filepath.Clean canonicalizes both. - return !strings.EqualFold(filepath.Clean(full), filepath.Clean(targetPath)) -} - // fullProcessPath returns the full image path of the process pid via // QueryFullProcessImageName. func fullProcessPath(pid uint32) (string, error) { From d3177159545b107797c88c7d537b7681c5c95cb4 Mon Sep 17 00:00:00 2001 From: Ales Verbic Date: Sun, 12 Jul 2026 10:44:41 -0400 Subject: [PATCH 13/15] feat(tray): windows MSI packaging and per-user autostart Add a Windows tray distribution: MSI packaging, per-user HKCU Run-key autostart of the GUI tray, and a detached windowless engine child managed by recorded PID. Engine start now recovers when a stale PID has been reused by a protected or other-user process instead of deadlocking. Also: shared explorer URL helper, notification rules and filter logic, config-change restart, service registration assertion, and cleanup across the tray, wizard, and output plugins. Signed-off-by: Ales Verbic --- .github/workflows/go-test.yml | 11 +- .github/workflows/publish.yml | 6 - .github/workflows/test-msi.yml | 31 +- api/api.go | 59 ++- api/lifecycle_test.go | 62 +++ bundle-macos.sh | 23 +- cmd/adder-tray/instance_other.go | 3 +- cmd/adder-tray/instance_windows.go | 5 +- cmd/adder-tray/main.go | 19 +- cmd/adder/main.go | 16 + event/block.go | 5 +- filter/cardano/cardano.go | 31 +- filter/cardano/cardano_test.go | 69 ++++ input/chainsync/chainsync.go | 9 +- input/utxorpc/mapper_test.go | 4 + input/utxorpc/protobuf.go | 9 + internal/explorer/explorer.go | 38 ++ internal/explorer/explorer_test.go | 32 ++ output/telegram/telegram.go | 22 +- output/telegram/telegram_test.go | 16 +- output/webhook/webhook.go | 22 +- packaging/windows/README.md | 12 +- packaging/windows/adder.wxs | 7 +- pipeline/pipeline.go | 61 ++- pipeline/pipeline_test.go | 92 +++++ tray/app.go | 440 +++++++++++++--------- tray/app_helpers_test.go | 319 +++++++++++++--- tray/notifications/engine.go | 6 + tray/notifications/engine_test.go | 67 ++-- tray/notifications/notify.go | 6 + tray/notifications/notify_test.go | 17 + tray/notifications/render_test.go | 5 +- tray/notifications/rules.go | 121 +++++- tray/notifications/rules_test.go | 200 ++++++---- tray/setup/codec.go | 18 +- tray/setup/codec_test.go | 52 +-- tray/setup/finder.go | 7 +- tray/setup/plan.go | 14 +- tray/setup/plan_paths_service_test.go | 170 ++------- tray/setup/plan_test.go | 1 + tray/setup/runner.go | 20 +- tray/setup/runner_test.go | 47 ++- tray/setup/service.go | 130 +++++-- tray/setup/service_darwin.go | 56 +-- tray/setup/service_freebsd.go | 8 + tray/setup/service_impl.go | 2 +- tray/setup/service_linux.go | 5 + tray/setup/service_test.go | 93 +++++ tray/setup/service_windows.go | 518 +++++++++++++------------- tray/setup/service_windows_test.go | 239 ++++++++++++ tray/setup/store.go | 4 +- tray/status.go | 75 ++-- tray/status_test.go | 45 +++ tray/wizard/filter_logic.go | 26 ++ tray/wizard/rules_editor.go | 6 + tray/wizard/step3_template.go | 4 + tray/wizard/step4_notifications.go | 3 +- tray/wizard/steps_test.go | 3 + 58 files changed, 2307 insertions(+), 1084 deletions(-) create mode 100644 api/lifecycle_test.go create mode 100644 internal/explorer/explorer.go create mode 100644 internal/explorer/explorer_test.go create mode 100644 tray/setup/service_test.go create mode 100644 tray/setup/service_windows_test.go create mode 100644 tray/wizard/filter_logic.go diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index dc3b177..6516441 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 4fe2f3f..9403125 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -471,12 +471,6 @@ 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' - uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 https://github.com/actions/attest/releases/tag/v4.1.1 - with: - subject-path: '${{ env.APPLICATION_NAME }}.exe' - - name: Compute MSI path (Windows) if: startsWith(github.ref, 'refs/tags/') && matrix.os == 'windows' && matrix.tray shell: pwsh diff --git a/.github/workflows/test-msi.yml b/.github/workflows/test-msi.yml index 3074bda..883e986 100644 --- a/.github/workflows/test-msi.yml +++ b/.github/workflows/test-msi.yml @@ -1,6 +1,9 @@ name: test-msi on: + push: + branches: + - feat/windows-msi-689 workflow_dispatch: inputs: arch: @@ -20,12 +23,13 @@ env: jobs: build-msi: - # Pick the native runner for the chosen arch: amd64 → windows-latest, - # arm64 → windows-11-arm. - runs-on: ${{ inputs.arch == 'arm64' && 'windows-11-arm' || 'windows-latest' }} + # 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: @@ -36,7 +40,7 @@ jobs: go-version: 1.26.x - name: Set up MSYS2 MinGW64 (amd64 CGO for Fyne) - if: inputs.arch == 'amd64' + if: env.WINDOWS_ARCH == 'amd64' id: msys2-amd64 uses: msys2/setup-msys2@v2 with: @@ -46,7 +50,7 @@ jobs: mingw-w64-x86_64-gcc - name: Set up MSYS2 CLANGARM64 (arm64 CGO for Fyne) - if: inputs.arch == 'arm64' + if: env.WINDOWS_ARCH == 'arm64' id: msys2-arm64 uses: msys2/setup-msys2@v2 with: @@ -59,9 +63,9 @@ jobs: shell: pwsh run: | $env:GOOS = 'windows' - $env:GOARCH = '${{ inputs.arch }}' + $env:GOARCH = '${{ env.WINDOWS_ARCH }}' - if ('${{ inputs.arch }}' -eq 'amd64') { + 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" @@ -178,9 +182,6 @@ jobs: 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 - # Util extension provides util:CloseApplication (stop the running app - # before upgrade). Version-matched to the wix toolset above. - & "$env:USERPROFILE\.dotnet\tools\wix.exe" extension add -g WixToolset.Util.wixext/4.0.5 - name: Build MSI shell: pwsh @@ -191,7 +192,7 @@ jobs: # 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: ${{ inputs.arch }} + 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. @@ -220,7 +221,7 @@ jobs: shell: pwsh run: | $sdkRoot = "${env:ProgramFiles(x86)}\Windows Kits\10\bin" - $archDir = if ('${{ inputs.arch }}' -eq 'arm64') { 'arm64' } else { 'x64' } + $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 @@ -230,7 +231,7 @@ jobs: } if (-not $signtool) { Write-Error "signtool.exe not found under $sdkRoot"; exit 1 } - $msi = "dist\${{ env.APPLICATION_NAME }}-0.0.${{ github.run_number }}-windows-${{ inputs.arch }}.msi" + $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" @@ -245,7 +246,7 @@ jobs: - name: Upload MSI artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: adder-test-windows-${{ inputs.arch }}-run${{ github.run_number }} - path: dist\${{ env.APPLICATION_NAME }}-0.0.${{ github.run_number }}-windows-${{ inputs.arch }}.msi + 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/api/api.go b/api/api.go index 3b62cf7..d873de8 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/lifecycle_test.go b/api/lifecycle_test.go new file mode 100644 index 0000000..81ec139 --- /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 3ea7a6f..b29a1eb 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 index 7ee5531..ff49410 100644 --- a/cmd/adder-tray/instance_other.go +++ b/cmd/adder-tray/instance_other.go @@ -16,6 +16,5 @@ package main -// acquireSingleInstance is a no-op on non-Windows platforms, where the tray is -// not autostarted via a registry Run key. Returns true (always allowed). +// 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 index 31db70f..21b0ad1 100644 --- a/cmd/adder-tray/instance_windows.go +++ b/cmd/adder-tray/instance_windows.go @@ -23,14 +23,11 @@ import ( ) // instanceMutexName is per-session (Local\) so one tray runs per logon session. -// On Windows the tray autostarts from the HKCU Run key AND can be launched -// manually from the Start Menu; without this guard both would run, and since -// startService restarts the engine, the two trays would fight over it. 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 any unexpected error it returns true +// 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) diff --git a/cmd/adder-tray/main.go b/cmd/adder-tray/main.go index 6b7bfa2..35b5dd8 100644 --- a/cmd/adder-tray/main.go +++ b/cmd/adder-tray/main.go @@ -36,10 +36,9 @@ import ( 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 (e.g. - // VirtualBox). Pin the Gallium driver to llvmpipe so Mesa does not try the - // D3D12 (dozen) path, which needs dxil.dll we do not ship. Respect an - // explicit user override. + // 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") @@ -55,13 +54,6 @@ func main() { // 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. - // - // The log FILE is listed first, and deliberately so: io.MultiWriter stops - // at the first writer that errors, and on Windows GUI os.Stderr.Write - // fails (invalid handle). If stderr came first, the file — the whole point - // on Windows — would never be written. File-first guarantees the file is - // always written; a subsequent stderr error is harmless (slog discards - // handler write errors). logOut := io.Writer(os.Stderr) if f := openTrayLogFile(); f != nil { logOut = io.MultiWriter(f, os.Stderr) @@ -85,9 +77,8 @@ func main() { } }() - // Refuse to run a second tray in the same session (Windows autostarts the - // tray from the Run key and it can also be launched from the Start Menu). - // A duplicate would fight the first over the engine lifecycle. + // 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 diff --git a/cmd/adder/main.go b/cmd/adder/main.go index fe75e5f..2590691 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/event/block.go b/event/block.go index 5417a40..4429f7b 100644 --- a/event/block.go +++ b/event/block.go @@ -44,7 +44,10 @@ func NewBlockContext(block ledger.Block, networkMagic uint32) BlockContext { return ctx } -func NewBlockHeaderContext(block ledger.BlockHeader, networkMagic uint32) BlockContext { +func NewBlockHeaderContext( + block ledger.BlockHeader, + networkMagic uint32, +) BlockContext { ctx := BlockContext{ BlockNumber: block.BlockNumber(), SlotNumber: block.SlotNumber(), diff --git a/filter/cardano/cardano.go b/filter/cardano/cardano.go index 6a72f90..d944b0b 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 fb4f0bd..87663e9 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 5dede80..086959f 100644 --- a/input/chainsync/chainsync.go +++ b/input/chainsync/chainsync.go @@ -304,14 +304,7 @@ func (c *ChainSync) setupConnection() error { return err } if c.logger != nil { - network := c.network - if network == "" { - network = "custom" - } - c.logger.Info(fmt.Sprintf( - "connected to node at %s (network=%s, magic=%d)", - c.dialAddress, network, c.networkMagic, - )) + c.logger.Info("connected to node at " + c.dialAddress) } // Start async error handler c.wg.Add(1) diff --git a/input/utxorpc/mapper_test.go b/input/utxorpc/mapper_test.go index 4327870..c4c0d60 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 52f8b7d..8bd3245 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 0000000..457d9d8 --- /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 0000000..5fd5d3d --- /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/output/telegram/telegram.go b/output/telegram/telegram.go index 8a7fda5..f64168b 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 @@ -310,7 +307,7 @@ func (t *TelegramOutput) processEvent(evt *event.Event) { // formatBlockMessage formats a block event for Telegram func formatBlockMessage(be event.BlockEvent, bc event.BlockContext, baseURL string, mode models.ParseMode) string { - blockURL := baseURL + "/blocks/" + be.BlockHash + blockURL := baseURL + "/block/" + be.BlockHash return fmt.Sprintf( "%s\n\n"+ "%s %s\n"+ @@ -350,7 +347,7 @@ func formatTransactionMessage( baseURL string, mode models.ParseMode, ) string { - txURL := baseURL + "/transactions/" + tc.TransactionHash + txURL := baseURL + "/tx/" + tc.TransactionHash return fmt.Sprintf( "%s\n\n"+ "%s %d\n"+ @@ -376,7 +373,7 @@ func formatGovernanceMessage( baseURL string, mode models.ParseMode, ) string { - txURL := baseURL + "/transactions/" + gc.TransactionHash + txURL := baseURL + "/tx/" + gc.TransactionHash return fmt.Sprintf( "%s\n\n"+ "%s %d\n"+ @@ -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://adastat.net" - case preprodNetworkMagic: - return "https://preprod.adastat.net" - case previewNetworkMagic: - return "https://preview.adastat.net" - default: - return "https://adastat.net" - } + 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 11d1c6b..5153e47 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 @@ -91,22 +99,22 @@ func TestFormatFunctions(t *testing.T) { t.Run("getBaseURL mainnet", func(t *testing.T) { result := getBaseURL(mainnetNetworkMagic) - assert.Equal(t, "https://adastat.net", result) + assert.Equal(t, "https://cexplorer.io", result) }) t.Run("getBaseURL preprod", func(t *testing.T) { result := getBaseURL(preprodNetworkMagic) - assert.Equal(t, "https://preprod.adastat.net", result) + assert.Equal(t, "https://preprod.cexplorer.io", result) }) t.Run("getBaseURL preview", func(t *testing.T) { result := getBaseURL(previewNetworkMagic) - assert.Equal(t, "https://preview.adastat.net", result) + assert.Equal(t, "https://preview.cexplorer.io", result) }) t.Run("getBaseURL unknown defaults to mainnet", func(t *testing.T) { result := getBaseURL(12345) - assert.Equal(t, "https://adastat.net", result) + assert.Equal(t, "https://cexplorer.io", result) }) } diff --git a/output/webhook/webhook.go b/output/webhook/webhook.go index 986f212..3b2aaae 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 @@ -172,7 +169,7 @@ func formatWebhook(e *event.Event, format string) []byte { Value: be.IssuerVkey, }) baseURL := getBaseURL(bc.NetworkMagic) - dme.URL = fmt.Sprintf("%s/blocks/%s", baseURL, be.BlockHash) + dme.URL = fmt.Sprintf("%s/block/%s", baseURL, be.BlockHash) case "input.rollback": be := e.Payload.(event.RollbackEvent) dme.Title = "Cardano Rollback" @@ -213,7 +210,7 @@ func formatWebhook(e *event.Event, format string) []byte { Value: tc.TransactionHash, }) baseURL := getBaseURL(tc.NetworkMagic) - dme.URL = fmt.Sprintf("%s/transactions/%s", baseURL, tc.TransactionHash) + dme.URL = fmt.Sprintf("%s/tx/%s", baseURL, tc.TransactionHash) case "input.governance": ge := e.Payload.(event.GovernanceEvent) gc := e.Context.(event.GovernanceContext) @@ -239,7 +236,7 @@ func formatWebhook(e *event.Event, format string) []byte { Value: gc.TransactionHash, }) baseURL := getBaseURL(gc.NetworkMagic) - dme.URL = fmt.Sprintf("%s/transactions/%s", baseURL, gc.TransactionHash) + dme.URL = fmt.Sprintf("%s/tx/%s", baseURL, gc.TransactionHash) default: dwe.Content = fmt.Sprintf("%v", e.Payload) } @@ -277,16 +274,7 @@ type DiscordMessageEmbedField struct { } func getBaseURL(networkMagic uint32) string { - switch networkMagic { - case mainnetNetworkMagic: - return "https://adastat.net" - case preprodNetworkMagic: - return "https://preprod.adastat.net" - case previewNetworkMagic: - return "https://preview.adastat.net" - default: - return "https://adastat.net" // 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 index b2b8993..cc9c00a 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -58,13 +58,11 @@ to `adder-tray.exe`: ## What the installer does NOT do -- It does **not** register a Scheduled Task or any autostart. Autostart is - owned by the `adder-tray` first-run wizard (`tray/setup/service_windows.go`), - which on Windows writes a per-user `HKCU\...\Run` value that launches the - **tray** at logon (GUI subsystem → silent). The tray in turn launches the - **engine** (`adder.exe`) as a detached, windowless child and manages its - lifecycle. The engine is never put in the Run key directly, because Explorer - would open a visible console window for it. No elevation is required; this +- 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 diff --git a/packaging/windows/adder.wxs b/packaging/windows/adder.wxs index 03fa943..44e148d 100644 --- a/packaging/windows/adder.wxs +++ b/packaging/windows/adder.wxs @@ -182,9 +182,10 @@ + register autostart here: it is owned by the adder-tray first-run + wizard, which writes a per-user HKCU\...\Run value (no elevation) that + launches the tray at logon. Mirrors the macOS installer, which does + not install a LaunchAgent. --> diff --git a/pipeline/pipeline.go b/pipeline/pipeline.go index 7fa9542..31b52a2 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 99ea874..9c0ab62 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 64c0b5e..8ead1da 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,29 @@ 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{} + // applying is set for the duration of an Apply (wizard finish / + // reconfigure / notification-rules edit), which restarts the engine + // and briefly cycles the connection. It suppresses the transient + // "Lost connection"/"Reconnected" alerts that restart would + // otherwise emit. Written from applyPlan, read from the status + // observer goroutine, hence atomic. + applying atomic.Bool + 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 +135,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 +155,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 +274,23 @@ 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 the observer + // consumes-and-clears this flag on the first StatusConnected; the + // StatusConnected or StatusError consumes the flag after queued status + // transitions are delivered. Clearing it when Apply returns races that + // asynchronous delivery and leaks phantom connection notifications. + a.applying.Store(true) + result, err := a.runner.Apply(ctx, plan) if err != nil { + a.applying.Store(false) return result, err } + if ctx.Err() != nil { + a.applying.Store(false) + } a.configMu.Lock() a.config = result.TrayConfig a.configMu.Unlock() @@ -255,9 +306,6 @@ func (a *App) applyPlan( limit, window := result.TrayConfig.ResolvedNotifyRate() eng.SetRateLimit(limit, window) } - // A network switch invalidates cached events from the old chain; - // drop them so their explorer links do not resolve on the wrong network. - a.dropStaleRecentEvents(plan.Network.Name) return result, nil } @@ -474,6 +522,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() @@ -590,6 +640,7 @@ func (a *App) setupTray() { a.fyneApp, eng.CurrentEpoch, eng.RecordDrop, + a.addRecentAlert, ) // Log non-zero drop deltas every 30s so suppressed notifications @@ -607,7 +658,10 @@ func (a *App) setupTray() { var initialFire atomic.Bool initialFire.Store(true) a.conn.status.OnChange(func(s Status) { - slog.Info("tray status changed", "status", s.String()) + icon := GetStatusIcon(s) + slog.Info("tray status changed", + "status", s.String(), + "icon_name", icon.Name()) // Sleep outside fyne.Do so the UI thread is never blocked // while waiting for the OS to settle between rapid icon @@ -616,21 +670,25 @@ func (a *App) setupTray() { fyne.Do(func() { a.uiMu.Lock() defer a.uiMu.Unlock() - // Apply the CURRENT status, not the captured s: Set spawns a - // goroutine per transition, so rapid transitions can run these - // callbacks out of order. Reading the latest status here makes - // every callback converge on it (e.g. a late "starting" no - // longer overwrites the final "connected" icon). - cur := a.conn.status.Status() - mStatus.Label = "Status: " + cur.String() + mStatus.Label = "Status: " + s.String() menu.Refresh() - desk.SetSystemTrayIcon(GetStatusIcon(cur)) + + desk.SetSystemTrayIcon(icon) }) if suppressInitialFire(&initialFire, s) { return } + // During an Apply the engine restart cycles the connection + // (stopped → connected). Suppress the phantom Lost/Reconnected + // alerts that would otherwise fire, and consume the flag on the + // settling StatusConnected so genuine drops after Apply still + // report. Errors are never suppressed. + if consumeApplyingStatus(&a.applying, s) { + 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 @@ -652,32 +710,28 @@ func (a *App) setupTray() { } }) - // Icon watchdog: re-asserts the tray icon whenever it no longer matches - // the current status. macOS can drop a SetSystemTrayIcon during rapid - // status transitions (e.g. a reconfigure's disconnect/reconnect), which - // would otherwise leave a stale icon until the next transition. Runs for - // the whole session and only re-sets on change, so it is cheap and self- - // heals a dropped update at any time, not just at startup. + // Startup watchdog: re-asserts the tray icon every 500ms for the + // first 10s to work around macOS dropping icon updates during + // rapid status transitions. go func() { - ticker := time.NewTicker(1 * time.Second) + ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() - last := Status(-1) + timeout := time.After(10 * time.Second) for { select { case <-ticker.C: - cur := a.conn.status.Status() - if cur == last { - continue - } - last = cur fyne.Do(func() { a.uiMu.Lock() - desk.SetSystemTrayIcon(GetStatusIcon(cur)) + s := a.conn.status.Status() + desk.SetSystemTrayIcon(GetStatusIcon(s)) a.uiMu.Unlock() }) case <-a.quitChan: return + case <-timeout: + slog.Debug("startup icon watchdog finished") + return } } }() @@ -707,10 +761,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 @@ -738,35 +788,19 @@ func (a *App) setupTray() { // (the EventClient retries with backoff, so a down engine shows // "reconnecting" rather than a permanent gray "stopped"). // - // The engine should run whenever the tray is up and configured, so - // launching the tray always brings monitoring up. If a config exists but - // the service is not registered (never installed, or removed), self-heal - // by registering it now; if it is registered, (re)start the engine - // (StartService is stop-then-start, so an already-running engine is not - // duplicated). Otherwise the tray would sit disconnected with no path back - // except a manual Reconfigure. Only act on a CONFIRMED status — a probe - // failure falls back to ServiceNotRegistered, and acting on that would - // spuriously re-register on a transient error; log and skip, the Connect - // below still runs so the tray reflects real state and reconnects. - status, statusErr := setup.ServiceStatusCheck() - switch { - case statusErr != nil: - slog.Error("could not determine adder service status; "+ - "skipping autostart/self-heal", "error", statusErr) - case status == setup.ServiceNotRegistered && ConfigExists(): - if err := a.autoRegisterService(); err != nil { - slog.Error("failed to auto-register adder service", "error", err) + // 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 register the adder service. Open the tray "+ + "Could not ensure the adder service. Open the tray "+ "menu and choose Reconfigure… to finish setup.", )) - } else if err := setup.StartService(); err != nil { - slog.Error("failed to start adder service", "error", err) - } - case status == setup.ServiceRegistered && ConfigExists(): - if err := setup.StartService(); err != nil { - slog.Error("failed to start adder service", "error", err) } } @@ -775,11 +809,12 @@ func (a *App) setupTray() { } } -// autoRegisterService installs the adder engine as a system service -// using the trusted binary located next to the tray executable and the -// configured engine config path. It self-heals a startup where a -// config exists but the service is not registered. -func (a *App) autoRegisterService() error { +// 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) @@ -788,11 +823,13 @@ func (a *App) autoRegisterService() error { if cfgPath == "" { cfgPath = filepath.Join(setup.ConfigDir(), "config.yaml") } - return setup.RegisterService(setup.ServiceConfig{ - BinaryPath: binPath, - ConfigPath: cfgPath, - LogDir: setup.LogDir(), - }) + 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, @@ -801,6 +838,9 @@ func (a *App) autoRegisterService() error { func (a *App) Shutdown() { a.shutdownOnce.Do(func() { close(a.quitChan) + if a.conn != nil { + a.conn.Disconnect() + } if a.producerDone != nil { <-a.producerDone } @@ -917,106 +957,185 @@ func getEmojiForType(evtType string) string { } } -func (a *App) addRecentEvent(evt event.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...) - if len(a.recentEvents) > 10 { - a.recentEvents = a.recentEvents[:10] - } - a.refreshRecentMenuLocked() - }) +// 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 + +// suppressConnAlert reports whether a connection-status notification +// should be skipped because an Apply-driven restart is in flight. +// Errors always surface; any other transition during an Apply is a +// side effect of the restart, not a real connection change. +func suppressConnAlert(applying bool, s Status) bool { + return applying && s != StatusError } -// refreshRecentMenuLocked rebuilds the Recent Events child menu from -// a.recentEvents. Callers must hold uiMu. -func (a *App) refreshRecentMenuLocked() { - slog.Debug("updating recent events menu", "count", len(a.recentEvents)) - - 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) +// consumeApplyingStatus updates the Apply suppression lifecycle and reports +// whether the current connection alert should be skipped. Connected settles a +// successful Apply; Error settles a failed Apply but still surfaces the error. +func consumeApplyingStatus(applying *atomic.Bool, s Status) bool { + inFlight := applying.Load() + if inFlight && (s == StatusConnected || s == StatusError) { + applying.Store(false) + } + return suppressConnAlert(inFlight, s) +} - // Create action to "Show" the event in an explorer - hash := eventLinkHash(e) +// 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) +} - item := fyne.NewMenuItem(label, func() { - if hash != "" { - url := getExplorerURL(e, hash) - openURL(url) - } - }) - items = append(items, item) +// 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 +} - a.mRecent.ChildMenu = fyne.NewMenu("Recent", items...) - if a.mMenu != nil { - a.mMenu.Refresh() +// 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) } } -// dropStaleRecentEvents removes recent events that belong to a network -// other than networkName, so links do not resolve on the wrong chain -// after a network switch. Custom/unknown networks leave history intact. -func (a *App) dropStaleRecentEvents(networkName string) { - want, ok := networkMagicForName(networkName) - if !ok { - return +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() - kept := make([]event.Event, 0, len(a.recentEvents)) - for _, e := range a.recentEvents { - if m, ok := eventNetworkMagic(e); !ok || m == want { - kept = append(kept, e) + + // 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) } - if len(kept) == len(a.recentEvents) { - return + + // 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] + } + + slog.Debug("updating recent events menu", "count", len(a.recentEvents)) + + // Update the child menu + items := make([]*fyne.MenuItem, 0, len(a.recentEvents)) + 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(alert.Event, hash) + openURL(url) + } + }) + items = append(items, item) + } + + a.mRecent.ChildMenu = fyne.NewMenu("Recent", items...) + if a.mMenu != nil { + a.mMenu.Refresh() } - a.recentEvents = kept - a.refreshRecentMenuLocked() }) } -// eventNetworkMagic returns the event's network magic from its Context. -func eventNetworkMagic(e event.Event) (uint64, bool) { +func getExplorerURL(e event.Event, hash string) string { + // 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 m, ok := ctx["networkMagic"].(float64); ok { - return uint64(m), true + magic = uint32(m) } } - return 0, false -} + baseURL := explorer.BaseURL(magic) -// networkMagicForName maps a well-known network name to its magic. -func networkMagicForName(name string) (uint64, bool) { - switch strings.ToLower(name) { - case "mainnet": - return 764824073, true - case "preprod": - return 1, true - case "preview": - return 2, true + if e.Type == "input.transaction" || e.Type == "input.governance" { + return fmt.Sprintf("%s/tx/%s", baseURL, hash) } - return 0, false + return fmt.Sprintf("%s/block/%s", baseURL, hash) } -// eventLinkHash returns the hash to link in an explorer for an event. -// A transaction's hash lives in the event Context (transactionHash); a -// block's hash lives in the Payload (blockHash). Reading blockHash for a -// transaction produced a /transactions/ link (404), shared by -// every transaction in the same block. -func eventLinkHash(e event.Event) string { +// 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 { @@ -1034,26 +1153,6 @@ func eventLinkHash(e event.Event) string { return "" } -func getExplorerURL(e event.Event, hash string) string { - baseURL := "https://adastat.net" - // Inspect context for network info - if ctx, ok := e.Context.(map[string]any); ok { - if magic, ok := ctx["networkMagic"].(float64); ok { - switch uint32(magic) { - case 1: - baseURL = "https://preprod.adastat.net" - case 2: - baseURL = "https://preview.adastat.net" - } - } - } - - if e.Type == "input.transaction" || e.Type == "input.governance" { - return fmt.Sprintf("%s/transactions/%s", baseURL, hash) - } - return fmt.Sprintf("%s/blocks/%s", baseURL, hash) -} - // openFolder opens the given directory in the platform file manager. func openFolder(dir string) { if dir == "" { @@ -1063,7 +1162,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 @@ -1076,7 +1181,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 c3c3b2b..114d609 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" @@ -95,7 +96,7 @@ func TestEmojiAndExplorerURLHelpers(t *testing.T) { name: "mainnet block by default", evt: event.Event{Type: "input.block"}, hash: "abc", - want: "https://adastat.net/blocks/abc", + want: "https://cexplorer.io/block/abc", }, { name: "preprod transaction", @@ -104,7 +105,7 @@ func TestEmojiAndExplorerURLHelpers(t *testing.T) { Context: map[string]any{"networkMagic": float64(1)}, }, hash: "def", - want: "https://preprod.adastat.net/transactions/def", + want: "https://preprod.cexplorer.io/tx/def", }, { name: "preview block", @@ -113,7 +114,7 @@ func TestEmojiAndExplorerURLHelpers(t *testing.T) { Context: map[string]any{"networkMagic": float64(2)}, }, hash: "123", - want: "https://preview.adastat.net/blocks/123", + want: "https://preview.cexplorer.io/block/123", }, } @@ -124,32 +125,6 @@ func TestEmojiAndExplorerURLHelpers(t *testing.T) { } } -func TestEventLinkHash(t *testing.T) { - // A transaction event carries its own hash in Context and the block - // hash in Payload; the link must use the transaction hash, not the - // block hash (which is shared by every tx in the block). - txEvt := event.Event{ - Type: "input.transaction", - Context: map[string]any{"transactionHash": "tx-hash"}, - Payload: map[string]any{"blockHash": "block-hash"}, - } - assert.Equal(t, "tx-hash", eventLinkHash(txEvt)) - - govEvt := event.Event{ - Type: "input.governance", - Context: map[string]any{"transactionHash": "gov-tx-hash"}, - } - assert.Equal(t, "gov-tx-hash", eventLinkHash(govEvt)) - - blockEvt := event.Event{ - Type: "input.block", - Payload: map[string]any{"blockHash": "block-hash"}, - } - assert.Equal(t, "block-hash", eventLinkHash(blockEvt)) - - assert.Empty(t, eventLinkHash(event.Event{Type: "input.transaction"})) -} - // TestDispatchNotificationUpdatesHistoryAndHonorsPreferences was // removed when the inline dispatchNotification function was replaced // with the notifications.Engine + notifications.Dispatch pipeline. The @@ -161,9 +136,9 @@ func TestEventLinkHash(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), @@ -171,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"}, + }, }) } @@ -185,38 +163,145 @@ 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 TestDropStaleRecentEvents(t *testing.T) { +func TestAddRecentAlertDedupsReplayedEvents(t *testing.T) { test.NewApp() trayApp := &App{ mRecent: fyne.NewMenuItem("Recent Events", nil), mMenu: fyne.NewMenu("Adder"), } - mainnet := event.Event{ - Type: "input.transaction", - Context: map[string]any{"networkMagic": float64(764824073)}, + evt := event.Event{ + Type: "input.rollback", + Timestamp: time.Date(2026, 7, 12, 16, 56, 6, 0, time.UTC), + Payload: map[string]any{"blockHash": "hash"}, } - preview := event.Event{ - Type: "input.transaction", - Context: map[string]any{"networkMagic": float64(2)}, + // 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, + }) } - trayApp.recentEvents = []event.Event{preview, mainnet} - - // Switching to preview drops the mainnet event, keeps the preview one. - trayApp.dropStaleRecentEvents("preview") require.Eventually(t, func() bool { return len(trayApp.recentEvents) == 1 }, time.Second, 10*time.Millisecond) - m, ok := eventNetworkMagic(trayApp.recentEvents[0]) - require.True(t, ok) - assert.Equal(t, uint64(2), m) - // A custom/unknown network leaves history untouched. - trayApp.recentEvents = []event.Event{preview, mainnet} - trayApp.dropStaleRecentEvents("custom") - assert.Len(t, trayApp.recentEvents, 2) + // 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 TestSuppressConnAlert(t *testing.T) { + // Not applying: nothing suppressed. + assert.False(t, suppressConnAlert(false, StatusStopped)) + assert.False(t, suppressConnAlert(false, StatusConnected)) + // Applying: transient stop/connect suppressed, errors never. + assert.True(t, suppressConnAlert(true, StatusStopped)) + assert.True(t, suppressConnAlert(true, StatusConnected)) + assert.False(t, suppressConnAlert(true, StatusError)) +} + +func TestConsumeApplyingStatus(t *testing.T) { + var applying atomic.Bool + applying.Store(true) + + assert.True(t, consumeApplyingStatus(&applying, StatusStopped)) + assert.True(t, applying.Load()) + assert.True(t, consumeApplyingStatus(&applying, StatusConnected)) + assert.False(t, applying.Load()) + + applying.Store(true) + assert.False(t, consumeApplyingStatus(&applying, StatusError)) + assert.False(t, applying.Load()) } func TestSetupTrayBuildsDesktopMenu(t *testing.T) { @@ -247,9 +332,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() { @@ -264,7 +353,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() @@ -299,6 +388,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{ @@ -369,6 +500,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("") } @@ -432,7 +589,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 } @@ -448,7 +608,17 @@ func (s *wizardFinishStore) SaveTrayAtomic(cfg setup.TrayConfig) error { type wizardFinishService struct{} -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 } @@ -473,6 +643,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() diff --git a/tray/notifications/engine.go b/tray/notifications/engine.go index ee87e73..16e9715 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 9cf3173..5cad184 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,21 +219,18 @@ 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) { - const poolHex = "aabbccddeeff00112233" +// TestEngine_CompatibleTargetGroupsANDSemantics asserts that compatible +// target sections narrow matching on the same event, while values within +// each section remain ORed by the rule matcher. +func TestEngine_CompatibleTargetGroupsANDSemantics(t *testing.T) { plan := setup.SetupPlan{ Filter: setup.FilterConfig{ Wallets: []string{"addr1xyz"}, - DReps: []string{"drep1abc"}, - Pools: []string{poolHex}, + 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) @@ -238,39 +238,24 @@ 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: blockEventBy(poolHex), - wantRuleIDPart: "pool", - }, - { - name: "pure governance event fires drep rule", - evt: govVoteBy("drep1abc"), - wantRuleIDPart: "drep", - }, + events <- txEventTo("addr1xyz") + select { + case r := <-eng.Requests(): + t.Fatalf("unexpected request for wallet-only event: %+v", r) + case <-time.After(150 * time.Millisecond): } - 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 <- txWithTokens([2]string{"polA", "asset1abc"}) + select { + case r := <-eng.Requests(): + t.Fatalf("unexpected request for asset-only event: %+v", r) + case <-time.After(150 * time.Millisecond): } + + events <- txToWithTokens("addr1xyz", [2]string{"polA", "asset1abc"}) + req, ok := recvRequest(t, eng.Requests()) + require.True(t, ok) + assert.Contains(t, req.RuleID, "wallet") } // TestEngine_SetRulesDrainsStaleRequests guards the review finding diff --git a/tray/notifications/notify.go b/tray/notifications/notify.go index e14619b..cc3e20c 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 117d295..68e2c11 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 ed5fb40..9fad9b6 100644 --- a/tray/notifications/render_test.go +++ b/tray/notifications/render_test.go @@ -158,9 +158,8 @@ func TestRenderTemplates_RealPhrasings(t *testing.T) { want: "DRep drep1abc…wxyz voted Yes on proposal #42.", }, { - // Regression (#9): an event carrying several votes must render - // the FOLLOWED DRep's vote, not whichever is first. The - // followed DRep is second here; voteFor(.params) selects it. + // 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"}, diff --git a/tray/notifications/rules.go b/tray/notifications/rules.go index d5f6cb8..bcbcbdf 100644 --- a/tray/notifications/rules.go +++ b/tray/notifications/rules.go @@ -224,11 +224,12 @@ 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. +// The wizard lets the user populate per-target lists independently. +// Values inside a list OR together; compatible non-empty lists on the +// same event family AND together (for example wallet + asset on a tx). +// Event families that cannot carry the same fields keep their own +// event-shaped matching. When MonitorEverything is on the per-target +// lists 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 @@ -248,15 +249,33 @@ func RulesFromPlan(plan setup.SetupPlan) []Rule { rules = append(rules, everythingRules(prefs)...) } else { rules = append(rules, - walletRules(prefs, plan.Filter.Wallets)...) + withTargetFilter( + walletRules(prefs, plan.Filter.Wallets), + transactionTargetFilterMatcher(plan.Filter, true), + )...) rules = append(rules, - drepRules(prefs, plan.Filter.DReps)...) + withTargetFilter( + drepRules(prefs, plan.Filter.DReps), + // DRep rules own their subtype-specific matching: + // proposals are target-independent, while votes and + // registrations match the selected DRep IDs. + nil, + )...) rules = append(rules, - poolRules(prefs, plan.Filter.Pools)...) + withTargetFilter( + poolRules(prefs, plan.Filter.Pools), + poolTargetFilterMatcher(plan.Filter), + )...) rules = append(rules, - assetRules(prefs, plan.Filter.Assets)...) + withTargetFilter( + assetRules(prefs, plan.Filter.Assets), + transactionTargetFilterMatcher(plan.Filter, false), + )...) rules = append(rules, - policyRules(prefs, plan.Filter.Policies)...) + withTargetFilter( + policyRules(prefs, plan.Filter.Policies), + transactionTargetFilterMatcher(plan.Filter, false), + )...) } // Rollback fires for fork resolutions regardless of monitored @@ -283,6 +302,76 @@ func RulesFromPlan(plan setup.SetupPlan) []Rule { return rules } +func transactionTargetFilterMatcher( + f setup.FilterConfig, + requireWalletMatch bool, +) func(event.Event) bool { + var matchers []func(event.Event) bool + paymentAddrs := make([]string, 0, len(f.Wallets)) + for _, wallet := range f.Wallets { + if !strings.HasPrefix(strings.ToLower(wallet), "stake") { + paymentAddrs = append(paymentAddrs, wallet) + } + } + if len(paymentAddrs) > 0 { + addrs := stringSet(paymentAddrs) + matchers = append(matchers, func(evt event.Event) bool { + return matchAnyOutputAddress(addrs)(evt) || + matchAnyResolvedInputAddress(addrs)(evt) + }) + } else if requireWalletMatch && len(f.Wallets) > 0 { + matchers = append(matchers, func(event.Event) bool { return false }) + } + if len(f.Assets) > 0 { + matchers = append(matchers, matchAnyAsset(f.Assets)) + } + if len(f.Policies) > 0 { + matchers = append(matchers, matchAnyPolicy(f.Policies)) + } + if len(matchers) == 0 { + return nil + } + return func(evt event.Event) bool { + for _, match := range matchers { + if !match(evt) { + return false + } + } + return true + } +} + +func poolTargetFilterMatcher(f setup.FilterConfig) func(event.Event) bool { + if len(f.Pools) > 0 { + return matchBlockIssuer(f.Pools) + } + return nil +} + +func withTargetFilter(rules []Rule, filter func(event.Event) bool) []Rule { + if filter == nil { + return rules + } + for i := range rules { + rule := rules[i] + rules[i].CustomMatch = func(evt event.Event) bool { + if !rulePredicateMatches(rule, evt) { + return false + } + return filter(evt) + } + rules[i].MatchExpr = "" + } + return rules +} + +func rulePredicateMatches(r Rule, evt event.Event) bool { + if r.CustomMatch != nil { + return r.CustomMatch(evt) + } + return evalMatchExpr(r.MatchExpr, evt) +} + // coarseRule describes one of the broad event-family rules emitted in // MonitorEverything mode. The rule is Enabled if ANY of the listed // prefs is on, so toggling e.g. only "Outgoing transactions" still @@ -589,6 +678,18 @@ func lowerSet(vals []string) map[string]struct{} { return set } +// stringSet returns a set of the input strings matched exactly. Unlike +// lowerSet it does not fold case: wallet/asset/policy identifiers are compared +// verbatim, whereas pool/DRep IDs (lowerSet) may be given in hex or bech32 and +// are matched case-insensitively. +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 diff --git a/tray/notifications/rules_test.go b/tray/notifications/rules_test.go index 1a47198..56a7602 100644 --- a/tray/notifications/rules_test.go +++ b/tray/notifications/rules_test.go @@ -77,35 +77,35 @@ func blockEvent(hash string) event.Event { } } -func govEvent() event.Event { +func poolBlockEvent(hash, pool string) event.Event { return event.Event{ - Type: EventTypeGovernance, - Payload: map[string]any{}, + Type: EventTypeBlock, + Payload: map[string]any{ + "blockHash": hash, + "issuerVkey": pool, + }, } } -// blockEventBy builds a block event minted by the given issuer (the hex -// pool key-hash carried in payload.issuerVkey). -func blockEventBy(issuer string) event.Event { - return event.Event{ - Type: EventTypeBlock, - Payload: map[string]any{"issuerVkey": issuer}, - } +func govEvent() event.Event { + return govEventForDRep("drep1abc") } -// govVoteBy builds a governance event carrying a vote cast by voterID. -func govVoteBy(voterID string) event.Event { +func govEventForDRep(drep string) event.Event { return event.Event{ Type: EventTypeGovernance, Payload: map[string]any{ "votingProcedures": []any{ - map[string]any{"voterId": voterID}, + map[string]any{ + "voterId": drep, + "vote": "yes", + "govActionIndex": float64(1), + }, }, }, } } -// govProposal builds a governance event carrying a proposal procedure. func govProposal() event.Event { return event.Event{ Type: EventTypeGovernance, @@ -117,6 +117,17 @@ func govProposal() event.Event { } } +func govDRepCert(drep string) event.Event { + return event.Event{ + Type: EventTypeGovernance, + Payload: map[string]any{ + "drepCertificates": []any{ + map[string]any{"drepId": drep}, + }, + }, + } +} + func TestMatchExprEvaluate(t *testing.T) { tests := []struct { name string @@ -354,20 +365,24 @@ 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: a vote cast by the followed DRep matches. - require.True(t, anyRuleMatches(rules, govVoteBy("drep1abc")), - "drep vote rule should match a vote by the followed DRep") - // Positive: a proposal matches the (target-independent) proposals rule. + // Positive: a proposal matches the target-independent proposals rule. require.True(t, anyRuleMatches(rules, govProposal()), "proposals rule should match a proposal event") - // Negative: a vote by a DIFFERENT DRep must NOT match. - require.False(t, anyRuleMatches(rules, govVoteBy("drep1other")), - "vote by an unfollowed DRep must not match") + // 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") @@ -381,29 +396,23 @@ func TestRulesFromPlan_MonitorPool(t *testing.T) { }, Notify: setup.NotificationPrefs{ setup.NotifyPrefBlocksMinted: true, + setup.NotifyPrefPoolParams: true, }, } rules := RulesFromPlan(plan) - // Positive: a block minted by the followed pool matches. - require.True(t, anyRuleMatches(rules, blockEventBy(poolHex)), - "pool block rule should match a block by the followed pool") - // Negative: a block minted by a DIFFERENT pool must NOT match. - require.False(t, anyRuleMatches(rules, blockEventBy("ffffffffffffffffffff")), - "block by an unfollowed pool must not match") + // 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") } -// TestRulesFromPlan_PoolBech32MatchesOnlyOwnBlocks is the regression -// guard for the reported bug where a user monitoring one pool received a -// notification for EVERY block minted on the chain. A bech32 "pool1…" ID -// must be decoded to the hex key-hash that block events carry in -// payload.issuerVkey, and the rule must fire only for that pool's blocks. func TestRulesFromPlan_PoolBech32MatchesOnlyOwnBlocks(t *testing.T) { - // pool16cdtqyk0fvxzfkhjg3esjcuty4tnlpds5lj0lkmqmwdjyzaj7p8 decodes to - // this key-hash (verified via gouroboros NewPoolIdFromBech32). const ( poolBech32 = "pool16cdtqyk0fvxzfkhjg3esjcuty4tnlpds5lj0lkmqmwdjyzaj7p8" poolHash = "d61ab012cf4b0c24daf2447309638b25573f85b0a7e4ffdb60db9b22" @@ -414,9 +423,9 @@ func TestRulesFromPlan_PoolBech32MatchesOnlyOwnBlocks(t *testing.T) { } rules := RulesFromPlan(plan) - require.True(t, anyRuleMatches(rules, blockEventBy(poolHash)), + require.True(t, anyRuleMatches(rules, poolBlockEvent("h", poolHash)), "a block minted by the followed pool must match") - require.False(t, anyRuleMatches(rules, blockEventBy( + require.False(t, anyRuleMatches(rules, poolBlockEvent("h", "0000000000000000000000000000000000000000000000000000000000")), "a block minted by any other pool must NOT match") } @@ -485,6 +494,7 @@ func TestRulesFromPlan_MonitorEverythingORsRelevantPrefs(t *testing.T) { setup.NotifyPrefBlocksMinted: true, // block family setup.NotifyPrefIncomingTx: false, setup.NotifyPrefVotesCast: false, + setup.NotifyPrefPoolParams: false, }, } rules := RulesFromPlan(plan) @@ -496,6 +506,31 @@ func TestRulesFromPlan_MonitorEverythingORsRelevantPrefs(t *testing.T) { "BlocksMinted alone must enable the everything-block rule") } +// TestRulesFromPlan_MonitorEverythingPoolParamsAloneDoesNotFireBlockRule +// is the regression guard for the "block-body misleading when only +// PoolParams is on" fix. In MonitorEverything mode the block rule is +// gated on BlocksMinted only — PoolParams is intentionally not in the +// pref list because the everything-block body renders "Block #N +// minted." which is a lie when the trigger was a pool-parameter +// change. The wizard's MonitorEverything pref list also omits +// PoolParams; this test guards the hand-edited / migrated config path +// where PoolParams could still be true. +func TestRulesFromPlan_MonitorEverythingPoolParamsAloneDoesNotFireBlockRule( + t *testing.T, +) { + plan := setup.SetupPlan{ + Filter: setup.FilterConfig{MonitorEverything: true}, + Notify: setup.NotificationPrefs{ + setup.NotifyPrefPoolParams: true, + setup.NotifyPrefBlocksMinted: false, + }, + } + rules := RulesFromPlan(plan) + require.False(t, anyRuleMatches(rules, blockEvent("h")), + "PoolParams alone must not fire the everything-block rule "+ + "(its body would mislabel pool-param changes as block mints)") +} + // TestRulesFromPlan_EmptyTargetListsEmitNoRules guards the // review-feedback regression: per-target sections with empty lists must // not produce catch-all rules. An unconfigured wallet/DRep/pool section @@ -662,6 +697,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{ @@ -756,45 +798,66 @@ 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) { - const poolHex = "aabbccddeeff00112233" +// TestRulesFromPlan_CompatibleTargetGroupsANDSemantics guards the UI +// promise for target groups that can coexist on one event: entries +// inside a target section OR together, while populated compatible +// sections AND together. +func TestRulesFromPlan_CompatibleTargetGroupsANDSemantics(t *testing.T) { plan := setup.SetupPlan{ Filter: setup.FilterConfig{ - Wallets: []string{"addr1qxy"}, - DReps: []string{"drep1abc"}, - Pools: []string{poolHex}, - 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 minted by the followed pool still fires. - require.True(t, anyRuleMatches(rules, blockEventBy(poolHex)), - "pool block rule fires on a block by the followed pool") - // Governance vote by the followed DRep still fires. - require.True(t, anyRuleMatches(rules, govVoteBy("drep1abc")), - "drep vote rule fires on a vote by the followed DRep") - // 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, + require.False(t, anyRuleMatches(rules, txEventTo("addr1watch")), + "wallet-only tx must not match when asset is also configured") + require.False(t, anyRuleMatches(rules, txWithTokens([2]string{"polA", "asset1abc"})), - "asset rule fires independent of other kinds") + "asset-only tx must not match when wallet is also configured") + 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 @@ -816,6 +879,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 dcd6828..497a837 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 ed88e63..a7f161a 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, ) { @@ -474,25 +468,3 @@ func TestToEngineConfigWritesCustomNodeAddress(t *testing.T) { got.Plugin["input"]["chainsync"]["address"], ) } - -// Notification prefs must not affect the engine config, so a notify-only edit -// leaves config.yaml unchanged and does not restart the engine. -func TestNotifyPrefsDoNotAffectEngineConfig(t *testing.T) { - base := config.Config{} - mk := func(notify NotificationPrefs) config.Config { - return SetupPlan{ - Network: NetworkConfig{Name: "mainnet"}, - Filter: FilterConfig{DReps: []string{"drep1abc"}}, - Output: OutputConfig{Type: "none", Config: make(map[string]string)}, - Notify: notify, - }.ToEngineConfig(base) - } - - a := mk(NotificationPrefs{NotifyPrefVotesCast: true}) - b := mk(NotificationPrefs{ - NotifyPrefVotesCast: false, - NotifyPrefGovProposals: true, - }) - assert.Equal(t, a, b, - "notification prefs must not change the engine config (no restart)") -} diff --git a/tray/setup/finder.go b/tray/setup/finder.go index 0d17e9b..d391b35 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 4f18674..d346c1a 100644 --- a/tray/setup/plan.go +++ b/tray/setup/plan.go @@ -39,6 +39,8 @@ const ( NotifyPrefVotesCast = "Votes cast" // NotifyPrefRegChanges is the preference key for registration change alerts. NotifyPrefRegChanges = "Registration changes" + // NotifyPrefPoolParams is the preference key for pool parameter change alerts. + NotifyPrefPoolParams = "Pool parameter changes" // NotifyPrefAssetActivity is the preference key for events touching // any followed asset fingerprint (mint, burn, transfer). NotifyPrefAssetActivity = "Asset activity" @@ -60,6 +62,7 @@ var allNotifyPrefs = []string{ NotifyPrefOutgoingTx, NotifyPrefTokenTransfers, NotifyPrefBlocksMinted, + NotifyPrefPoolParams, NotifyPrefGovProposals, NotifyPrefVotesCast, NotifyPrefRegChanges, @@ -95,12 +98,11 @@ 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). +// FilterConfig defines the user's monitoring targets. Values inside +// each list OR together; non-empty lists AND together. MonitorEverything +// ignores the per-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"` diff --git a/tray/setup/plan_paths_service_test.go b/tray/setup/plan_paths_service_test.go index a74ed16..c862361 100644 --- a/tray/setup/plan_paths_service_test.go +++ b/tray/setup/plan_paths_service_test.go @@ -328,6 +328,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 +433,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,6 +448,8 @@ func TestServiceRenderingAndSafeStatusPaths(t *testing.T) { assert.Empty(t, text) } + assert.NotEmpty(t, serviceUnitPath()) + status, err := ServiceStatusCheck() require.NoError(t, err) assert.Equal(t, ServiceNotRegistered, status) @@ -457,8 +461,16 @@ func TestServiceRenderingAndSafeStatusPaths(t *testing.T) { strings.Contains(err.Error(), "service not registered") || strings.Contains(err.Error(), "not implemented"), ) - // RestartIfConfigChanged is not called here: it self-registers (real - // platform command). Covered by TestServiceLifecycleWithFakePlatformCommand. + + err = (&OSManager{}).RestartIfConfigChanged( + "/tmp/adder", + "/tmp/config.yaml", + ) + require.Error(t, err) + assert.True(t, + strings.Contains(err.Error(), "service not registered") || + strings.Contains(err.Error(), "not implemented"), + ) } } @@ -512,9 +524,8 @@ func TestServiceLifecycleWithFakePlatformCommand(t *testing.T) { } mgr := &OSManager{} - // Idempotent: first call registers + starts, second is a no-op restart. - require.NoError(t, mgr.RestartIfConfigChanged(cfg.BinaryPath, cfg.ConfigPath)) - require.NoError(t, mgr.RestartIfConfigChanged(cfg.BinaryPath, cfg.ConfigPath)) + require.NoError(t, mgr.EnsureRegistered(cfg.BinaryPath, cfg.ConfigPath)) + require.NoError(t, mgr.EnsureRegistered(cfg.BinaryPath, cfg.ConfigPath)) status, err := mgr.Status() require.NoError(t, err) @@ -522,7 +533,10 @@ func TestServiceLifecycleWithFakePlatformCommand(t *testing.T) { require.NoError(t, mgr.EnsureRunning()) - // Re-apply with unchanged config: idempotent, no error. + require.NoError( + t, + os.WriteFile(serviceUnitPath(), []byte("stale"), 0o644), + ) require.NoError( t, mgr.RestartIfConfigChanged(cfg.BinaryPath, cfg.ConfigPath), @@ -558,145 +572,3 @@ exit 0 ) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) } - -// The change key must cover both the service command and the config file -// contents (the latter is what makes a config-only reconfigure take effect). -func TestConfigFingerprint(t *testing.T) { - dir := t.TempDir() - cfg := filepath.Join(dir, "config.yaml") - require.NoError(t, os.WriteFile(cfg, []byte("network: preview\n"), 0o600)) - - base, err := configFingerprint([]byte("cmd"), cfg) - require.NoError(t, err) - require.NotEmpty(t, base) - - same, err := configFingerprint([]byte("cmd"), cfg) - require.NoError(t, err) - assert.Equal(t, base, same, "identical inputs must fingerprint the same") - - require.NoError(t, os.WriteFile(cfg, []byte("network: preprod\n"), 0o600)) - changedContent, err := configFingerprint([]byte("cmd"), cfg) - require.NoError(t, err) - assert.NotEqual(t, base, changedContent, - "config CONTENT change must change the fingerprint") - - changedCmd, err := configFingerprint([]byte("cmd2"), cfg) - require.NoError(t, err) - assert.NotEqual(t, changedContent, changedCmd, - "command change must change the fingerprint") - - _, err = configFingerprint([]byte("cmd"), filepath.Join(dir, "absent.yaml")) - assert.NoError(t, err, "missing config file must not error") -} - -// A config-content-only change updates the fingerprint (restart branch ran); -// re-applying an unchanged config does not. -func TestRestartIfConfigChangedDetectsContentChange(t *testing.T) { - if runtime.GOOS == "windows" || runtime.GOOS == "freebsd" { - t.Skip("platform service command differs or is intentionally unsupported") - } - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) - t.Setenv("ADDER_TRAY_CONFIG_DIR", filepath.Join(home, "cfg")) - installFakeServiceCommand(t) - - cfgFile := filepath.Join(home, "config.yaml") - require.NoError(t, os.WriteFile(cfgFile, []byte("network: preview\n"), 0o600)) - mgr := &OSManager{} - const bin = "/tmp/adder" - - require.NoError(t, mgr.RestartIfConfigChanged(bin, cfgFile)) - fp1, err := os.ReadFile(serviceStatePath()) - require.NoError(t, err) - require.NotEmpty(t, fp1) - - // Re-apply unchanged config: fingerprint unchanged (no needless restart). - require.NoError(t, mgr.RestartIfConfigChanged(bin, cfgFile)) - fp2, err := os.ReadFile(serviceStatePath()) - require.NoError(t, err) - assert.Equal(t, fp1, fp2, "unchanged config must not update the fingerprint") - - // Change config CONTENTS only (same path): fingerprint must change. - require.NoError(t, os.WriteFile(cfgFile, []byte("network: preprod\n"), 0o600)) - require.NoError(t, mgr.RestartIfConfigChanged(bin, cfgFile)) - fp3, err := os.ReadFile(serviceStatePath()) - require.NoError(t, err) - assert.NotEqual(t, fp1, fp3, "config content change must update the fingerprint") -} - -// installFailingServiceCommand stubs launchctl/systemctl to always fail. -func installFailingServiceCommand(t *testing.T) { - t.Helper() - name := "systemctl" - if runtime.GOOS == "darwin" { - name = "launchctl" - } - binDir := t.TempDir() - script := "#!/bin/sh\nexit 1\n" - require.NoError(t, - os.WriteFile(filepath.Join(binDir, name), []byte(script), 0o755)) - t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) -} - -// A failed (re)start must not persist the fingerprint, so the next apply -// retries instead of treating the config as already applied. -func TestRestartIfConfigChangedFailureLeavesFingerprintUnset(t *testing.T) { - if runtime.GOOS == "windows" || runtime.GOOS == "freebsd" { - t.Skip("platform service command differs or is intentionally unsupported") - } - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) - t.Setenv("ADDER_TRAY_CONFIG_DIR", filepath.Join(home, "cfg")) - installFailingServiceCommand(t) - - cfgFile := filepath.Join(home, "config.yaml") - require.NoError(t, os.WriteFile(cfgFile, []byte("network: preview\n"), 0o600)) - mgr := &OSManager{} - const bin = "/tmp/adder" - - // First apply fails at register/restart. - require.Error(t, mgr.RestartIfConfigChanged(bin, cfgFile)) - - // Fingerprint must not exist — nothing was successfully applied. - _, statErr := os.Stat(serviceStatePath()) - assert.True(t, os.IsNotExist(statErr), - "fingerprint must not be persisted when (re)start fails") - - // Next apply must retry (still errors), not silently pass as unchanged. - require.Error(t, mgr.RestartIfConfigChanged(bin, cfgFile)) -} - -// A transient "Bootstrap failed: 5" right after bootout must be retried (macOS). -func TestRegisterServiceRetriesBootstrapEIO(t *testing.T) { - if runtime.GOOS != "darwin" { - t.Skip("darwin launchctl bootstrap retry") - } - home := t.TempDir() - t.Setenv("HOME", home) - counter := filepath.Join(home, "n") - t.Setenv("COUNTER", counter) - - binDir := t.TempDir() - script := "#!/bin/sh\n" + - "if [ \"$1\" = bootstrap ]; then\n" + - " n=$(cat \"$COUNTER\" 2>/dev/null || echo 0); n=$((n+1)); echo $n > \"$COUNTER\"\n" + - " if [ $n -eq 1 ]; then echo 'Bootstrap failed: 5: Input/output error' >&2; exit 5; fi\n" + - "fi\n" + - "exit 0\n" - require.NoError(t, - os.WriteFile(filepath.Join(binDir, "launchctl"), []byte(script), 0o755)) - t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) - - err := registerService(ServiceConfig{ - BinaryPath: "/tmp/adder", - ConfigPath: "/tmp/config.yaml", - LogDir: filepath.Join(home, "logs"), - }) - require.NoError(t, err, "registerService must retry the transient EIO") - - n, _ := os.ReadFile(counter) - assert.Equal(t, "2", strings.TrimSpace(string(n)), - "bootstrap should have been retried exactly once") -} diff --git a/tray/setup/plan_test.go b/tray/setup/plan_test.go index 34de3a7..d10d864 100644 --- a/tray/setup/plan_test.go +++ b/tray/setup/plan_test.go @@ -71,6 +71,7 @@ func TestAllNotifyPrefsExhaustive(t *testing.T) { NotifyPrefGovProposals, NotifyPrefVotesCast, NotifyPrefRegChanges, + NotifyPrefPoolParams, NotifyPrefAssetActivity, NotifyPrefPolicyActivity, NotifyPrefConnectionIssues, diff --git a/tray/setup/runner.go b/tray/setup/runner.go index f54d290..49c573f 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,10 +129,20 @@ 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 { - // RestartIfConfigChanged registers and restarts as needed. + } else if err := r.Service.EnsureRegistered( + binPath, + engineCfgPath, + ); err != nil { result.ServiceRestartErr = err - slog.Error("failed to (re)start service", + 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) } diff --git a/tray/setup/runner_test.go b/tray/setup/runner_test.go index 7d00748..50bdcc2 100644 --- a/tray/setup/runner_test.go +++ b/tray/setup/runner_test.go @@ -58,24 +58,32 @@ func (m *mockStore) SaveTrayAtomic(cfg TrayConfig) error { } type mockService struct { - registered bool - running bool - restarts int - restartErr error + registered bool + running bool + restarts int + ensureRegisteredErr error } -func (m *mockService) EnsureRunning() error { m.running = true; return nil } - -func (m *mockService) RestartIfConfigChanged(bin, cfg string) error { - if m.restartErr != nil { - return m.restartErr +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 @@ -125,19 +133,20 @@ func TestApplyDoesNotTouchHostServicesWithFakeManager(t *testing.T) { require.NoError(t, err) assert.True(t, store.saved) - assert.True(t, svc.registered, "service must be registered during restart") + 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) } -// TestApplySurfacesRestartError guards the wizard flow: a failure to -// register/(re)start the service is a soft error (config still persisted) -// surfaced on ApplyResult so the caller can warn the user. -func TestApplySurfacesRestartError(t *testing.T) { +// 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{restartErr: errors.New("schtasks create failed")} + svc := &mockService{ensureRegisteredErr: errors.New("register failed")} conn := &mockConnector{} runner := &SetupRunner{ Store: store, @@ -154,8 +163,8 @@ func TestApplySurfacesRestartError(t *testing.T) { assert.True(t, store.saved) require.Error(t, result.ServiceRestartErr) - assert.ErrorIs(t, result.ServiceRestartErr, svc.restartErr) - assert.Equal(t, 0, svc.restarts, "no restart counted when it fails") + 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) { diff --git a/tray/setup/service.go b/tray/setup/service.go index 36a9a12..269fa18 100644 --- a/tray/setup/service.go +++ b/tray/setup/service.go @@ -27,18 +27,71 @@ import ( // ServiceManager defines the interface for idempotent service lifecycle // management. type ServiceManager interface { + EnsureRegistered(binPath, cfgPath string) error EnsureRunning() error - // RestartIfConfigChanged registers when missing and restarts when the - // service command or the engine config file contents changed; otherwise - // ensures it is running. It owns registration. RestartIfConfigChanged(binPath, cfgPath string) error Stop() error 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{ + BinaryPath: binPath, + ConfigPath: cfgPath, + LogDir: LogDir(), + } + + desired, err := renderUnit(cfg) + if err != nil { + return fmt.Errorf("rendering service unit: %w", err) + } + + // 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 + } + + slog.Info("registering/updating system service") + return r.registerService(cfg) +} func (m *OSManager) EnsureRunning() error { status, err := serviceStatusCheck() @@ -58,38 +111,40 @@ func (m *OSManager) RestartIfConfigChanged(binPath, cfgPath string) error { LogDir: LogDir(), } - desired, err := renderServiceUnit(cfg) + desired, err := renderUnit(cfg) if err != nil { return err } - // Fingerprint = service command + engine config contents. Comparing - // contents (not just the command) is what makes a reconfigure that only - // rewrites config.yaml trigger a restart. + // 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 } - fpPath := serviceStatePath() - got, _ := os.ReadFile(fpPath) + got, _ := os.ReadFile(serviceStatePath()) if !bytes.Equal(got, want) { - slog.Info("service config changed, (re)registering and restarting") - // Stop before (re)registering: on macOS `launchctl bootstrap` fails - // ("Bootstrap failed: 5") if the agent is still loaded, so unload it - // first. Best-effort — a not-running service is not an error here. + 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 := registerService(cfg); err != nil { - return err - } if err := startService(); err != nil { return err } - // Persist the fingerprint only after a successful (re)start, so a - // failed start is retried on the next apply instead of being masked. - if err := os.MkdirAll(filepath.Dir(fpPath), 0o755); err == nil { - if werr := os.WriteFile(fpPath, want, 0o600); werr != nil { - slog.Warn("could not persist service fingerprint", "error", werr) + // 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 @@ -98,9 +153,18 @@ func (m *OSManager) RestartIfConfigChanged(binPath, cfgPath string) error { return m.EnsureRunning() } -// configFingerprint hashes the rendered service command together with the -// engine config file contents, so a change to either is detected. A missing -// config file hashes as empty (deterministic). +func (m *OSManager) Stop() error { + return stopService() +} + +func (m *OSManager) Status() (ServiceStatus, error) { + return serviceStatusCheck() +} + +// 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) @@ -114,21 +178,7 @@ func configFingerprint(unit []byte, cfgPath string) ([]byte, error) { return h.Sum(nil), nil } -// serviceStatePath is where the last-applied fingerprint is stored. +// serviceStatePath is where the last-applied restart fingerprint is stored. func serviceStatePath() string { return filepath.Join(ConfigDir(), "service-state") } - -func (m *OSManager) Stop() error { - return stopService() -} - -func (m *OSManager) Status() (ServiceStatus, error) { - return serviceStatusCheck() -} - -// renderServiceUnit renders the platform-specific service unit (defined in -// service_.go). -func renderServiceUnit(cfg ServiceConfig) ([]byte, error) { - return renderUnit(cfg) -} diff --git a/tray/setup/service_darwin.go b/tray/setup/service_darwin.go index 5b848f6..460d1ec 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,36 +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()) - - // bootstrap can race a just-completed bootout: launchd may still be tearing - // down the old job and returns "Bootstrap failed: 5: Input/output error". - // Retrying after a short settle succeeds, so retry the transient EIO. - var lastErr error - for attempt := range 5 { - out, err := exec.Command( //nolint:gosec // paths are generated internally + 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 { - return nil - } - msg := strings.TrimSpace(string(out)) - if strings.Contains(msg, "service already bootstrapped") { + if err == nil || + strings.Contains(string(out), "service already bootstrapped") { return nil } - lastErr = fmt.Errorf("loading launch agent: %s: %w", msg, err) - if strings.Contains(msg, "Bootstrap failed: 5") || - strings.Contains(msg, "Input/output error") { - time.Sleep(time.Duration(attempt+1) * 300 * time.Millisecond) - continue + if !strings.Contains(string(out), "Bootstrap failed: 5") { + break } - return lastErr + time.Sleep(time.Duration(attempt+1) * 300 * time.Millisecond) } - return lastErr + return fmt.Errorf("loading launch agent: %s: %w", + strings.TrimSpace(string(out)), err) } func unregisterService() error { @@ -151,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) } } @@ -167,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 } @@ -186,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 } @@ -228,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 43b2024..a15b701 100644 --- a/tray/setup/service_freebsd.go +++ b/tray/setup/service_freebsd.go @@ -27,6 +27,10 @@ func renderUnit(cfg ServiceConfig) ([]byte, error) { return []byte{}, nil } +func serviceUnitPath() string { + return "unsupported://freebsd/adder" +} + func registerService(ServiceConfig) error { return errFreeBSDServiceUnsupported } @@ -46,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 87c7d8c..eb47b24 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 e2d5a98..6c4d521 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 0000000..f7aff59 --- /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 ea706c2..e7801fa 100644 --- a/tray/setup/service_windows.go +++ b/tray/setup/service_windows.go @@ -20,21 +20,20 @@ package setup // // 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 gets +// ("Only Administrators can schedule tasks."), so a non-elevated tray would get // "Access is denied". // -// Autostart model (mirrors how mainstream tray apps like Slack/Dropbox work): +// Autostart model (mirrors mainstream tray apps like Slack/Dropbox): // -// - The per-user HKCU\...\Run value autostarts the TRAY (adder-tray.exe), -// which is a GUI-subsystem binary (-H=windowsgui) and therefore starts -// silently with no console window. -// - The engine (adder.exe) is a CONSOLE binary. It is never placed in the +// - 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 the engine as a -// DETACHED, windowless child and manages its lifecycle. -// - The engine command line is recorded in a mirror file so the -// platform-agnostic ServiceManager (service.go) can diff config changes, -// and so startService knows what to launch. +// 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. @@ -60,37 +59,79 @@ const ( runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run` // runValueName is the value we own under runKeyPath. It launches the TRAY. runValueName = "Adder" - // engineImage is the engine process image name, used as a fallback match - // when a process image path cannot be read. - engineImage = "adder.exe" - // trayImage is the tray process image name, excluded from engine matching. - trayImage = "adder-tray.exe" // 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). + // actually exit before proceeding, so a restart does not race the old + // process for the API port. stopWaitTimeout = 10 * time.Second ) -// serviceCommandFile returns the path of a file mirroring the engine command. -// There is no readable "unit file" behind a registry Run value, so this mirror -// (a) gives service.go something to diff desired-vs-existing against, which is -// what lets RestartIfConfigChanged detect config changes, and (b) records the -// exact command startService must launch. +// 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 +) + +// 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 + } + + 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 returns the path the detached engine's stdout/stderr are -// redirected to. A DETACHED_PROCESS has no console, so without this the -// engine's logs (which go to stderr) would be lost. +// 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") } -// renderUnit returns the canonical, correctly-quoted engine command line -// stored in the mirror file. ComposeCommandLine produces quoting that -// DecomposeCommandLine (and CreateProcess) parse back identically, including -// paths with spaces such as %ProgramFiles%\Adder. +// 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) { args := []string{cfg.BinaryPath} if cfg.ConfigPath != "" { @@ -99,9 +140,9 @@ func renderUnit(cfg ServiceConfig) ([]byte, error) { return []byte(windows.ComposeCommandLine(args)), nil } -// trayExecutable returns the path of the running tray executable, which is what -// the Run value autostarts. registerService is only ever called from within -// adder-tray, so os.Executable() resolves to adder-tray.exe. +// 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 { @@ -124,7 +165,7 @@ func registerService(cfg ServiceConfig) error { } runCommand := windows.ComposeCommandLine([]string{trayExe}) k, _, err := registry.CreateKey( - registry.CURRENT_USER, runKeyPath, registry.SET_VALUE, + runKeyRegistryHive, runKeyRegistryPath, registry.SET_VALUE, ) if err != nil { return fmt.Errorf("opening Run registry key: %w", err) @@ -134,9 +175,10 @@ func registerService(cfg ServiceConfig) error { return fmt.Errorf("writing Run registry value: %w", err) } - // 2. Record the engine command so startService knows what to launch and - // service.go can diff config changes. - if err := os.MkdirAll(filepath.Dir(serviceCommandFile()), 0o755); err != nil { + // 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 { @@ -150,7 +192,7 @@ func unregisterService() error { _ = stopService() k, err := registry.OpenKey( - registry.CURRENT_USER, runKeyPath, registry.SET_VALUE, + runKeyRegistryHive, runKeyRegistryPath, registry.SET_VALUE, ) switch { case err == nil: @@ -173,26 +215,18 @@ func unregisterService() error { } func serviceStatusCheck() (ServiceStatus, error) { - running, err := engineRunning() - if err != nil { - return ServiceNotRegistered, err - } - if running { + if pid, ok := readEnginePID(); ok && processAlive(pid) { return ServiceRunning, nil } - registered, err := serviceRegistered() - if err != nil { - return ServiceNotRegistered, err - } - if registered { + if serviceRegistered() { return ServiceRegistered, nil } return ServiceNotRegistered, nil } -// startService (re)starts the engine. It first terminates any running engine -// AND waits for it to exit, so a config change can never leave two instances -// racing for the API port, then launches the recorded command detached and +// 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 { command, err := registeredCommand() @@ -212,9 +246,8 @@ func startService() error { return fmt.Errorf("stopping existing engine before start: %w", err) } - // Redirect the detached engine's output to a log file; a DETACHED_PROCESS - // has no console, so its stderr would otherwise be an invalid handle and - // its logs lost. Failure to open the log is non-fatal (engine still runs). + // 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( @@ -227,11 +260,12 @@ func startService() error { defer logHandle.Close() } - cmd := exec.Command(argv[0], argv[1:]...) //nolint:gosec // argv from our own recorded command + 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, console-less) tray. - // CREATE_NO_WINDOW would be ignored alongside DETACHED_PROCESS, so it is - // not set. HideWindow guards any incidental GUI window. + // runs windowless and independent of the (windowsgui) tray. HideWindow + // guards any incidental GUI window. cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: windows.DETACHED_PROCESS, @@ -243,20 +277,49 @@ func startService() error { if err := cmd.Start(); err != nil { return fmt.Errorf("starting adder engine: %w", err) } - // Record the launched PID so a later stop can terminate this exact - // process by PID even if its on-disk image is renamed (an MSI upgrade - // renames an in-use adder.exe to a .rbf rollback file, which would - // otherwise escape image-name matching). - // Detach: do not Wait; the engine outlives the tray. + // Record the PID and detach: the engine outlives the tray. if cmd.Process != nil { - writeEnginePID(cmd.Process.Pid) + 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 { - return terminateEngine() + 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 + } + if rmErr := os.Remove(enginePIDFile()); rmErr != nil && + !errors.Is(rmErr, os.ErrNotExist) { + slog.Warn("could not remove engine pid file", "error", rmErr) + } + return nil } // registeredCommand returns the engine command recorded by registerService, or @@ -276,43 +339,22 @@ func registeredCommand() (string, error) { return command, nil } -// serviceRegistered reports whether the tray autostart Run value exists. -func serviceRegistered() (bool, error) { - k, err := registry.OpenKey( - registry.CURRENT_USER, runKeyPath, registry.QUERY_VALUE, - ) - if err != nil { - if errors.Is(err, registry.ErrNotExist) { - return false, nil - } - return false, fmt.Errorf("opening Run registry key: %w", err) - } - defer k.Close() - if _, _, err := k.GetStringValue(runValueName); err != nil { - if errors.Is(err, registry.ErrNotExist) { - return false, nil - } - return false, fmt.Errorf("reading Run registry value: %w", err) - } - return true, nil -} - -// enginePIDFile records the PID of the engine the tray launched, so it can be -// terminated by PID even after its on-disk image is renamed. -func enginePIDFile() string { - return filepath.Join(ConfigDir(), "engine.pid") +// serviceRegistered reports whether the tray autostart and command mirror are +// both present and current. +func serviceRegistered() bool { + return existingUnit() != nil } -func writeEnginePID(pid int) { +func writeEnginePID(pid int) error { if err := os.MkdirAll(filepath.Dir(enginePIDFile()), 0o755); err != nil { - slog.Warn("could not create dir for engine pid file", "error", err) - return + return fmt.Errorf("creating engine pid directory: %w", err) } if err := os.WriteFile( enginePIDFile(), []byte(strconv.Itoa(pid)), 0o600, ); err != nil { - slog.Warn("could not write engine pid file", "error", err) + return fmt.Errorf("writing engine pid file: %w", err) } + return nil } func readEnginePID() (uint32, bool) { @@ -327,72 +369,38 @@ func readEnginePID() (uint32, bool) { return uint32(n), true } -// engineRunning reports whether at least one of our engine processes is -// currently running. -func engineRunning() (bool, error) { - found := false - err := forEachEngineProcess(func(uint32) bool { - found = true - return false // stop at the first match - }) - return found, err -} - -// terminateEngine terminates every running engine process and waits (bounded) -// for each to exit so a subsequent start does not race for the API port. It -// kills both the PID we recorded at launch (robust to a renamed image) and any -// process whose image lives in our install directory (robust to the MSI .rbf -// rename). A process that cannot be terminated but is still alive is a hard -// failure, so the caller does not stack a second engine on a survivor. -func terminateEngine() error { - var firstErr error - killed := make(map[uint32]bool) - - // 1. The exact PID we launched, even if its image was renamed. - if pid, ok := readEnginePID(); ok { - killed[pid] = true - if err := terminatePID(pid); err != nil { - firstErr = err - } - } - - // 2. Any remaining engine process (image under the install dir). - err := forEachEngineProcess(func(pid uint32) bool { - if killed[pid] { - return true - } - killed[pid] = true - if terr := terminatePID(pid); terr != nil && firstErr == nil { - firstErr = terr - } - return true - }) - if rmErr := os.Remove(enginePIDFile()); rmErr != nil && - !errors.Is(rmErr, os.ErrNotExist) { - slog.Warn("could not remove engine pid file", "error", rmErr) - } - if err != nil { - return err - } - return firstErr -} - // terminatePID terminates a single process and waits (bounded) for it to exit. -// An already-gone process is success. A process that cannot be opened but is -// still alive (e.g. a leftover started at higher integrity) is a hard failure. -func terminatePID(pid uint32) error { +// 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) { + 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) } - return nil // already exited + slog.Warn( + "stale engine pid reused by another process; ignoring", + "pid", pid, "image", actual, "error", err, + ) + return nil } - defer windows.CloseHandle(h) + defer func() { _ = windows.CloseHandle(h) }() if terr := windows.TerminateProcess(h, 1); terr != nil { return fmt.Errorf("terminating engine process %d: %w", pid, terr) } @@ -419,7 +427,7 @@ func processAlive(pid uint32) bool { if err != nil { return errors.Is(err, windows.ERROR_ACCESS_DENIED) } - defer windows.CloseHandle(h) + defer func() { _ = windows.CloseHandle(h) }() var code uint32 if err := windows.GetExitCodeProcess(h, &code); err != nil { return true @@ -428,150 +436,128 @@ func processAlive(pid uint32) bool { return code == stillActive } -// forEachEngineProcess walks the process table and invokes fn with the PID of -// every one of our engine processes (excluding this process). fn returns false -// to stop early. -func forEachEngineProcess(fn func(pid uint32) bool) error { - installDir := engineInstallDir() - self := uint32(os.Getpid()) - - snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) - if err != nil { - return fmt.Errorf("snapshotting processes: %w", err) - } - defer windows.CloseHandle(snap) - - var entry windows.ProcessEntry32 - entry.Size = uint32(unsafe.Sizeof(entry)) - err = windows.Process32First(snap, &entry) - for err == nil { - pid := entry.ProcessID - name := windows.UTF16ToString(entry.ExeFile[:]) - if pid != self && isOwnEngineProcess(pid, name, installDir) { - if !fn(pid) { - return nil - } - } - err = windows.Process32Next(snap, &entry) - } - // ERROR_NO_MORE_FILES marks the clean end of enumeration. - if errors.Is(err, windows.ERROR_NO_MORE_FILES) { - return nil - } - return err -} - -// engineInstallDir returns the directory the engine binary lives in, or "". -func engineInstallDir() string { - if p := engineExePath(); p != "" { - return filepath.Dir(p) - } - return "" -} - -// isOwnEngineProcess reports whether pid is one of our engine processes: its -// image lives in our install directory. Matching by directory (not file name) -// also catches an engine whose exe an MSI upgrade renamed to a .rbf rollback -// file, since the rename stays in the same directory. The tray executable is -// excluded. When the image path cannot be read, fall back to the engine image -// name so an unreadable-path engine is still caught. -func isOwnEngineProcess(pid uint32, name, installDir string) bool { - if strings.EqualFold(name, trayImage) { - return false - } - full, err := fullProcessPath(pid) - if err != nil || full == "" { - return strings.EqualFold(name, engineImage) - } - if strings.EqualFold(filepath.Base(full), trayImage) { - return false - } - if installDir == "" { - return strings.EqualFold(name, engineImage) - } - return pathUnderDir(full, installDir) -} - -// pathUnderDir reports whether path is inside dir, comparing normalized -// (symlink-resolved, long-form, cleaned) case-insensitive paths. -func pathUnderDir(path, dir string) bool { - nd := normalizeWinPath(dir) - if nd == "" { +// 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 } - np := normalizeWinPath(path) - prefix := strings.ToLower(strings.TrimRight(nd, `\/`)) + - string(filepath.Separator) - return strings.HasPrefix(strings.ToLower(np), prefix) + return strings.EqualFold(expectImage, actual) } -// normalizeWinPath canonicalizes a Windows path so that short (8.3) vs long -// names, symlink/junction form, and separator/case differences do not cause a -// false mismatch. Steps that require the path to exist degrade gracefully. -func normalizeWinPath(p string) string { - if p == "" { +// 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 { + command, err := registeredCommand() + if err != nil { return "" } - if r, err := filepath.EvalSymlinks(p); err == nil && r != "" { - p = r - } - if l, err := longPathName(p); err == nil && l != "" { - p = l + argv, err := windows.DecomposeCommandLine(command) + if err != nil || len(argv) == 0 { + return "" } - return filepath.Clean(p) + return filepath.Base(argv[0]) } -// longPathName expands a path to its long (non-8.3) form. Requires the path to -// exist; returns an error otherwise. -func longPathName(p string) (string, error) { - from, err := windows.UTF16PtrFromString(p) +// 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 "", err + return nil } - buf := make([]uint16, windows.MAX_PATH) - n, err := windows.GetLongPathName(from, &buf[0], uint32(len(buf))) - if err != nil { - return "", err + argv, err := windows.DecomposeCommandLine(command) + if err != nil || len(argv) == 0 { + return nil } - if n > uint32(len(buf)) { - buf = make([]uint16, n) - n, err = windows.GetLongPathName(from, &buf[0], uint32(len(buf))) - if err != nil { - return "", err + installDir := filepath.Dir(argv[0]) + self := uint32(os.Getpid()) + return forEachProcess(func(pid uint32) error { + if pid == self { + return nil } - } - return windows.UTF16ToString(buf[:n]), 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") } -// engineExePath returns the engine's executable path from the recorded -// command, or "" if it cannot be determined (in which case process matching -// falls back to image-name only). -func engineExePath() string { - cmd, err := registeredCommand() +func forEachProcess(fn func(uint32) error) error { + snap, err := windows.CreateToolhelp32Snapshot( + windows.TH32CS_SNAPPROCESS, 0, + ) if err != nil { - return "" + return fmt.Errorf("snapshotting processes: %w", err) } - argv, err := windows.DecomposeCommandLine(cmd) - if err != nil || len(argv) == 0 { - return "" + 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) } - return argv[0] + if errors.Is(err, windows.ERROR_NO_MORE_FILES) { + return nil + } + return err } -// fullProcessPath returns the full image path of the process pid via -// QueryFullProcessImageName. func fullProcessPath(pid uint32) (string, error) { - h, err := windows.OpenProcess( + handle, err := windows.OpenProcess( windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid, ) if err != nil { return "", err } - defer windows.CloseHandle(h) + defer func() { _ = windows.CloseHandle(handle) }() buf := make([]uint16, 1024) size := uint32(len(buf)) - if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size); err != nil { + 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 0000000..9ca80f6 --- /dev/null +++ b/tray/setup/service_windows_test.go @@ -0,0 +1,239 @@ +// 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 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 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 b5b45d8..42697be 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 0e1dfa8..3bbc3b4 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 27c2683..17c2be3 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 0000000..b6bc5da --- /dev/null +++ b/tray/wizard/filter_logic.go @@ -0,0 +1,26 @@ +// 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/widget" +) + +func newFilterLogicLabel(text string) *widget.Label { + label := widget.NewLabel(text) + label.Wrapping = fyne.TextWrapWord + return label +} diff --git a/tray/wizard/rules_editor.go b/tray/wizard/rules_editor.go index 86ee4c6..7bd8ad1 100644 --- a/tray/wizard/rules_editor.go +++ b/tray/wizard/rules_editor.go @@ -135,6 +135,12 @@ func NewRulesEditor( fyne.TextAlignLeading, fyne.TextStyle{Bold: true}, ), + newFilterLogicLabel( + "Matching logic: OR within each target section; compatible "+ + "target sections narrow matching alerts together. "+ + "Notification Preferences choose which matching alert "+ + "types are shown.", + ), e.everythingCheck, e.targetsBox, widget.NewSeparator(), diff --git a/tray/wizard/step3_template.go b/tray/wizard/step3_template.go index dfded13..f0f0820 100644 --- a/tray/wizard/step3_template.go +++ b/tray/wizard/step3_template.go @@ -158,6 +158,10 @@ func (s *templateStep) Content() fyne.CanvasObject { fyne.TextAlignLeading, fyne.TextStyle{Bold: true}, ), + newFilterLogicLabel( + "Matching logic: OR within each target section; compatible "+ + "target sections narrow matching alerts together.", + ), s.everythingCheck, s.targetsBox, s.summaryLabel, diff --git a/tray/wizard/step4_notifications.go b/tray/wizard/step4_notifications.go index b902228..b7bd7ab 100644 --- a/tray/wizard/step4_notifications.go +++ b/tray/wizard/step4_notifications.go @@ -259,7 +259,8 @@ func (s *notificationsStep) getCheckLabels() []string { setup.NotifyPrefRegChanges) } if len(s.plan.Filter.Pools) > 0 { - add(setup.NotifyPrefBlocksMinted) + add(setup.NotifyPrefBlocksMinted, + setup.NotifyPrefPoolParams) } if len(s.plan.Filter.Assets) > 0 { add(setup.NotifyPrefAssetActivity) diff --git a/tray/wizard/steps_test.go b/tray/wizard/steps_test.go index 60ee2c2..dd9489a 100644 --- a/tray/wizard/steps_test.go +++ b/tray/wizard/steps_test.go @@ -452,6 +452,7 @@ func TestNotificationsStepLabelsAndApply(t *testing.T) { filter: setup.FilterConfig{Pools: []string{"p"}}, want: []string{ setup.NotifyPrefBlocksMinted, + setup.NotifyPrefPoolParams, }, }, { @@ -471,6 +472,7 @@ func TestNotificationsStepLabelsAndApply(t *testing.T) { setup.NotifyPrefVotesCast, setup.NotifyPrefRegChanges, setup.NotifyPrefBlocksMinted, + setup.NotifyPrefPoolParams, }, }, {name: "empty plan", filter: setup.FilterConfig{}}, @@ -530,6 +532,7 @@ func TestNotificationsStepApplyOverwritesStalePrefs(t *testing.T) { setup.NotifyPrefTokenTransfers: true, // Stale, from a prior "Monitor Pool" run. setup.NotifyPrefBlocksMinted: true, + setup.NotifyPrefPoolParams: true, // Still relevant. setup.NotifyPrefConnectionIssues: true, }, From ced68dbdaa29cc1f6a2f7d21aeacf01d136652d9 Mon Sep 17 00:00:00 2001 From: Ales Verbic Date: Mon, 13 Jul 2026 21:50:15 -0400 Subject: [PATCH 14/15] feat(tray): add configurable target filtering Signed-off-by: Ales Verbic --- .github/workflows/test-msi.yml | 3 - README.md | 6 + api/events.go | 28 +- api/events_test.go | 23 ++ docs/adder-tray-filtering.md | 451 ++++++++++++++++++++++++++ tray/app.go | 109 ++++--- tray/app_helpers_test.go | 91 ++++-- tray/events.go | 24 +- tray/events_test.go | 48 +++ tray/notifications/engine_test.go | 27 +- tray/notifications/rules.go | 207 ++++++------ tray/notifications/rules_test.go | 70 +++- tray/setup/codec_test.go | 2 + tray/setup/plan.go | 177 ++++++++-- tray/setup/plan_paths_service_test.go | 118 ++++++- tray/setup/plan_test.go | 2 + tray/wizard/filter_logic.go | 40 +++ tray/wizard/rules_editor.go | 102 +++++- tray/wizard/rules_editor_test.go | 31 ++ tray/wizard/step3_template.go | 180 +++++----- tray/wizard/steps_test.go | 35 +- tray/wizard/target_section.go | 61 +++- tray/wizard/wizard_capture_test.go | 37 +++ 23 files changed, 1535 insertions(+), 337 deletions(-) create mode 100644 docs/adder-tray-filtering.md diff --git a/.github/workflows/test-msi.yml b/.github/workflows/test-msi.yml index 883e986..4e9a455 100644 --- a/.github/workflows/test-msi.yml +++ b/.github/workflows/test-msi.yml @@ -1,9 +1,6 @@ name: test-msi on: - push: - branches: - - feat/windows-msi-689 workflow_dispatch: inputs: arch: diff --git a/README.md b/README.md index b65f299..64322a4 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/events.go b/api/events.go index 4e66447..3297b3d 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 bba8401..17dd8d3 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/docs/adder-tray-filtering.md b/docs/adder-tray-filtering.md new file mode 100644 index 0000000..da8dbb4 --- /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/tray/app.go b/tray/app.go index 8ead1da..e408b23 100644 --- a/tray/app.go +++ b/tray/app.go @@ -88,14 +88,16 @@ type App struct { // Written from menu handlers and read from the status observer // goroutine, hence atomic. intentionalStop atomic.Bool - // applying is set for the duration of an Apply (wizard finish / - // reconfigure / notification-rules edit), which restarts the engine - // and briefly cycles the connection. It suppresses the transient - // "Lost connection"/"Reconnected" alerts that restart would - // otherwise emit. Written from applyPlan, read from the status - // observer goroutine, hence atomic. - applying 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 @@ -276,20 +278,21 @@ func (a *App) applyPlan( ) (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 the observer - // consumes-and-clears this flag on the first StatusConnected; the - // StatusConnected or StatusError consumes the flag after queued status - // transitions are delivered. Clearing it when Apply returns races that - // asynchronous delivery and leaks phantom connection notifications. - a.applying.Store(true) + // 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.applying.Store(false) + a.applyDeadline.Store(0) return result, err } if ctx.Err() != nil { - a.applying.Store(false) + a.applyDeadline.Store(0) } a.configMu.Lock() a.config = result.TrayConfig @@ -657,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", @@ -682,11 +686,21 @@ func (a *App) setupTray() { // During an Apply the engine restart cycles the connection // (stopped → connected). Suppress the phantom Lost/Reconnected - // alerts that would otherwise fire, and consume the flag on the - // settling StatusConnected so genuine drops after Apply still - // report. Errors are never suppressed. - if consumeApplyingStatus(&a.applying, s) { - return + // 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 @@ -704,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.") + } } }) @@ -962,23 +978,38 @@ func getEmojiForType(evtType string) string { // a still-tracked event. const recentEventKeyCap = 128 -// suppressConnAlert reports whether a connection-status notification -// should be skipped because an Apply-driven restart is in flight. -// Errors always surface; any other transition during an Apply is a -// side effect of the restart, not a real connection change. -func suppressConnAlert(applying bool, s Status) bool { - return applying && s != StatusError -} +// 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 -// consumeApplyingStatus updates the Apply suppression lifecycle and reports -// whether the current connection alert should be skipped. Connected settles a -// successful Apply; Error settles a failed Apply but still surfaces the error. -func consumeApplyingStatus(applying *atomic.Bool, s Status) bool { - inFlight := applying.Load() - if inFlight && (s == StatusConnected || s == StatusError) { - applying.Store(false) +// 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 suppressConnAlert(inFlight, s) + return true, false } // recentLabel builds a recent-events menu label. The notification title diff --git a/tray/app_helpers_test.go b/tray/app_helpers_test.go index 114d609..0933f22 100644 --- a/tray/app_helpers_test.go +++ b/tray/app_helpers_test.go @@ -280,28 +280,75 @@ func TestAddRecentAlertConnectionEventsNotDeduped(t *testing.T) { }, time.Second, 10*time.Millisecond) } -func TestSuppressConnAlert(t *testing.T) { - // Not applying: nothing suppressed. - assert.False(t, suppressConnAlert(false, StatusStopped)) - assert.False(t, suppressConnAlert(false, StatusConnected)) - // Applying: transient stop/connect suppressed, errors never. - assert.True(t, suppressConnAlert(true, StatusStopped)) - assert.True(t, suppressConnAlert(true, StatusConnected)) - assert.False(t, suppressConnAlert(true, StatusError)) -} - -func TestConsumeApplyingStatus(t *testing.T) { - var applying atomic.Bool - applying.Store(true) - - assert.True(t, consumeApplyingStatus(&applying, StatusStopped)) - assert.True(t, applying.Load()) - assert.True(t, consumeApplyingStatus(&applying, StatusConnected)) - assert.False(t, applying.Load()) - - applying.Store(true) - assert.False(t, consumeApplyingStatus(&applying, StatusError)) - assert.False(t, applying.Load()) +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) { diff --git a/tray/events.go b/tray/events.go index 7b5b8d5..08c33c1 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 18d5320..4d28822 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_test.go b/tray/notifications/engine_test.go index 5cad184..3416676 100644 --- a/tray/notifications/engine_test.go +++ b/tray/notifications/engine_test.go @@ -219,10 +219,9 @@ func TestEngine_RateLimitResetsAfterQuietGap(t *testing.T) { assert.Equal(t, 1, r3.Count) } -// TestEngine_CompatibleTargetGroupsANDSemantics asserts that compatible -// target sections narrow matching on the same event, while values within -// each section remain ORed by the rule matcher. -func TestEngine_CompatibleTargetGroupsANDSemantics(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"}, @@ -239,23 +238,19 @@ func TestEngine_CompatibleTargetGroupsANDSemantics(t *testing.T) { defer eng.Stop() events <- txEventTo("addr1xyz") - select { - case r := <-eng.Requests(): - t.Fatalf("unexpected request for wallet-only event: %+v", r) - case <-time.After(150 * time.Millisecond): - } + req, ok := recvRequest(t, eng.Requests()) + require.True(t, ok) + assert.Equal(t, "wallet-in", req.RuleID) events <- txWithTokens([2]string{"polA", "asset1abc"}) - select { - case r := <-eng.Requests(): - t.Fatalf("unexpected request for asset-only event: %+v", r) - case <-time.After(150 * time.Millisecond): - } + 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()) + req, ok = recvRequest(t, eng.Requests()) require.True(t, ok) - assert.Contains(t, req.RuleID, "wallet") + assert.Contains(t, []string{"wallet-in", "asset-activity"}, req.RuleID) } // TestEngine_SetRulesDrainsStaleRequests guards the review finding diff --git a/tray/notifications/rules.go b/tray/notifications/rules.go index bcbcbdf..29338ba 100644 --- a/tray/notifications/rules.go +++ b/tray/notifications/rules.go @@ -224,12 +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 per-target lists independently. -// Values inside a list OR together; compatible non-empty lists on the -// same event family AND together (for example wallet + asset on a tx). -// Event families that cannot carry the same fields keep their own -// event-shaped matching. When MonitorEverything is on the per-target -// lists are ignored and a single coarse rule per event type is emitted. +// 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 @@ -248,34 +247,22 @@ func RulesFromPlan(plan setup.SetupPlan) []Rule { if plan.Filter.MonitorEverything { rules = append(rules, everythingRules(prefs)...) } else { + 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)...) + targetRules = append(targetRules, + drepRules(prefs, plan.Filter.DReps)...) + targetRules = append(targetRules, + poolRules(prefs, plan.Filter.Pools)...) + targetRules = append(targetRules, + assetRules(prefs, plan.Filter.Assets)...) + targetRules = append(targetRules, + policyRules(prefs, plan.Filter.Policies)...) rules = append(rules, - withTargetFilter( - walletRules(prefs, plan.Filter.Wallets), - transactionTargetFilterMatcher(plan.Filter, true), - )...) - rules = append(rules, - withTargetFilter( - drepRules(prefs, plan.Filter.DReps), - // DRep rules own their subtype-specific matching: - // proposals are target-independent, while votes and - // registrations match the selected DRep IDs. - nil, - )...) - rules = append(rules, - withTargetFilter( - poolRules(prefs, plan.Filter.Pools), - poolTargetFilterMatcher(plan.Filter), - )...) - rules = append(rules, - withTargetFilter( - assetRules(prefs, plan.Filter.Assets), - transactionTargetFilterMatcher(plan.Filter, false), - )...) - rules = append(rules, - withTargetFilter( - policyRules(prefs, plan.Filter.Policies), - transactionTargetFilterMatcher(plan.Filter, false), - )...) + applyStandardExpression(targetRules, plan.Filter)...) } // Rollback fires for fork resolutions regardless of monitored @@ -302,74 +289,103 @@ func RulesFromPlan(plan setup.SetupPlan) []Rule { return rules } -func transactionTargetFilterMatcher( - f setup.FilterConfig, - requireWalletMatch bool, -) func(event.Event) bool { - var matchers []func(event.Event) bool - paymentAddrs := make([]string, 0, len(f.Wallets)) - for _, wallet := range f.Wallets { - if !strings.HasPrefix(strings.ToLower(wallet), "stake") { - paymentAddrs = append(paymentAddrs, wallet) +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) } } - if len(paymentAddrs) > 0 { - addrs := stringSet(paymentAddrs) - matchers = append(matchers, func(evt event.Event) bool { - return matchAnyOutputAddress(addrs)(evt) || - matchAnyResolvedInputAddress(addrs)(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, }) - } else if requireWalletMatch && len(f.Wallets) > 0 { - matchers = append(matchers, func(event.Event) bool { return false }) } - if len(f.Assets) > 0 { - matchers = append(matchers, matchAnyAsset(f.Assets)) - } - if len(f.Policies) > 0 { - matchers = append(matchers, matchAnyPolicy(f.Policies)) + if len(filter.DReps) > 0 { + conditions = append(conditions, joinedCondition{ + match: matchDRepActivity(filter.DReps), + join: filter.ResolvedDRepMatch(), + }) } - if len(matchers) == 0 { - return nil + if len(filter.Pools) > 0 { + conditions = append(conditions, joinedCondition{ + match: matchBlockIssuer(filter.Pools), + join: filter.ResolvedPoolMatch(), + }) } - return func(evt event.Event) bool { - for _, match := range matchers { - if !match(evt) { - return false - } - } - return true + if len(filter.Assets) > 0 { + conditions = append(conditions, joinedCondition{ + match: matchAnyAsset(filter.Assets), + join: filter.ResolvedAssetMatch(), + }) } -} - -func poolTargetFilterMatcher(f setup.FilterConfig) func(event.Event) bool { - if len(f.Pools) > 0 { - return matchBlockIssuer(f.Pools) + if len(filter.Policies) > 0 { + conditions = append(conditions, joinedCondition{ + match: matchAnyPolicy(filter.Policies), + join: filter.ResolvedPolicyMatch(), + }) } - return nil -} - -func withTargetFilter(rules []Rule, filter func(event.Event) bool) []Rule { - if filter == nil { - return rules + if len(conditions) == 0 { + return nil } - for i := range rules { - rule := rules[i] - rules[i].CustomMatch = func(evt event.Event) bool { - if !rulePredicateMatches(rule, evt) { - return false + 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 filter(evt) } - rules[i].MatchExpr = "" + return groupMatches } - return rules } -func rulePredicateMatches(r Rule, evt event.Event) bool { - if r.CustomMatch != nil { - return r.CustomMatch(evt) +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) } - return evalMatchExpr(r.MatchExpr, evt) } // coarseRule describes one of the broad event-family rules emitted in @@ -678,10 +694,11 @@ func lowerSet(vals []string) map[string]struct{} { return set } -// stringSet returns a set of the input strings matched exactly. Unlike -// lowerSet it does not fold case: wallet/asset/policy identifiers are compared -// verbatim, whereas pool/DRep IDs (lowerSet) may be given in hex or bech32 and -// are matched case-insensitively. +// 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 { @@ -805,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 56a7602..9e42576 100644 --- a/tray/notifications/rules_test.go +++ b/tray/notifications/rules_test.go @@ -252,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 @@ -747,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 @@ -798,11 +851,10 @@ func TestMatchAnyAsset_PayloadEdgeCases(t *testing.T) { } } -// TestRulesFromPlan_CompatibleTargetGroupsANDSemantics guards the UI -// promise for target groups that can coexist on one event: entries -// inside a target section OR together, while populated compatible -// sections AND together. -func TestRulesFromPlan_CompatibleTargetGroupsANDSemantics(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{"addr1watch"}, @@ -815,11 +867,11 @@ func TestRulesFromPlan_CompatibleTargetGroupsANDSemantics(t *testing.T) { } rules := RulesFromPlan(plan) - require.False(t, anyRuleMatches(rules, txEventTo("addr1watch")), - "wallet-only tx must not match when asset is also configured") - require.False(t, + 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-only tx must not match when wallet is also configured") + "asset-only tx should match its independent target") require.True(t, anyRuleMatches( rules, diff --git a/tray/setup/codec_test.go b/tray/setup/codec_test.go index a7f161a..ce2ac1b 100644 --- a/tray/setup/codec_test.go +++ b/tray/setup/codec_test.go @@ -443,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/plan.go b/tray/setup/plan.go index d346c1a..6efcc99 100644 --- a/tray/setup/plan.go +++ b/tray/setup/plan.go @@ -98,18 +98,57 @@ type NetworkConfig struct { CustomPort uint } -// FilterConfig defines the user's monitoring targets. Values inside -// each list OR together; non-empty lists AND together. MonitorEverything -// ignores the per-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. +// 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 @@ -181,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 c862361..e686789 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") diff --git a/tray/setup/plan_test.go b/tray/setup/plan_test.go index d10d864..68657de 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/wizard/filter_logic.go b/tray/wizard/filter_logic.go index b6bc5da..1b5feba 100644 --- a/tray/wizard/filter_logic.go +++ b/tray/wizard/filter_logic.go @@ -16,7 +16,16 @@ 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 { @@ -24,3 +33,34 @@ func newFilterLogicLabel(text string) *widget.Label { 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 7bd8ad1..702d615 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 @@ -135,12 +139,7 @@ func NewRulesEditor( fyne.TextAlignLeading, fyne.TextStyle{Bold: true}, ), - newFilterLogicLabel( - "Matching logic: OR within each target section; compatible "+ - "target sections narrow matching alerts together. "+ - "Notification Preferences choose which matching alert "+ - "types are shown.", - ), + newFilterLogicLabel(filterLogicDescription), e.everythingCheck, e.targetsBox, widget.NewSeparator(), @@ -200,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 } @@ -229,17 +228,21 @@ 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. @@ -261,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 @@ -336,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 @@ -345,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 { @@ -366,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 ce00369..fd32a22 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 f0f0820..d6f134f 100644 --- a/tray/wizard/step3_template.go +++ b/tray/wizard/step3_template.go @@ -36,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). @@ -93,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) { @@ -129,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() } @@ -158,10 +150,7 @@ func (s *templateStep) Content() fyne.CanvasObject { fyne.TextAlignLeading, fyne.TextStyle{Bold: true}, ), - newFilterLogicLabel( - "Matching logic: OR within each target section; compatible "+ - "target sections narrow matching alerts together.", - ), + newFilterLogicLabel(filterLogicDescription), s.everythingCheck, s.targetsBox, s.summaryLabel, @@ -200,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()), ) } @@ -311,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", ) } } @@ -366,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/steps_test.go b/tray/wizard/steps_test.go index dd9489a..6dfbce2 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 diff --git a/tray/wizard/target_section.go b/tray/wizard/target_section.go index 8a34744..10b6f94 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_capture_test.go b/tray/wizard/wizard_capture_test.go index cf17675..faf5a9c 100644 --- a/tray/wizard/wizard_capture_test.go +++ b/tray/wizard/wizard_capture_test.go @@ -23,6 +23,7 @@ import ( "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" ) @@ -77,4 +78,40 @@ func TestCaptureSteps(t *testing.T) { 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", + }, + }, + }, + ) + } From e9241ecf650d812ce929cb58963dd592bb5622b8 Mon Sep 17 00:00:00 2001 From: Ales Verbic Date: Tue, 14 Jul 2026 15:18:30 -0400 Subject: [PATCH 15/15] fix(tray): validate Windows engine PID identity Only report the Windows engine as running when the recorded PID is alive and its full image path matches the registered command. Otherwise clear the stale PID so unchanged configurations fall through EnsureRunning and relaunch the engine. Cover missing identity, reused same-name foreign PIDs, valid matches, and concurrent PID replacement cleanup with Windows-specific tests. Signed-off-by: Ales Verbic --- tray/setup/service_windows.go | 100 ++++++++++++++++-- tray/setup/service_windows_test.go | 161 +++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+), 10 deletions(-) diff --git a/tray/setup/service_windows.go b/tray/setup/service_windows.go index e7801fa..34843c2 100644 --- a/tray/setup/service_windows.go +++ b/tray/setup/service_windows.go @@ -46,6 +46,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "syscall" "time" "unsafe" @@ -76,6 +77,7 @@ func serviceUnitPath() string { var ( runKeyRegistryHive = registry.CURRENT_USER runKeyRegistryPath = runKeyPath + enginePIDMu sync.Mutex ) // existingUnit reads and verifies both the registry autostart value pointing @@ -215,8 +217,22 @@ func unregisterService() error { } func serviceStatusCheck() (ServiceStatus, error) { - if pid, ok := readEnginePID(); ok && processAlive(pid) { - return ServiceRunning, 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 + } + slog.Warn( + "ignoring stale engine pid", + "pid", pid, + "expected", expected, + "actual", actual, + "expected_error", expectedErr, + "actual_error", actualErr, + ) + removeEnginePIDIf(pid) } if serviceRegistered() { return ServiceRegistered, nil @@ -315,10 +331,7 @@ func stopTrackedEngine( if err := terminate(pid, expectedEngineImage()); err != nil { return err } - if rmErr := os.Remove(enginePIDFile()); rmErr != nil && - !errors.Is(rmErr, os.ErrNotExist) { - slog.Warn("could not remove engine pid file", "error", rmErr) - } + removeEnginePID() return nil } @@ -346,6 +359,8 @@ func serviceRegistered() bool { } 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) } @@ -369,6 +384,29 @@ func readEnginePID() (uint32, bool) { 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. // @@ -451,15 +489,33 @@ func isOurEngine(expectImage, actual string, known bool) bool { // 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 { - command, err := registeredCommand() + 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 || len(argv) == 0 { - return "" + 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 filepath.Base(argv[0]) + return strings.EqualFold(filepath.Clean(expected), filepath.Clean(actual)) } // terminateUntrackedEngines recovers from a missing PID file by terminating @@ -523,6 +579,30 @@ func fullProcessPath(pid uint32) (string, error) { 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( diff --git a/tray/setup/service_windows_test.go b/tray/setup/service_windows_test.go index 9ca80f6..37fe836 100644 --- a/tray/setup/service_windows_test.go +++ b/tray/setup/service_windows_test.go @@ -88,6 +88,149 @@ func TestIsOurEngine(t *testing.T) { } } +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 { @@ -100,6 +243,24 @@ func TestWriteEnginePIDReturnsError(t *testing.T) { } } +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)