diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index c4aad22..297c2f2 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -1,62 +1,50 @@ name: Commit -on: +on: push: pull_request: env: - DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + # Leftover MSBuild nodes keep obj/ files open and break subsequent WPF markup compilation. + MSBUILDDISABLENODEREUSE: 1 jobs: - build: - name: Build + ci: + name: CI runs-on: windows-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 ref: ${{ github.ref }} - - name: Build solution - run: dotnet build -c Release - - name: Run tests - run: dotnet test -c Release --no-build --verbosity normal - - name: Pack NuGet packages - run: dotnet pack -c Release --no-build -o nuget - - name: Upload nuget packages as artifacts - uses: actions/upload-artifact@v4 - with: - name: NuGet - path: nuget - if-no-files-found: error - retention-days: 1 - - publish-app: - name: Publish Application - needs: [build] - runs-on: windows-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: '8.x' - name: Install GitVersion - uses: gittools/actions/gitversion/setup@v0.9.7 + uses: gittools/actions/gitversion/setup@v4.7.0 with: versionSpec: '6.x' - name: Run GitVersion id: gitversion - uses: gittools/actions/gitversion/execute@v0.9.7 - - name: Set VERSION environment variable + uses: gittools/actions/gitversion/execute@v4.7.0 + - name: Set version environment variables + id: version run: | $version = "${{ steps.gitversion.outputs.fullSemVer }}" + $safeVersion = $version -replace '[^a-zA-Z0-9\.\-]', '_' + $mp = "${{ steps.gitversion.outputs.majorMinorPatch }}" Add-Content $env:GITHUB_ENV "VERSION=$version" - - name: Normalize version - run: | - $rawVersion = $env:VERSION - $safeVersion = $rawVersion -replace '[^a-zA-Z0-9\.\-]', '_' Add-Content $env:GITHUB_ENV "SAFE_VERSION=$safeVersion" + Add-Content $env:GITHUB_ENV "INSTALLER_VERSION=$mp.0" + Add-Content $env:GITHUB_OUTPUT "safe_version=$safeVersion" + - name: Build solution + # NLogViewer.CI.slnf omits WiX so compile/test does not harvest a 1.0.0.0 MSI. + # -m:1 serializes the build: parallel projects race on the shared WPF obj/ folder. + run: dotnet build NLogViewer.CI.slnf -c Release -m:1 + - name: Run tests + run: dotnet test NLogViewer.CI.slnf -c Release --no-build --verbosity normal + - name: Pack NuGet packages + run: dotnet pack NLogViewer.CI.slnf -c Release --no-build -o nuget - name: Publish Self-Contained run: | dotnet publish app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj ` @@ -78,19 +66,56 @@ jobs: - name: Rename folders with version run: | $version = $env:SAFE_VERSION - Rename-Item -Path "publish/self-contained" -NewName "NLogViewer-SelfContained-win-x64-$version" - Rename-Item -Path "publish/framework-dependent" -NewName "NLogViewer-FrameworkDependent-win-x64-$version" + Rename-Item -Path "publish/self-contained" -NewName "Sentinel.LogViewer-SelfContained-win-x64-$version" + Rename-Item -Path "publish/framework-dependent" -NewName "Sentinel.LogViewer-FrameworkDependent-win-x64-$version" + - name: Add portable marker and readme to published folders + run: | + $version = $env:SAFE_VERSION + $folders = @( + "publish/Sentinel.LogViewer-SelfContained-win-x64-$version", + "publish/Sentinel.LogViewer-FrameworkDependent-win-x64-$version" + ) + foreach ($d in $folders) { + New-Item -ItemType File -Path (Join-Path $d "NLogViewer.portable") -Force | Out-Null + Copy-Item -Path "dist/portable/README-Portable.txt" -Destination (Join-Path $d "README-Portable.txt") -Force + } + - name: Build installer MSI + run: | + dotnet build installer/Sentinel.NLogViewer.Installer.wixproj ` + -c Release ` + -m:1 ` + -p:InstallerVersion=$env:INSTALLER_VERSION + $msi = Get-ChildItem -Path installer -Recurse -Filter Sentinel.LogViewer.msi | + Where-Object { $_.FullName -match '[\\/]bin[\\/]' } | + Select-Object -First 1 + if (-not $msi) { throw "Sentinel.LogViewer.msi was not produced under installer/bin" } + New-Item -ItemType Directory -Force -Path publish | Out-Null + Copy-Item $msi.FullName "publish/Sentinel.LogViewer-$($env:SAFE_VERSION)-win-x64.msi" + - name: Upload NuGet packages + uses: actions/upload-artifact@v7 + with: + name: NuGet-${{ steps.version.outputs.safe_version }} + path: nuget + if-no-files-found: error + retention-days: 7 + - name: Upload MSI + uses: actions/upload-artifact@v7 + with: + name: Sentinel.LogViewer-msi-${{ steps.version.outputs.safe_version }} + path: publish/Sentinel.LogViewer-${{ steps.version.outputs.safe_version }}-win-x64.msi + if-no-files-found: error + retention-days: 7 - name: Upload Self-Contained artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: NLogViewer-SelfContained-win-x64-${{ env.SAFE_VERSION }} - path: publish/NLogViewer-SelfContained-win-x64-${{ env.SAFE_VERSION }} + name: Sentinel.LogViewer-SelfContained-win-x64-${{ steps.version.outputs.safe_version }} + path: publish/Sentinel.LogViewer-SelfContained-win-x64-${{ steps.version.outputs.safe_version }} if-no-files-found: error - retention-days: 1 + retention-days: 7 - name: Upload Framework-Dependent artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: NLogViewer-FrameworkDependent-win-x64-${{ env.SAFE_VERSION }} - path: publish/NLogViewer-FrameworkDependent-win-x64-${{ env.SAFE_VERSION }} + name: Sentinel.LogViewer-FrameworkDependent-win-x64-${{ steps.version.outputs.safe_version }} + path: publish/Sentinel.LogViewer-FrameworkDependent-win-x64-${{ steps.version.outputs.safe_version }} if-no-files-found: error - retention-days: 1 \ No newline at end of file + retention-days: 7 diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml deleted file mode 100644 index f998e84..0000000 --- a/.github/workflows/pre-release.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: Pre-Release -on: [workflow_dispatch] -env: - DOTNET_CLI_TELEMETRY_OPTOUT: true -jobs: - build: - name: Build - runs-on: windows-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.ref }} - - name: Build solution - run: dotnet build -c Release - - name: Run tests - run: dotnet test -c Release --no-build --verbosity normal - - name: Pack NuGet packages - run: dotnet pack -c Release --no-build -o nuget - - name: Upload nuget packages as artifacts - uses: actions/upload-artifact@v4 - with: - name: NuGet - path: nuget - if-no-files-found: error - - publish-app: - name: Publish Application - needs: [build] - runs-on: windows-latest - if: github.event_name == 'workflow_dispatch' - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.ref }} - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.x' - - name: Install GitVersion - uses: gittools/actions/gitversion/setup@v0.9.7 - with: - versionSpec: '6.x' - - name: Run GitVersion - id: gitversion - uses: gittools/actions/gitversion/execute@v0.9.7 - - name: Set VERSION environment variable - run: | - $version = "${{ steps.gitversion.outputs.fullSemVer }}" - Add-Content $env:GITHUB_ENV "VERSION=$version" - - name: Publish Self-Contained - run: | - dotnet publish app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj ` - -c Release ` - -p:SelfContained=true ` - -p:PublishSingleFile=true ` - -p:RuntimeIdentifier=win-x64 ` - -p:IncludeNativeLibrariesForSelfExtract=true ` - -o publish/self-contained - - name: Publish Framework-Dependent - run: | - dotnet publish app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj ` - -c Release ` - -p:SelfContained=false ` - -p:RuntimeIdentifier=win-x64 ` - -o publish/framework-dependent - - name: Rename folders with version - run: | - $version = $env:VERSION - Rename-Item -Path "publish/self-contained" -NewName "NLogViewer-SelfContained-win-x64-$version" - Rename-Item -Path "publish/framework-dependent" -NewName "NLogViewer-FrameworkDependent-win-x64-$version" - - name: Upload Self-Contained artifact - uses: actions/upload-artifact@v4 - with: - name: NLogViewer-SelfContained-win-x64 - path: publish/NLogViewer-SelfContained-win-x64-${{ env.VERSION }} - if-no-files-found: error - - name: Upload Framework-Dependent artifact - uses: actions/upload-artifact@v4 - with: - name: NLogViewer-FrameworkDependent-win-x64 - path: publish/NLogViewer-FrameworkDependent-win-x64-${{ env.VERSION }} - if-no-files-found: error - - deploy: - name: Deploy - needs: [build] - runs-on: ubuntu-latest - steps: - - name: Download nuget artifacts - uses: actions/download-artifact@v4 - with: - name: NuGet - path: nuget - - name: Publish the package to GitHub Packages & nuget.org - run: | - dotnet nuget add source https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json --name github --username ${{ github.repository_owner }} --password ${{ github.token }} --store-password-in-clear-text - dotnet nuget push ./nuget/*.nupkg --source github - dotnet nuget push ./nuget/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} \ No newline at end of file diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml new file mode 100644 index 0000000..9d48976 --- /dev/null +++ b/.github/workflows/publish-dev.yml @@ -0,0 +1,239 @@ +name: Publish Dev + +on: + workflow_dispatch: + inputs: + publish: + description: Publish this build to GitHub Packages, nuget.org, Chocolatey, and a GitHub prerelease + required: true + type: boolean + default: false + +permissions: + contents: write + packages: write + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + MSBUILDDISABLENODEREUSE: 1 + +jobs: + publish-dev: + name: Build and publish dev + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ github.ref }} + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: '8.x' + + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v4.7.0 + with: + versionSpec: '6.x' + + - name: Run GitVersion + id: gitversion + uses: gittools/actions/gitversion/execute@v4.7.0 + + - name: Set development version + id: version + shell: pwsh + run: | + $baseVersion = "${{ steps.gitversion.outputs.majorMinorPatch }}" + $version = "$baseVersion-dev.${{ github.run_number }}.${{ github.run_attempt }}" + $runNumber = [int64]"${{ github.run_number }}" + $runAttempt = [int]"${{ github.run_attempt }}" + $chocolateyVersion = '{0}-dev{1:D10}{2:D3}' -f $baseVersion, $runNumber, $runAttempt + Add-Content $env:GITHUB_ENV "DEV_VERSION=$version" + Add-Content $env:GITHUB_ENV "CHOCO_VERSION=$chocolateyVersion" + Add-Content $env:GITHUB_ENV "INSTALLER_VERSION=$baseVersion.0" + Add-Content $env:GITHUB_OUTPUT "version=$version" + Add-Content $env:GITHUB_OUTPUT "chocolatey_version=$chocolateyVersion" + + - name: Build solution + run: dotnet build NLogViewer.CI.slnf -c Release -m:1 + + - name: Run tests + run: dotnet test NLogViewer.CI.slnf -c Release --no-build --verbosity normal + + - name: Pack development NuGet packages + run: | + dotnet pack NLogViewer.CI.slnf ` + -c Release ` + --no-build ` + -p:PackageVersion=$env:DEV_VERSION ` + -p:Version=$env:DEV_VERSION ` + -o nuget + + - name: Publish self-contained application + run: | + dotnet publish app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj ` + -c Release ` + -p:SelfContained=true ` + -p:PublishSingleFile=true ` + -p:RuntimeIdentifier=win-x64 ` + -p:Version=$env:DEV_VERSION ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -o publish/self-contained + + - name: Publish framework-dependent application + run: | + dotnet publish app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj ` + -c Release ` + -p:SelfContained=false ` + -p:RuntimeIdentifier=win-x64 ` + -p:Version=$env:DEV_VERSION ` + -o publish/framework-dependent + + - name: Prepare portable application folders + run: | + $folders = @( + @{ + Source = "publish/self-contained" + Target = "Sentinel.LogViewer-SelfContained-win-x64-$env:DEV_VERSION" + }, + @{ + Source = "publish/framework-dependent" + Target = "Sentinel.LogViewer-FrameworkDependent-win-x64-$env:DEV_VERSION" + } + ) + + foreach ($folder in $folders) { + Rename-Item -Path $folder.Source -NewName $folder.Target + $targetPath = Join-Path "publish" $folder.Target + New-Item -ItemType File -Path (Join-Path $targetPath "NLogViewer.portable") -Force | Out-Null + Copy-Item "dist/portable/README-Portable.txt" $targetPath -Force + } + + - name: Build installer MSI + run: | + dotnet build installer/Sentinel.NLogViewer.Installer.wixproj ` + -c Release ` + -m:1 ` + -p:InstallerVersion=$env:INSTALLER_VERSION + + $target = "publish/Sentinel.LogViewer-$env:DEV_VERSION-win-x64.msi" + Copy-Item "installer/bin/Release/Sentinel.LogViewer.msi" $target + + - name: Pack development Chocolatey package + run: | + New-Item -ItemType Directory -Force -Path chocolatey-packages | Out-Null + Copy-Item "publish/Sentinel.LogViewer-$env:DEV_VERSION-win-x64.msi" ` + "chocolatey/tools/Sentinel.LogViewer.msi" + choco pack chocolatey/Sentinel.LogViewer.nuspec ` + --version $env:CHOCO_VERSION ` + --output-directory chocolatey-packages + + - name: Upload NuGet packages + uses: actions/upload-artifact@v7 + with: + name: NuGet-${{ steps.version.outputs.version }} + path: nuget + if-no-files-found: error + retention-days: 7 + + - name: Upload MSI + uses: actions/upload-artifact@v7 + with: + name: Sentinel.LogViewer-msi-${{ steps.version.outputs.version }} + path: publish/Sentinel.LogViewer-${{ steps.version.outputs.version }}-win-x64.msi + if-no-files-found: error + retention-days: 7 + + - name: Upload self-contained application + uses: actions/upload-artifact@v7 + with: + name: Sentinel.LogViewer-SelfContained-win-x64-${{ steps.version.outputs.version }} + path: publish/Sentinel.LogViewer-SelfContained-win-x64-${{ steps.version.outputs.version }} + if-no-files-found: error + retention-days: 7 + + - name: Upload framework-dependent application + uses: actions/upload-artifact@v7 + with: + name: Sentinel.LogViewer-FrameworkDependent-win-x64-${{ steps.version.outputs.version }} + path: publish/Sentinel.LogViewer-FrameworkDependent-win-x64-${{ steps.version.outputs.version }} + if-no-files-found: error + retention-days: 7 + + - name: Upload development Chocolatey package + uses: actions/upload-artifact@v7 + with: + name: Chocolatey-Sentinel.LogViewer-${{ steps.version.outputs.chocolatey_version }} + path: chocolatey-packages + if-no-files-found: error + retention-days: 7 + + - name: Create portable ZIP archives + if: ${{ inputs.publish }} + run: | + $folders = @( + "publish/Sentinel.LogViewer-SelfContained-win-x64-$env:DEV_VERSION", + "publish/Sentinel.LogViewer-FrameworkDependent-win-x64-$env:DEV_VERSION" + ) + foreach ($folder in $folders) { + Compress-Archive -Path (Join-Path $folder '*') -DestinationPath "$folder.zip" + } + + - name: Create GitHub prerelease + if: ${{ inputs.publish }} + env: + GH_TOKEN: ${{ github.token }} + run: | + $tag = "dev-$env:DEV_VERSION" + $title = "Sentinel.LogViewer $env:DEV_VERSION" + $notes = @" + Development build from ``${{ github.ref_name }}`` (``${{ github.sha }}``). + + Install Chocolatey with ``choco upgrade Sentinel.LogViewer --pre -y``. + NuGet packages are prereleases and require a prerelease-capable restore. + "@ + $assets = @( + Get-ChildItem nuget -Filter '*.nupkg' + Get-ChildItem chocolatey-packages -Filter '*.nupkg' + Get-ChildItem publish -Filter '*.msi' + Get-ChildItem publish -Filter '*.zip' + ) | ForEach-Object { $_.FullName } + + gh release create $tag @assets ` + --prerelease ` + --title $title ` + --notes $notes ` + --target ${{ github.sha }} + + - name: Push development NuGet packages + if: ${{ inputs.publish }} + run: | + dotnet nuget add source ` + "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" ` + --name github ` + --username "${{ github.repository_owner }}" ` + --password "${{ github.token }}" ` + --store-password-in-clear-text + dotnet nuget push "nuget/*.nupkg" ` + --source github ` + --skip-duplicate + dotnet nuget push "nuget/*.nupkg" ` + --source "https://api.nuget.org/v3/index.json" ` + --api-key "${{ secrets.NUGET_API_KEY }}" ` + --skip-duplicate + + - name: Push development Chocolatey package + if: ${{ inputs.publish }} + run: | + $packages = @(Get-ChildItem chocolatey-packages -Filter "*.nupkg") + if ($packages.Count -ne 1) { + throw "Expected exactly one Chocolatey package, found $($packages.Count)." + } + $package = $packages[0] + choco push $package.FullName ` + --source "https://push.chocolatey.org/" ` + --api-key "${{ secrets.CHOCOLATEY_API_KEY }}" + diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index b18b7db..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Publish -on: [workflow_dispatch] -env: - DOTNET_CLI_TELEMETRY_OPTOUT: true -jobs: - build: - name: Build - runs-on: windows-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.ref }} - - name: Build solution - run: dotnet build -c Release - - name: Run tests - run: dotnet test -c Release --no-build --verbosity normal - - name: Pack NuGet packages - run: dotnet pack -c Release --no-build -o nuget - - name: Upload nuget packages as artifacts - uses: actions/upload-artifact@v4 - with: - name: NuGet - path: nuget - if-no-files-found: error - - publish-app: - name: Publish Application - needs: [build] - runs-on: windows-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.ref }} - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.x' - - name: Install GitVersion - uses: gittools/actions/gitversion/setup@v0.9.7 - with: - versionSpec: '6.x' - - name: Run GitVersion - id: gitversion - uses: gittools/actions/gitversion/execute@v0.9.7 - - name: Set VERSION environment variable - run: | - $version = "${{ steps.gitversion.outputs.fullSemVer }}" - Add-Content $env:GITHUB_ENV "VERSION=$version" - - name: Publish Self-Contained - run: | - dotnet publish app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj ` - -c Release ` - -p:SelfContained=true ` - -p:PublishSingleFile=true ` - -p:RuntimeIdentifier=win-x64 ` - -p:IncludeNativeLibrariesForSelfExtract=true ` - -o publish/self-contained - - name: Publish Framework-Dependent - run: | - dotnet publish app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj ` - -c Release ` - -p:SelfContained=false ` - -p:RuntimeIdentifier=win-x64 ` - -o publish/framework-dependent - - name: Rename folders with version - run: | - $version = $env:VERSION - Rename-Item -Path "publish/self-contained" -NewName "NLogViewer-SelfContained-win-x64-$version" - Rename-Item -Path "publish/framework-dependent" -NewName "NLogViewer-FrameworkDependent-win-x64-$version" - - name: Upload Self-Contained artifact - uses: actions/upload-artifact@v4 - with: - name: NLogViewer-SelfContained-win-x64 - path: publish/NLogViewer-SelfContained-win-x64-${{ env.VERSION }} - if-no-files-found: error - - name: Upload Framework-Dependent artifact - uses: actions/upload-artifact@v4 - with: - name: NLogViewer-FrameworkDependent-win-x64 - path: publish/NLogViewer-FrameworkDependent-win-x64-${{ env.VERSION }} - if-no-files-found: error - diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bdbc7b6..a48ae6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,25 +3,37 @@ on: push: tags: - '*' + +permissions: + contents: write + packages: write + env: - DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + # Leftover MSBuild nodes keep obj/ files open and break subsequent WPF markup compilation. + MSBUILDDISABLENODEREUSE: 1 jobs: build: name: Build runs-on: windows-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: '8.x' - name: Build solution - run: dotnet build -c Release + # -m:1 serializes the build: parallel projects race on the shared WPF obj/ folder. + run: dotnet build -c Release -m:1 - name: Run tests run: dotnet test -c Release --no-build --verbosity normal - name: Pack NuGet packages run: dotnet pack -c Release --no-build -o nuget - name: Upload nuget packages as artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: NuGet path: nuget @@ -33,24 +45,26 @@ jobs: runs-on: windows-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: '8.x' - name: Install GitVersion - uses: gittools/actions/gitversion/setup@v0.9.7 + uses: gittools/actions/gitversion/setup@v4.7.0 with: versionSpec: '6.x' - name: Run GitVersion id: gitversion - uses: gittools/actions/gitversion/execute@v0.9.7 + uses: gittools/actions/gitversion/execute@v4.7.0 - name: Set VERSION environment variable + id: version run: | $version = "${{ steps.gitversion.outputs.fullSemVer }}" Add-Content $env:GITHUB_ENV "VERSION=$version" + Add-Content $env:GITHUB_OUTPUT "version=$version" - name: Publish Self-Contained run: | dotnet publish app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj ` @@ -70,19 +84,57 @@ jobs: - name: Rename folders with version run: | $version = $env:VERSION - Rename-Item -Path "publish/self-contained" -NewName "NLogViewer-SelfContained-win-x64-$version" - Rename-Item -Path "publish/framework-dependent" -NewName "NLogViewer-FrameworkDependent-win-x64-$version" + Rename-Item -Path "publish/self-contained" -NewName "Sentinel.LogViewer-SelfContained-win-x64-$version" + Rename-Item -Path "publish/framework-dependent" -NewName "Sentinel.LogViewer-FrameworkDependent-win-x64-$version" + - name: Add portable marker and readme to published folders + run: | + $version = $env:VERSION + $folders = @( + "publish/Sentinel.LogViewer-SelfContained-win-x64-$version", + "publish/Sentinel.LogViewer-FrameworkDependent-win-x64-$version" + ) + foreach ($d in $folders) { + New-Item -ItemType File -Path (Join-Path $d "NLogViewer.portable") -Force | Out-Null + Copy-Item -Path "dist/portable/README-Portable.txt" -Destination (Join-Path $d "README-Portable.txt") -Force + } + - name: Build installer MSI + run: | + $mp = "${{ steps.gitversion.outputs.majorMinorPatch }}" + $installerVersion = "$mp.0" + dotnet build installer/Sentinel.NLogViewer.Installer.wixproj -c Release -m:1 -p:InstallerVersion=$installerVersion + New-Item -ItemType Directory -Force -Path publish | Out-Null + Copy-Item installer/bin/Release/Sentinel.LogViewer.msi "publish/Sentinel.LogViewer-$($env:VERSION)-win-x64.msi" + - name: Pack stable Chocolatey package + run: | + New-Item -ItemType Directory -Force -Path chocolatey-packages | Out-Null + Copy-Item "publish/Sentinel.LogViewer-$env:VERSION-win-x64.msi" ` + "chocolatey/tools/Sentinel.LogViewer.msi" + choco pack chocolatey/Sentinel.LogViewer.nuspec ` + --version $env:VERSION ` + --output-directory chocolatey-packages + - name: Upload MSI artifact + uses: actions/upload-artifact@v7 + with: + name: Sentinel.LogViewer-msi + path: publish/Sentinel.LogViewer-${{ steps.version.outputs.version }}-win-x64.msi + if-no-files-found: error + - name: Upload stable Chocolatey artifact + uses: actions/upload-artifact@v7 + with: + name: Chocolatey-Sentinel.LogViewer + path: chocolatey-packages + if-no-files-found: error - name: Upload Self-Contained artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: NLogViewer-SelfContained-win-x64 - path: publish/NLogViewer-SelfContained-win-x64-${{ env.VERSION }} + name: Sentinel.LogViewer-SelfContained-win-x64 + path: publish/Sentinel.LogViewer-SelfContained-win-x64-${{ steps.version.outputs.version }} if-no-files-found: error - name: Upload Framework-Dependent artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: NLogViewer-FrameworkDependent-win-x64 - path: publish/NLogViewer-FrameworkDependent-win-x64-${{ env.VERSION }} + name: Sentinel.LogViewer-FrameworkDependent-win-x64 + path: publish/Sentinel.LogViewer-FrameworkDependent-win-x64-${{ steps.version.outputs.version }} if-no-files-found: error publish: @@ -90,28 +142,41 @@ jobs: needs: [build, publish-app] runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: '8.x' - name: Download nuget artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: NuGet path: nuget - name: Download application artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: - name: NLogViewer-SelfContained-win-x64 + name: Sentinel.LogViewer-SelfContained-win-x64 path: artifacts - name: Download application artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: - name: NLogViewer-FrameworkDependent-win-x64 + name: Sentinel.LogViewer-FrameworkDependent-win-x64 + path: artifacts + - name: Download MSI artifact + uses: actions/download-artifact@v8 + with: + name: Sentinel.LogViewer-msi path: artifacts - name: Install GitVersion - uses: gittools/actions/gitversion/setup@v0.9.7 + uses: gittools/actions/gitversion/setup@v4.7.0 with: versionSpec: '6.x' - name: Run GitVersion id: gitversion - uses: gittools/actions/gitversion/execute@v0.9.7 + uses: gittools/actions/gitversion/execute@v4.7.0 - name: Create versioned ZIPs run: | cd artifacts @@ -136,9 +201,18 @@ jobs: done - name: Get release id: get_release - uses: bruceadams/get-release@v1.2.2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + result-encoding: string + script: | + const tag = context.ref.replace(/^refs\/tags\//, ''); + const { data } = await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag + }); + return String(data.id); - name: Upload artifacts to release uses: actions/github-script@v8 with: @@ -147,7 +221,7 @@ jobs: const fs = require('fs'); const path = require('path'); - const release_id = '${{ steps.get_release.outputs.id }}'; + const release_id = '${{ steps.get_release.outputs.result }}'; // Upload NuGet packages const nugetDir = './nuget'; @@ -173,35 +247,79 @@ jobs: } } - // Upload application artifacts (only ZIP files) - const artifactsDir = './artifacts'; - if (fs.existsSync(artifactsDir)) { - const artifactFiles = fs.readdirSync(artifactsDir); - for (const file of artifactFiles) { - const filePath = path.join(artifactsDir, file); + function collectFilesRecursive(dir, out) { + if (!fs.existsSync(dir)) return; + for (const name of fs.readdirSync(dir)) { + const filePath = path.join(dir, name); const stats = fs.statSync(filePath); - - // Only upload ZIP files, skip directories - if (stats.isFile() && file.endsWith('.zip')) { - const data = fs.readFileSync(filePath); - - console.log(`Uploading application asset: ${file} (${stats.size} bytes)`); - await github.rest.repos.uploadReleaseAsset({ - owner: context.repo.owner, - repo: context.repo.repo, - release_id: release_id, - name: file, - data: data, - headers: { - 'content-type': 'application/zip', - 'content-length': stats.size.toString() - } - }); + if (stats.isDirectory()) { + collectFilesRecursive(filePath, out); + } else if (stats.isFile()) { + out.push(filePath); } } } + + // Upload application artifacts (ZIP from bash step, plus MSI from nested artifact paths) + const artifactsDir = './artifacts'; + const artifactFiles = []; + collectFilesRecursive(artifactsDir, artifactFiles); + for (const filePath of artifactFiles) { + const file = path.basename(filePath); + const stats = fs.statSync(filePath); + if (file.endsWith('.zip')) { + const data = fs.readFileSync(filePath); + console.log(`Uploading application asset: ${file} (${stats.size} bytes)`); + await github.rest.repos.uploadReleaseAsset({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release_id, + name: file, + data: data, + headers: { + 'content-type': 'application/zip', + 'content-length': stats.size.toString() + } + }); + } else if (file.endsWith('.msi')) { + const data = fs.readFileSync(filePath); + console.log(`Uploading application asset: ${file} (${stats.size} bytes)`); + await github.rest.repos.uploadReleaseAsset({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release_id, + name: file, + data: data, + headers: { + 'content-type': 'application/octet-stream', + 'content-length': stats.size.toString() + } + }); + } + } - name: Publish the package to GitHub Packages & nuget.org run: | dotnet nuget add source https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json --name github --username ${{ github.repository_owner }} --password ${{ github.token }} --store-password-in-clear-text dotnet nuget push ./nuget/*.nupkg --source github - dotnet nuget push ./nuget/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} \ No newline at end of file + dotnet nuget push ./nuget/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} + + publish-chocolatey: + name: Publish Chocolatey + needs: [publish] + runs-on: windows-latest + steps: + - name: Download stable Chocolatey artifact + uses: actions/download-artifact@v8 + with: + name: Chocolatey-Sentinel.LogViewer + path: chocolatey-packages + - name: Publish stable Chocolatey package + run: | + $packages = @(Get-ChildItem chocolatey-packages -Filter "*.nupkg") + if ($packages.Count -ne 1) { + throw "Expected exactly one Chocolatey package, found $($packages.Count)." + } + $package = $packages[0] + choco push $package.FullName ` + --source "https://push.chocolatey.org/" ` + --api-key "${{ secrets.CHOCOLATEY_API_KEY }}" diff --git a/.gitignore b/.gitignore index cf9566c..3853b22 100644 --- a/.gitignore +++ b/.gitignore @@ -167,6 +167,9 @@ DocProject/Help/html # Click-Once directory publish/ +# NuGet package output directory used by `dotnet pack -o nuget` (CI + local) +nuget/ + # Publish Web Output *.[Pp]ublish.xml *.azurePubxml diff --git a/Directory.build.props b/Directory.build.props index a8cc613..102a773 100644 --- a/Directory.build.props +++ b/Directory.build.props @@ -10,6 +10,7 @@ sentinel nlog NLogViewer is a ui control library to visualize NLog logs LICENSE.md + PackageReadme.md true true snupkg @@ -26,5 +27,6 @@ LICENSE.md false + \ No newline at end of file diff --git a/GitVersion.yml b/GitVersion.yml index 59fa8d8..3833b2d 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -67,7 +67,7 @@ branches: is-main-branch: false pre-release-weight: 30000 feature: - mode: ManualDeployment + mode: ContinuousDelivery label: '{BranchName}' increment: Inherit prevent-increment: @@ -130,7 +130,8 @@ branches: is-main-branch: true pre-release-weight: 55000 unknown: - mode: ManualDeployment + # Increment the pre-release number with each commit (e.g. …-branch.1, …-branch.2). + mode: ContinuousDelivery label: '{BranchName}' increment: Inherit prevent-increment: diff --git a/LICENSE.md b/LICENSE.md index 26301b0..114f4ea 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Dominic Jonas +Copyright (c) 2026 Dominic Böxler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/NLogViewer.CI.slnf b/NLogViewer.CI.slnf new file mode 100644 index 0000000..849f1da --- /dev/null +++ b/NLogViewer.CI.slnf @@ -0,0 +1,15 @@ +{ + "solution": { + "path": "NLogViewer.sln", + "projects": [ + "app\\Sentinel.NLogViewer.App\\Sentinel.NLogViewer.App.csproj", + "app\\Sentinel.NLogViewer.TestLogging\\Sentinel.NLogViewer.TestLogging.csproj", + "ui\\Sentinel.NLogViewer.Wpf\\Sentinel.NLogViewer.Wpf.csproj", + "ui\\Sentinel.NLogViewer.Wpf.MaterialDesign\\Sentinel.NLogViewer.Wpf.MaterialDesign.csproj", + "testapp\\Sentinel.NLogViewer.App.TestApp\\Sentinel.NLogViewer.App.TestApp.csproj", + "testapp\\Sentinel.NLogViewer.Wpf.TestApp\\Sentinel.NLogViewer.Wpf.TestApp.csproj", + "testapp\\Sentinel.NLogViewer.Wpf.MaterialDesign.TestApp\\Sentinel.NLogViewer.Wpf.MaterialDesign.TestApp.csproj", + "tests\\Sentinel.NLogViewer.App.Tests\\Sentinel.NLogViewer.App.Tests.csproj" + ] + } +} diff --git a/NLogViewer.sln b/NLogViewer.sln index e4de234..e965b97 100644 --- a/NLogViewer.sln +++ b/NLogViewer.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 VisualStudioVersion = 18.0.11222.15 @@ -34,44 +34,128 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "app", "app", "{A1B2C3D4-E5F EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{1EBF655C-A427-41E1-8BFC-6A2429B7EF6E}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "installer", "installer", "{1860D2E4-5C43-71D5-59A5-599A4DE1DF67}" +EndProject +Project("{B7DD6F7E-DEF8-4E67-B5B7-07EF123DB6F0}") = "Sentinel.NLogViewer.Installer", "installer\Sentinel.NLogViewer.Installer.wixproj", "{BFA0BBF7-DF4D-4818-AAB3-AFEDA6FDD41D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {FF15C180-042C-42D9-8687-24F1BEB5F4FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FF15C180-042C-42D9-8687-24F1BEB5F4FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF15C180-042C-42D9-8687-24F1BEB5F4FB}.Debug|x64.ActiveCfg = Debug|Any CPU + {FF15C180-042C-42D9-8687-24F1BEB5F4FB}.Debug|x64.Build.0 = Debug|Any CPU + {FF15C180-042C-42D9-8687-24F1BEB5F4FB}.Debug|x86.ActiveCfg = Debug|Any CPU + {FF15C180-042C-42D9-8687-24F1BEB5F4FB}.Debug|x86.Build.0 = Debug|Any CPU {FF15C180-042C-42D9-8687-24F1BEB5F4FB}.Release|Any CPU.ActiveCfg = Release|Any CPU {FF15C180-042C-42D9-8687-24F1BEB5F4FB}.Release|Any CPU.Build.0 = Release|Any CPU + {FF15C180-042C-42D9-8687-24F1BEB5F4FB}.Release|x64.ActiveCfg = Release|Any CPU + {FF15C180-042C-42D9-8687-24F1BEB5F4FB}.Release|x64.Build.0 = Release|Any CPU + {FF15C180-042C-42D9-8687-24F1BEB5F4FB}.Release|x86.ActiveCfg = Release|Any CPU + {FF15C180-042C-42D9-8687-24F1BEB5F4FB}.Release|x86.Build.0 = Release|Any CPU {D1C5763C-DA9C-43E0-B7EA-DDC9AAA90AE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D1C5763C-DA9C-43E0-B7EA-DDC9AAA90AE7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1C5763C-DA9C-43E0-B7EA-DDC9AAA90AE7}.Debug|x64.ActiveCfg = Debug|Any CPU + {D1C5763C-DA9C-43E0-B7EA-DDC9AAA90AE7}.Debug|x64.Build.0 = Debug|Any CPU + {D1C5763C-DA9C-43E0-B7EA-DDC9AAA90AE7}.Debug|x86.ActiveCfg = Debug|Any CPU + {D1C5763C-DA9C-43E0-B7EA-DDC9AAA90AE7}.Debug|x86.Build.0 = Debug|Any CPU {D1C5763C-DA9C-43E0-B7EA-DDC9AAA90AE7}.Release|Any CPU.ActiveCfg = Release|Any CPU {D1C5763C-DA9C-43E0-B7EA-DDC9AAA90AE7}.Release|Any CPU.Build.0 = Release|Any CPU + {D1C5763C-DA9C-43E0-B7EA-DDC9AAA90AE7}.Release|x64.ActiveCfg = Release|Any CPU + {D1C5763C-DA9C-43E0-B7EA-DDC9AAA90AE7}.Release|x64.Build.0 = Release|Any CPU + {D1C5763C-DA9C-43E0-B7EA-DDC9AAA90AE7}.Release|x86.ActiveCfg = Release|Any CPU + {D1C5763C-DA9C-43E0-B7EA-DDC9AAA90AE7}.Release|x86.Build.0 = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU {D1CF3283-A165-477B-A08A-BEBFCD7A7712}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D1CF3283-A165-477B-A08A-BEBFCD7A7712}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1CF3283-A165-477B-A08A-BEBFCD7A7712}.Debug|x64.ActiveCfg = Debug|Any CPU + {D1CF3283-A165-477B-A08A-BEBFCD7A7712}.Debug|x64.Build.0 = Debug|Any CPU + {D1CF3283-A165-477B-A08A-BEBFCD7A7712}.Debug|x86.ActiveCfg = Debug|Any CPU + {D1CF3283-A165-477B-A08A-BEBFCD7A7712}.Debug|x86.Build.0 = Debug|Any CPU {D1CF3283-A165-477B-A08A-BEBFCD7A7712}.Release|Any CPU.ActiveCfg = Release|Any CPU {D1CF3283-A165-477B-A08A-BEBFCD7A7712}.Release|Any CPU.Build.0 = Release|Any CPU + {D1CF3283-A165-477B-A08A-BEBFCD7A7712}.Release|x64.ActiveCfg = Release|Any CPU + {D1CF3283-A165-477B-A08A-BEBFCD7A7712}.Release|x64.Build.0 = Release|Any CPU + {D1CF3283-A165-477B-A08A-BEBFCD7A7712}.Release|x86.ActiveCfg = Release|Any CPU + {D1CF3283-A165-477B-A08A-BEBFCD7A7712}.Release|x86.Build.0 = Release|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F123456789AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F123456789AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F123456789AB}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F123456789AB}.Debug|x64.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F123456789AB}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F123456789AB}.Debug|x86.Build.0 = Debug|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F123456789AB}.Release|Any CPU.ActiveCfg = Release|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F123456789AB}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F123456789AB}.Release|x64.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F123456789AB}.Release|x64.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F123456789AB}.Release|x86.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F123456789AB}.Release|x86.Build.0 = Release|Any CPU {E5F6A7B8-C9D0-1234-EF56-789ABCDE0123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E5F6A7B8-C9D0-1234-EF56-789ABCDE0123}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5F6A7B8-C9D0-1234-EF56-789ABCDE0123}.Debug|x64.ActiveCfg = Debug|Any CPU + {E5F6A7B8-C9D0-1234-EF56-789ABCDE0123}.Debug|x64.Build.0 = Debug|Any CPU + {E5F6A7B8-C9D0-1234-EF56-789ABCDE0123}.Debug|x86.ActiveCfg = Debug|Any CPU + {E5F6A7B8-C9D0-1234-EF56-789ABCDE0123}.Debug|x86.Build.0 = Debug|Any CPU {E5F6A7B8-C9D0-1234-EF56-789ABCDE0123}.Release|Any CPU.ActiveCfg = Release|Any CPU {E5F6A7B8-C9D0-1234-EF56-789ABCDE0123}.Release|Any CPU.Build.0 = Release|Any CPU + {E5F6A7B8-C9D0-1234-EF56-789ABCDE0123}.Release|x64.ActiveCfg = Release|Any CPU + {E5F6A7B8-C9D0-1234-EF56-789ABCDE0123}.Release|x64.Build.0 = Release|Any CPU + {E5F6A7B8-C9D0-1234-EF56-789ABCDE0123}.Release|x86.ActiveCfg = Release|Any CPU + {E5F6A7B8-C9D0-1234-EF56-789ABCDE0123}.Release|x86.Build.0 = Release|Any CPU {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Debug|x64.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Debug|x86.Build.0 = Debug|Any CPU {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Release|x64.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Release|x64.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Release|x86.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Release|x86.Build.0 = Release|Any CPU {D4E5F6A7-B8C9-0123-DEF1-23456789ABCD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D4E5F6A7-B8C9-0123-DEF1-23456789ABCD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D4E5F6A7-B8C9-0123-DEF1-23456789ABCD}.Debug|x64.ActiveCfg = Debug|Any CPU + {D4E5F6A7-B8C9-0123-DEF1-23456789ABCD}.Debug|x64.Build.0 = Debug|Any CPU + {D4E5F6A7-B8C9-0123-DEF1-23456789ABCD}.Debug|x86.ActiveCfg = Debug|Any CPU + {D4E5F6A7-B8C9-0123-DEF1-23456789ABCD}.Debug|x86.Build.0 = Debug|Any CPU {D4E5F6A7-B8C9-0123-DEF1-23456789ABCD}.Release|Any CPU.ActiveCfg = Release|Any CPU {D4E5F6A7-B8C9-0123-DEF1-23456789ABCD}.Release|Any CPU.Build.0 = Release|Any CPU + {D4E5F6A7-B8C9-0123-DEF1-23456789ABCD}.Release|x64.ActiveCfg = Release|Any CPU + {D4E5F6A7-B8C9-0123-DEF1-23456789ABCD}.Release|x64.Build.0 = Release|Any CPU + {D4E5F6A7-B8C9-0123-DEF1-23456789ABCD}.Release|x86.ActiveCfg = Release|Any CPU + {D4E5F6A7-B8C9-0123-DEF1-23456789ABCD}.Release|x86.Build.0 = Release|Any CPU + {BFA0BBF7-DF4D-4818-AAB3-AFEDA6FDD41D}.Debug|Any CPU.ActiveCfg = Debug|x64 + {BFA0BBF7-DF4D-4818-AAB3-AFEDA6FDD41D}.Debug|Any CPU.Build.0 = Debug|x64 + {BFA0BBF7-DF4D-4818-AAB3-AFEDA6FDD41D}.Debug|x64.ActiveCfg = Debug|x64 + {BFA0BBF7-DF4D-4818-AAB3-AFEDA6FDD41D}.Debug|x64.Build.0 = Debug|x64 + {BFA0BBF7-DF4D-4818-AAB3-AFEDA6FDD41D}.Debug|x86.ActiveCfg = Debug|x86 + {BFA0BBF7-DF4D-4818-AAB3-AFEDA6FDD41D}.Debug|x86.Build.0 = Debug|x86 + {BFA0BBF7-DF4D-4818-AAB3-AFEDA6FDD41D}.Release|Any CPU.ActiveCfg = Release|x64 + {BFA0BBF7-DF4D-4818-AAB3-AFEDA6FDD41D}.Release|Any CPU.Build.0 = Release|x64 + {BFA0BBF7-DF4D-4818-AAB3-AFEDA6FDD41D}.Release|x64.ActiveCfg = Release|x64 + {BFA0BBF7-DF4D-4818-AAB3-AFEDA6FDD41D}.Release|x64.Build.0 = Release|x64 + {BFA0BBF7-DF4D-4818-AAB3-AFEDA6FDD41D}.Release|x86.ActiveCfg = Release|x86 + {BFA0BBF7-DF4D-4818-AAB3-AFEDA6FDD41D}.Release|x86.Build.0 = Release|x86 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -85,6 +169,7 @@ Global {E5F6A7B8-C9D0-1234-EF56-789ABCDE0123} = {A1B2C3D4-E5F6-7890-ABCD-EF1234567891} {C3D4E5F6-A7B8-9012-CDEF-123456789ABC} = {9F4FA7B3-9ADD-4601-8EC7-8C9A7BD691C7} {D4E5F6A7-B8C9-0123-DEF1-23456789ABCD} = {1EBF655C-A427-41E1-8BFC-6A2429B7EF6E} + {BFA0BBF7-DF4D-4818-AAB3-AFEDA6FDD41D} = {1860D2E4-5C43-71D5-59A5-599A4DE1DF67} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F32EE165-3408-4FBD-9AA5-03E9FD4C9AC0} diff --git a/PackageReadme.md b/PackageReadme.md new file mode 100644 index 0000000..4e9a351 --- /dev/null +++ b/PackageReadme.md @@ -0,0 +1,8 @@ +# Sentinel.NLogViewer + +WPF controls and themes to visualize NLog log output in desktop applications. + +- **Sentinel.NLogViewer** — core viewer control (targets `net8-windows`). +- **Sentinel.NLogViewer.MaterialDesign** — Material Design styling on top of the core library. + +Repository: [https://github.com/boexler/NLogViewer](https://github.com/boexler/NLogViewer) diff --git a/README.md b/README.md index 9415378..cc4dd93 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,15 @@ NLogViewer is a ui control library to visualize NLog logs in your personal appli supported Framework: `.NET8` +## Desktop application (Sentinel.NLogViewer.App) + +GitHub **Releases** ship: + +- **MSI installer** (`Sentinel.LogViewer--win-x64.msi`): installs under Program Files with a license dialog (MIT), Start Menu shortcut, and optional features (desktop shortcut, `.log` association) selectable in **Custom** setup. Silent install: `msiexec /i Sentinel.LogViewer--win-x64.msi /qn` (optional features with higher install level stay off unless you raise `INSTALLLEVEL` or use transforms). +- **Portable ZIP** (self-contained and framework-dependent): unpack and run `Sentinel.NLogViewer.App.exe` from any folder; a `NLogViewer.portable` marker file directs settings to `appsettings.json` beside the app (see `README-Portable.txt` in the ZIP). Installed (MSI) copies keep using per-user `%LocalAppData%\Sentinel.NLogViewer.App\`. + +Build the installer locally (Windows): `dotnet build installer/Sentinel.NLogViewer.Installer.wixproj -c Release` — output `installer/bin/Release/Sentinel.LogViewer.msi`. + ![NLogViewer][p2] ## Material Design Theme diff --git a/app/Sentinel.NLogViewer.App/App.xaml b/app/Sentinel.NLogViewer.App/App.xaml index d974111..a9a64cc 100644 --- a/app/Sentinel.NLogViewer.App/App.xaml +++ b/app/Sentinel.NLogViewer.App/App.xaml @@ -14,6 +14,10 @@ + + diff --git a/app/Sentinel.NLogViewer.App/App.xaml.cs b/app/Sentinel.NLogViewer.App/App.xaml.cs index 6375e58..ce65b2c 100644 --- a/app/Sentinel.NLogViewer.App/App.xaml.cs +++ b/app/Sentinel.NLogViewer.App/App.xaml.cs @@ -1,6 +1,5 @@ using System; -using System.Globalization; -using System.Threading; +using System.IO; using System.Windows; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -14,6 +13,7 @@ namespace Sentinel.NLogViewer.App /// public partial class App : Application { + private readonly SingleInstanceService _singleInstanceService = new(); private IHost? _host; #if DEBUG @@ -22,6 +22,9 @@ public partial class App : Application public App() { + if (!_singleInstanceService.IsPrimaryInstance) + return; + // Build the host with dependency injection var hostBuilder = Host.CreateApplicationBuilder(); @@ -77,6 +80,12 @@ protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); + if (!_singleInstanceService.IsPrimaryInstance) + { + ForwardInvocationAndExit(e.Args); + return; + } + // Create and show the main window using DI // Create a scope for the main window and its dependencies if (_host != null) @@ -88,6 +97,13 @@ protected override void OnStartup(StartupEventArgs e) // Store the scope so it's disposed when the window closes mainWindow.Closed += (s, args) => scope.Dispose(); + _singleInstanceService.InvocationReceived += (_, arguments) => + Dispatcher.BeginInvoke(() => mainWindow.HandleExternalInvocation(arguments)); + _singleInstanceService.StartListening(); + + if (e.Args.Length > 0) + mainWindow.Loaded += (_, _) => mainWindow.HandleExternalInvocation(e.Args); + mainWindow.Show(); #if DEBUG @@ -107,6 +123,37 @@ protected override void OnStartup(StartupEventArgs e) } } + /// + /// Forwards a secondary process invocation to the primary process and exits. + /// + private void ForwardInvocationAndExit(string[] arguments) + { + try + { + _singleInstanceService.ForwardInvocationAsync(arguments).GetAwaiter().GetResult(); + } + catch (IOException ex) + { + MessageBox.Show( + $"The running Sentinel.NLogViewer instance could not be reached.{Environment.NewLine}{ex.Message}", + "Sentinel.NLogViewer", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + catch (TimeoutException ex) + { + MessageBox.Show( + $"The running Sentinel.NLogViewer instance did not respond.{Environment.NewLine}{ex.Message}", + "Sentinel.NLogViewer", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + finally + { + Shutdown(); + } + } + #if DEBUG private bool ShouldAutoStartTestLogging() { @@ -130,6 +177,7 @@ protected override void OnExit(ExitEventArgs e) #endif // Dispose the host and all registered services _host?.Dispose(); + _singleInstanceService.Dispose(); base.OnExit(e); } diff --git a/app/Sentinel.NLogViewer.App/AttachedProperties/TabContent.cs b/app/Sentinel.NLogViewer.App/AttachedProperties/TabContent.cs index 546fee8..4e8c216 100644 --- a/app/Sentinel.NLogViewer.App/AttachedProperties/TabContent.cs +++ b/app/Sentinel.NLogViewer.App/AttachedProperties/TabContent.cs @@ -253,7 +253,7 @@ public void UpdateSelectedTab() _border.Child = GetCurrentContent(); } - private ContentControl GetCurrentContent() + private ContentControl? GetCurrentContent() { var item = _tabControl.SelectedItem; if (item == null) return null; diff --git a/app/Sentinel.NLogViewer.App/MainWindow.xaml.cs b/app/Sentinel.NLogViewer.App/MainWindow.xaml.cs index bb1e376..2e6ab00 100644 --- a/app/Sentinel.NLogViewer.App/MainWindow.xaml.cs +++ b/app/Sentinel.NLogViewer.App/MainWindow.xaml.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Windows; @@ -41,6 +42,21 @@ public MainWindow(MainViewModel viewModel) #endif } + /// + /// Activates this window and imports paths supplied by a shell or secondary-process invocation. + /// + public void HandleExternalInvocation(IReadOnlyList arguments) + { + if (WindowState == System.Windows.WindowState.Minimized) + WindowState = System.Windows.WindowState.Normal; + + Show(); + Activate(); + + if (arguments.Count > 0) + _viewModel.BeginImportPathsFromUi(arguments); + } + /// /// Accept file drops onto the tab area (and tab headers via ). /// @@ -216,7 +232,8 @@ private void SetWindowTitleWithVersion() /// private void LanguageButton_Click(object sender, RoutedEventArgs e) { - using var scope = App.ServiceProvider.CreateScope(); + var provider = App.ServiceProvider ?? throw new InvalidOperationException("Service provider is not initialized."); + using var scope = provider.CreateScope(); var languageWindow = scope.ServiceProvider.GetRequiredService(); var result = languageWindow.ShowDialog(this); diff --git a/app/Sentinel.NLogViewer.App/Models/AppInfo.cs b/app/Sentinel.NLogViewer.App/Models/AppInfo.cs index 968b8ba..06a62d7 100644 --- a/app/Sentinel.NLogViewer.App/Models/AppInfo.cs +++ b/app/Sentinel.NLogViewer.App/Models/AppInfo.cs @@ -22,9 +22,9 @@ public AppName(string name, string id) public class AppInfo { /// - /// Application name + /// Application name. May be null when the source log event carries no usable app identifier. /// - public AppName AppName { get; set; } = new("Unknown", Guid.NewGuid().ToString()); + public AppName? AppName { get; set; } = new("Unknown", Guid.NewGuid().ToString()); /// /// Sender/Remote endpoint information @@ -38,7 +38,8 @@ public class AppInfo public override string ToString() { - return string.IsNullOrEmpty(Sender) ? AppName.Name : $"{AppName.Name} @ {Sender}"; + var name = AppName?.Name ?? string.Empty; + return string.IsNullOrEmpty(Sender) ? name : $"{name} @ {Sender}"; } public override bool Equals(object? obj) diff --git a/app/Sentinel.NLogViewer.App/Models/Log4JEvent.cs b/app/Sentinel.NLogViewer.App/Models/Log4JEvent.cs index a75dff7..aa22362 100644 --- a/app/Sentinel.NLogViewer.App/Models/Log4JEvent.cs +++ b/app/Sentinel.NLogViewer.App/Models/Log4JEvent.cs @@ -20,7 +20,7 @@ public class Log4JEvent : ILogEvent /// Gets or sets the name of the logger that generated this event. /// Corresponds to the "logger" attribute in the log4j:event element. /// - public string Logger { get; set; } + public string Logger { get; set; } = string.Empty; /// /// Gets or sets the log level of this event. @@ -38,19 +38,19 @@ public class Log4JEvent : ILogEvent /// Gets or sets the name of the thread that generated this event. /// Corresponds to the "thread" attribute in the log4j:event element. /// - public string Thread { get; set; } + public string Thread { get; set; } = string.Empty; /// /// Gets or sets the log message content. /// Extracted from the log4j:message child element of the log4j:event. /// - public string Message { get; set; } + public string Message { get; set; } = string.Empty; /// /// Gets or sets the exception or throwable information (stack trace) associated with this log event. /// Extracted from the log4j:throwable child element of the log4j:event. May be null if no exception information is included. /// - public string Throwable { get; set; } + public string? Throwable { get; set; } /// /// Gets or sets the location information (class, method, file, line) where the log event was generated. @@ -166,7 +166,7 @@ public LogEvent ToLogEvent(string sender) /// Safely handles missing or invalid log4japp values by returning null. /// /// The extracted application name, or null if the property is missing or doesn't match the expected pattern. - private AppName ExtractAppName() + private AppName? ExtractAppName() { if (!Properties.TryGetValue("log4japp", out var log4jApp) || string.IsNullOrEmpty(log4jApp)) { diff --git a/app/Sentinel.NLogViewer.App/Models/Log4JLocationInfo.cs b/app/Sentinel.NLogViewer.App/Models/Log4JLocationInfo.cs index b2322c2..c1b4dd2 100644 --- a/app/Sentinel.NLogViewer.App/Models/Log4JLocationInfo.cs +++ b/app/Sentinel.NLogViewer.App/Models/Log4JLocationInfo.cs @@ -10,19 +10,19 @@ public class Log4JLocationInfo /// Gets or sets the fully qualified name of the class where the log event was generated. /// Corresponds to the "class" attribute in the log4j:locationInfo element. /// - public string Class { get; set; } + public string Class { get; set; } = string.Empty; /// /// Gets or sets the name of the method where the log event was generated. /// Corresponds to the "method" attribute in the log4j:locationInfo element. /// - public string Method { get; set; } + public string Method { get; set; } = string.Empty; /// /// Gets or sets the name of the source file where the log event was generated. /// Corresponds to the "file" attribute in the log4j:locationInfo element. /// - public string File { get; set; } + public string File { get; set; } = string.Empty; /// /// Gets or sets the line number in the source file where the log event was generated. diff --git a/app/Sentinel.NLogViewer.App/Models/LogEvent.cs b/app/Sentinel.NLogViewer.App/Models/LogEvent.cs index df8727c..4009e1b 100644 --- a/app/Sentinel.NLogViewer.App/Models/LogEvent.cs +++ b/app/Sentinel.NLogViewer.App/Models/LogEvent.cs @@ -4,7 +4,6 @@ namespace Sentinel.NLogViewer.App.Models; public class LogEvent { - - public AppInfo AppInfo { get; set; } - public LogEventInfo LogEventInfo { get; set; } + public required AppInfo AppInfo { get; set; } + public required LogEventInfo LogEventInfo { get; set; } } \ No newline at end of file diff --git a/app/Sentinel.NLogViewer.App/Parsers/Log4JEventParser.cs b/app/Sentinel.NLogViewer.App/Parsers/Log4JEventParser.cs index be65324..7a584a6 100644 --- a/app/Sentinel.NLogViewer.App/Parsers/Log4JEventParser.cs +++ b/app/Sentinel.NLogViewer.App/Parsers/Log4JEventParser.cs @@ -52,10 +52,10 @@ public Log4JEvent Parse(string xml) var evt = new Log4JEvent { - Logger = (string)root.Attribute("logger") ?? string.Empty, - Level = ParseLogLevel((string)root.Attribute("level")), - Thread = (string)root.Attribute("thread") ?? string.Empty, - Timestamp = ParseLong((string)root.Attribute("timestamp")), + Logger = (string?)root.Attribute("logger") ?? string.Empty, + Level = ParseLogLevel((string?)root.Attribute("level")), + Thread = (string?)root.Attribute("thread") ?? string.Empty, + Timestamp = ParseLong((string?)root.Attribute("timestamp")), Message = ExtractMessage(root, ns), Throwable = ExtractValue(root.Element(ns + "throwable")), LocationInfo = ExtractLocationInfo(root, ns), @@ -63,7 +63,7 @@ public Log4JEvent Parse(string xml) }; if (string.IsNullOrEmpty(evt.Thread)) - evt.Thread = ExtractValue(root.Element(ns + "thread")); + evt.Thread = ExtractValue(root.Element(ns + "thread")) ?? string.Empty; return evt; } @@ -91,7 +91,7 @@ private string ExtractMessage (XElement root, XNamespace ns) /// /// The XML element to extract the value from. Can be null. /// The trimmed element value, or null if the element is null or empty. - private string ExtractValue(XElement element) + private string? ExtractValue(XElement? element) { if (element == null) return null; @@ -107,7 +107,7 @@ private string ExtractValue(XElement element) /// The root log4j:event element. /// The XML namespace for log4j elements. /// A Log4JLocationInfo object, or null if the locationInfo element is not present. - private Log4JLocationInfo ExtractLocationInfo(XElement root, XNamespace ns) + private Log4JLocationInfo? ExtractLocationInfo(XElement root, XNamespace ns) { var e = root.Element(ns + "locationInfo"); if (e == null) @@ -115,10 +115,10 @@ private Log4JLocationInfo ExtractLocationInfo(XElement root, XNamespace ns) return new Log4JLocationInfo { - Class = (string)e.Attribute("class") ?? string.Empty, - Method = (string)e.Attribute("method") ?? string.Empty, - File = (string)e.Attribute("file") ?? string.Empty, - Line = ParseNullableInt((string)e.Attribute("line")) + Class = (string?)e.Attribute("class") ?? string.Empty, + Method = (string?)e.Attribute("method") ?? string.Empty, + File = (string?)e.Attribute("file") ?? string.Empty, + Line = ParseNullableInt((string?)e.Attribute("line")) }; } @@ -138,8 +138,8 @@ private Dictionary ExtractProperties(XElement root, XNamespace n foreach (var d in props.Elements(ns + "data")) { - var name = (string)d.Attribute("name"); - var value = (string)d.Attribute("value"); + var name = (string?)d.Attribute("name"); + var value = (string?)d.Attribute("value"); if (!string.IsNullOrEmpty(name)) dict[name] = value ?? string.Empty; @@ -154,7 +154,7 @@ private Dictionary ExtractProperties(XElement root, XNamespace n /// /// The string representation of the log level (e.g., "INFO", "ERROR"). /// The corresponding Log4JLevel enum value, or Log4JLevel.Unknown if the string cannot be parsed. - private Log4JLevel ParseLogLevel(string levelString) + private Log4JLevel ParseLogLevel(string? levelString) { if (string.IsNullOrWhiteSpace(levelString)) return Log4JLevel.Unknown; @@ -178,8 +178,10 @@ private Log4JLevel ParseLogLevel(string levelString) /// /// The string to parse. /// The parsed long value, or 0 if parsing fails. - private long ParseLong(string value) + private long ParseLong(string? value) { + if (string.IsNullOrEmpty(value)) + return 0L; return long.TryParse(value, out long result) ? result : 0L; } @@ -188,8 +190,10 @@ private long ParseLong(string value) /// /// The string to parse. /// The parsed integer value, or null if parsing fails or the input is null/empty. - private int? ParseNullableInt(string value) + private int? ParseNullableInt(string? value) { + if (string.IsNullOrEmpty(value)) + return null; return int.TryParse(value, out int result) ? result : null; } } \ No newline at end of file diff --git a/app/Sentinel.NLogViewer.App/Resources/icons/icons8-analysieren-256.png b/app/Sentinel.NLogViewer.App/Resources/icons/icons8-analysieren-256.png new file mode 100644 index 0000000..be7c7f2 Binary files /dev/null and b/app/Sentinel.NLogViewer.App/Resources/icons/icons8-analysieren-256.png differ diff --git a/app/Sentinel.NLogViewer.App/Resources/icons/icons8-analysieren-64.ico b/app/Sentinel.NLogViewer.App/Resources/icons/icons8-analysieren-64.ico new file mode 100644 index 0000000..3dbf994 Binary files /dev/null and b/app/Sentinel.NLogViewer.App/Resources/icons/icons8-analysieren-64.ico differ diff --git a/app/Sentinel.NLogViewer.App/Resources/icons/icons8-analysieren-64.png b/app/Sentinel.NLogViewer.App/Resources/icons/icons8-analysieren-64.png new file mode 100644 index 0000000..9eb757a Binary files /dev/null and b/app/Sentinel.NLogViewer.App/Resources/icons/icons8-analysieren-64.png differ diff --git a/app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj b/app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj index f9b2e77..c1d2788 100644 --- a/app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj +++ b/app/Sentinel.NLogViewer.App/Sentinel.NLogViewer.App.csproj @@ -7,11 +7,14 @@ Sentinel.NLogViewer.App.App Sentinel.NLogViewer.App Sentinel.NLogViewer.App - win-x64 + Resources\icons\icons8-analysieren-64.ico + + win-x64 + @@ -22,6 +25,8 @@ + + @@ -45,6 +50,14 @@ + + + + + + + + True diff --git a/app/Sentinel.NLogViewer.App/Services/ConfigurationService.cs b/app/Sentinel.NLogViewer.App/Services/ConfigurationService.cs index 468dff6..b45633c 100644 --- a/app/Sentinel.NLogViewer.App/Services/ConfigurationService.cs +++ b/app/Sentinel.NLogViewer.App/Services/ConfigurationService.cs @@ -2,24 +2,80 @@ using System.Collections.Generic; using System.IO; using System.Text.Json; -using Sentinel.NLogViewer.App.Models; namespace Sentinel.NLogViewer.App.Services { /// - /// Service for loading and saving application configuration + /// Service for loading and saving application configuration. + /// When portable mode is active, settings are stored next to the application (see ). + /// Otherwise settings use LocalApplicationData (per-user, suitable for installed copies). /// public class ConfigurationService { + /// Name of the marker file shipped with portable ZIP distributions. + public const string PortableMarkerFileName = "NLogViewer.portable"; + + /// Alternative portable marker (hidden-style dotfile). + public const string PortableMarkerDotFileName = ".portable"; + + /// + /// When set to 1 or true, forces portable configuration layout using the app base directory. + /// + public const string PortableEnvironmentVariableName = "NLOGVIEWER_PORTABLE"; + private readonly string _configPath; public ConfigurationService() + : this(AppContext.BaseDirectory) + { + } + + /// + /// For tests: is used for portable detection and for the portable config path. + /// + /// Typically . + internal ConfigurationService(string applicationBaseDirectory) + { + _configPath = ResolveConfigPath(applicationBaseDirectory); + } + + /// + /// Returns true when configuration should be stored beside the application (portable ZIP / USB) rather than in AppData. + /// + public static bool IsPortableMode(string applicationBaseDirectory) + { + if (IsPortableEnvironmentOverride()) + return true; + if (string.IsNullOrEmpty(applicationBaseDirectory)) + return false; + if (File.Exists(Path.Combine(applicationBaseDirectory, PortableMarkerDotFileName))) + return true; + if (File.Exists(Path.Combine(applicationBaseDirectory, PortableMarkerFileName))) + return true; + return false; + } + + /// + /// Returns the full path to appsettings.json for either portable or per-user storage. + /// + internal static string ResolveConfigPath(string applicationBaseDirectory) { + if (IsPortableMode(applicationBaseDirectory)) + return Path.Combine(applicationBaseDirectory, "appsettings.json"); + var appDataPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Sentinel.NLogViewer.App"); Directory.CreateDirectory(appDataPath); - _configPath = Path.Combine(appDataPath, "appsettings.json"); + return Path.Combine(appDataPath, "appsettings.json"); + } + + private static bool IsPortableEnvironmentOverride() + { + var value = Environment.GetEnvironmentVariable(PortableEnvironmentVariableName); + if (string.IsNullOrEmpty(value)) + return false; + return value == "1" || value.Equals("true", StringComparison.OrdinalIgnoreCase); } public AppConfiguration LoadConfiguration() diff --git a/app/Sentinel.NLogViewer.App/Services/SingleInstanceService.cs b/app/Sentinel.NLogViewer.App/Services/SingleInstanceService.cs new file mode 100644 index 0000000..1171fad --- /dev/null +++ b/app/Sentinel.NLogViewer.App/Services/SingleInstanceService.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipes; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Sentinel.NLogViewer.App.Services; + +/// +/// Ensures that only one application instance runs and forwards invocations to the primary instance. +/// +internal sealed class SingleInstanceService : IDisposable +{ + private const string MutexName = @"Local\Boexler.Sentinel.NLogViewer.SingleInstance"; + private const string PipeName = "Boexler.Sentinel.NLogViewer.SingleInstance"; + private readonly Mutex _mutex; + private readonly string _pipeName; + private readonly CancellationTokenSource _cancellationTokenSource = new(); + private Task? _listenerTask; + private bool _disposed; + + /// + /// Initializes the instance coordinator and attempts to become the primary instance. + /// + public SingleInstanceService() : this(MutexName, PipeName) + { + } + + /// + /// Initializes an instance coordinator with explicit operating-system object names. + /// + internal SingleInstanceService(string mutexName, string pipeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(mutexName); + ArgumentException.ThrowIfNullOrWhiteSpace(pipeName); + + _pipeName = pipeName; + _mutex = new Mutex(initiallyOwned: false, mutexName, out var isPrimaryInstance); + IsPrimaryInstance = isPrimaryInstance; + } + + /// + /// Occurs when another process forwards command-line paths to the primary instance. + /// + public event EventHandler>? InvocationReceived; + + /// + /// Gets whether this process owns the application instance mutex. + /// + public bool IsPrimaryInstance { get; } + + /// + /// Starts accepting forwarded invocations in the primary instance. + /// + public void StartListening() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!IsPrimaryInstance || _listenerTask != null) + return; + + _listenerTask = ListenAsync(_cancellationTokenSource.Token); + } + + /// + /// Forwards an invocation to the primary instance. + /// + public async Task ForwardInvocationAsync(IEnumerable arguments, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + var payload = JsonSerializer.Serialize(arguments); + using var pipe = new NamedPipeClientStream( + ".", + _pipeName, + PipeDirection.Out, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + + await pipe.ConnectAsync(5000, cancellationToken).ConfigureAwait(false); + await using var writer = new StreamWriter(pipe); + await writer.WriteAsync(payload.AsMemory(), cancellationToken).ConfigureAwait(false); + } + + /// + /// Accepts invocation messages until application shutdown. + /// + private async Task ListenAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await using var pipe = new NamedPipeServerStream( + _pipeName, + PipeDirection.In, + 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + + await pipe.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); + using var reader = new StreamReader(pipe); + var payload = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + var arguments = JsonSerializer.Deserialize(payload) ?? Array.Empty(); + InvocationReceived?.Invoke(this, arguments); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (IOException) + { + // A disconnected client must not stop the listener for subsequent invocations. + } + catch (JsonException) + { + // Ignore malformed messages from unrelated or outdated clients. + } + } + } + + /// + /// Stops the listener and releases owned operating-system resources. + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _cancellationTokenSource.Cancel(); + + try + { + _listenerTask?.Wait(TimeSpan.FromSeconds(1)); + } + catch (AggregateException ex) when (ex.InnerExceptions.All( + innerException => innerException is OperationCanceledException)) + { + // Cancellation is the expected listener termination path. + } + + _cancellationTokenSource.Dispose(); + + _mutex.Dispose(); + } +} diff --git a/app/Sentinel.NLogViewer.App/Services/UdpLogReceiverService.cs b/app/Sentinel.NLogViewer.App/Services/UdpLogReceiverService.cs index 9f16a36..abc4aff 100644 --- a/app/Sentinel.NLogViewer.App/Services/UdpLogReceiverService.cs +++ b/app/Sentinel.NLogViewer.App/Services/UdpLogReceiverService.cs @@ -54,7 +54,7 @@ public async Task StartListeningAsync(IReadOnlyList ReceiveLoop(udpClient, port, cts.Token), cts.Token); + _ = Task.Run(() => ReceiveLoop(udpClient, port, cts.Token), cts.Token); } catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse || ex.NativeErrorCode == WSAEADDRINUSE) { diff --git a/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs b/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs index 2565223..ee9daf9 100644 --- a/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs +++ b/app/Sentinel.NLogViewer.App/ViewModels/MainViewModel.cs @@ -34,7 +34,7 @@ public class MainViewModel : INotifyPropertyChanged, IDisposable private readonly ConfigurationService _configService; private readonly LocalizationService _localizationService; private bool _isListening; - private string _listeningStatus; + private string _listeningStatus = string.Empty; private string _statusMessage = "Ready"; private string _lastLogTimestamp = string.Empty; private LogTabViewModel? _selectedTab; @@ -125,7 +125,7 @@ private void OnLogEvent(IList logEvents) tab.AddLogEvent(logEvent.LogEventInfo); } LastLogTimestamp = DateTime.Now.ToString("HH:mm:ss"); - StatusMessage = $"Received {logEvents.Count} log(s) from {firstEvent.AppInfo.AppName.Name}"; + StatusMessage = $"Received {logEvents.Count} log(s) from {firstEvent.AppInfo.AppName?.Name}"; } public ObservableCollection LogTabs { get; } @@ -356,7 +356,7 @@ private void LoadConfiguration() Task.Run(async () => { await Task.Delay(500); // Small delay to ensure UI is ready - System.Windows.Application.Current.Dispatcher.Invoke(() => _ = StartListeningAsync()); + System.Windows.Application.Current.Dispatcher.Invoke(() => { _ = StartListeningAsync(); }); }); } } @@ -809,7 +809,8 @@ private void ApplyParsedLogs(List logEvents, string filePath, LogF private void OpenSettings() { - using var scope = App.ServiceProvider.CreateScope(); + var provider = App.ServiceProvider ?? throw new InvalidOperationException("Service provider is not initialized."); + using var scope = provider.CreateScope(); var settingsWindow = scope.ServiceProvider.GetRequiredService(); var result = settingsWindow.ShowDialog(System.Windows.Application.Current.MainWindow); if (result == true) diff --git a/chocolatey/README.md b/chocolatey/README.md new file mode 100644 index 0000000..9b20ef2 --- /dev/null +++ b/chocolatey/README.md @@ -0,0 +1,30 @@ +# Chocolatey package + +Stable releases and development builds use the same `Sentinel.LogViewer` package ID. The package +version selects the channel: + +- Stable releases use versions such as `1.4.2`. +- Development builds use prerelease versions such as `1.4.3-dev00000042001`. + +Chocolatey ignores prereleases by default. Use `--pre` to opt into development builds: + +```powershell +choco install Sentinel.LogViewer -y +choco upgrade Sentinel.LogViewer -y +choco upgrade Sentinel.LogViewer --pre -y +``` + +The Chocolatey CLI supports SemVer 2, but the Chocolatey Community Repository currently does not. +The development workflow therefore keeps SemVer 2 for NuGet packages and uses a flattened +prerelease label without dots for the Chocolatey package. + +## Publishing + +The `Publish Dev` GitHub Actions workflow always builds a prerelease of `Sentinel.LogViewer` and +exposes it as a workflow artifact. With `publish` enabled it also pushes that build to the same +destinations as a tagged release: GitHub Packages, nuget.org, the Chocolatey Community Repository, +and a GitHub prerelease. + +Tagged releases build and publish a stable version of the same `Sentinel.LogViewer` package from +`release.yml`. +Pushes require repository Actions secrets named `NUGET_API_KEY` and `CHOCOLATEY_API_KEY`. diff --git a/chocolatey/Sentinel.LogViewer.nuspec b/chocolatey/Sentinel.LogViewer.nuspec new file mode 100644 index 0000000..311d8f0 --- /dev/null +++ b/chocolatey/Sentinel.LogViewer.nuspec @@ -0,0 +1,27 @@ + + + + Sentinel.LogViewer + 0.0.0 + Sentinel.LogViewer + Dominic Böxler + https://github.com/boexler/NLogViewer + https://github.com/boexler/NLogViewer + https://github.com/boexler/NLogViewer/tree/master/chocolatey + https://github.com/boexler/NLogViewer/issues + https://github.com/boexler/NLogViewer/blob/master/LICENSE.md + false + nlog log viewer desktop windows + View and analyze NLog and log files on Windows. + +Sentinel.LogViewer is a Windows desktop application for viewing and analyzing NLog and log files. + +The package embeds the official x64 MSI. Stable and prerelease versions share the same package ID; +use Chocolatey's `--pre` option to install or upgrade to a development version. + + https://github.com/boexler/NLogViewer/releases + + + + + diff --git a/chocolatey/tools/chocolateyInstall.ps1 b/chocolatey/tools/chocolateyInstall.ps1 new file mode 100644 index 0000000..eada3d7 --- /dev/null +++ b/chocolatey/tools/chocolateyInstall.ps1 @@ -0,0 +1,14 @@ +$ErrorActionPreference = 'Stop' + +$toolsDirectory = Split-Path -Parent $MyInvocation.MyCommand.Definition +$installerPath = Join-Path $toolsDirectory 'Sentinel.LogViewer.msi' + +$packageArguments = @{ + packageName = $env:ChocolateyPackageName + fileType = 'msi' + file = $installerPath + silentArgs = '/qn /norestart' + validExitCodes = @(0, 1641, 3010) +} + +Install-ChocolateyInstallPackage @packageArguments diff --git a/dist/portable/README-Portable.txt b/dist/portable/README-Portable.txt new file mode 100644 index 0000000..281b466 --- /dev/null +++ b/dist/portable/README-Portable.txt @@ -0,0 +1,24 @@ +NLogViewer – portable distribution +================================== + +These ZIP packages are portable: unpack anywhere (including a USB drive) and run +Sentinel.NLogViewer.App.exe from this folder. + +Configuration (ports, language, etc.) is stored in appsettings.json in this same +folder as the executable — not in AppData. A marker file (NLogViewer.portable or +.portable) enables this layout; do not delete it if you want portable behavior. + +Self-contained build +-------------------- +Includes the .NET runtime. No separate .NET installation is required. Larger +download (~100–150 MB typical). + +Framework-dependent build +------------------------- +Requires the .NET 8 (or compatible) desktop runtime for Windows (win-x64) +installed on the machine. Smaller download. + +Advanced: you can force portable paths by setting environment variable +NLOGVIEWER_PORTABLE=1 (mainly for diagnostics). + +Project home: https://github.com/boexler/NLogViewer diff --git a/installer/Directory.Build.targets b/installer/Directory.Build.targets new file mode 100644 index 0000000..a26ad05 --- /dev/null +++ b/installer/Directory.Build.targets @@ -0,0 +1,12 @@ + + + + ResolveWixPublishHarvestDir;$(HarvestDependsOn) + + diff --git a/installer/License.rtf b/installer/License.rtf new file mode 100644 index 0000000..4175bbf --- /dev/null +++ b/installer/License.rtf @@ -0,0 +1,11 @@ +{\rtf1\ansi\deff0{\fonttbl{\f0\fswiss Arial;}} +\f0\fs22 MIT License\par +\par +Copyright (c) 2026 Dominic Böxler\par +\par +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\par +\par +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\par +\par +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\par +} diff --git a/installer/Package.wxs b/installer/Package.wxs new file mode 100644 index 0000000..c67a79c --- /dev/null +++ b/installer/Package.wxs @@ -0,0 +1,271 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/installer/Sentinel.NLogViewer.Installer.wixproj b/installer/Sentinel.NLogViewer.Installer.wixproj new file mode 100644 index 0000000..9f2db37 --- /dev/null +++ b/installer/Sentinel.NLogViewer.Installer.wixproj @@ -0,0 +1,114 @@ + + + + + Package + + Sentinel.LogViewer + + + x64 + + + ICE61 + + + + 1.0.0.0 + + $(DefineConstants);InstallerVersion=$(InstallerVersion) + + false + + + + + + + + + + + + + + SelfContained=true;PublishSingleFile=false;RuntimeIdentifier=win-x64 + + + + + + + + + + + + + + + + + + + + <_WixHarvestPrimary>$([System.IO.Path]::GetFullPath($([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)', 'publish', 'Sentinel.NLogViewer.App')))) + + <_WixHarvestObjCfg>$([System.IO.Path]::GetFullPath($([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', 'obj', '$(Configuration)', 'publish', 'Sentinel.NLogViewer.App')))) + + <_WixHarvestObjPlatCfg>$([System.IO.Path]::GetFullPath($([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', 'obj', '$(Platform)', '$(Configuration)', 'publish', 'Sentinel.NLogViewer.App')))) + + $(_WixHarvestPrimary) + + $(_WixHarvestObjPlatCfg) + + $(_WixHarvestObjCfg) + + + + + + + + + + HarvestedComponents + + INSTALLFOLDER + + true + + true + + + + + + + + + + + + + + diff --git a/testapp/Sentinel.NLogViewer.Wpf.MaterialDesign.TestApp/MainWindow.xaml.cs b/testapp/Sentinel.NLogViewer.Wpf.MaterialDesign.TestApp/MainWindow.xaml.cs index 8647b2f..4a184ad 100644 --- a/testapp/Sentinel.NLogViewer.Wpf.MaterialDesign.TestApp/MainWindow.xaml.cs +++ b/testapp/Sentinel.NLogViewer.Wpf.MaterialDesign.TestApp/MainWindow.xaml.cs @@ -4,6 +4,7 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; +using System.Windows.Threading; using System.Windows; using System.Windows.Controls; using Sentinel.NLogViewer.Wpf; @@ -31,7 +32,7 @@ public partial class MainWindow : Window public MainWindow() { - Title = $"NLogViewer TestApp v{Assembly.GetEntryAssembly().GetName().Version} - framework v{AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}"; + Title = $"NLogViewer TestApp v{Assembly.GetEntryAssembly()?.GetName().Version} - framework v{AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}"; InitializeComponent(); DataContext = this; @@ -39,7 +40,8 @@ public MainWindow() Stopwatch stopwatch = Stopwatch.StartNew(); Random random = new Random(); - Observable.Interval(TimeSpan.FromMilliseconds(200)).ObserveOn(SynchronizationContext.Current).Subscribe(l => + var sync = SynchronizationContext.Current ?? new DispatcherSynchronizationContext(Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher); + Observable.Interval(TimeSpan.FromMilliseconds(200)).ObserveOn(sync).Subscribe(l => { //if((_CntMessage == 10 || _CntError == 20) && TabControl1.Items.Count > 0) // TabControl1.Items.RemoveAt(0); diff --git a/testapp/Sentinel.NLogViewer.Wpf.TestApp/MainWindow.xaml.cs b/testapp/Sentinel.NLogViewer.Wpf.TestApp/MainWindow.xaml.cs index fcac0c3..0f0f3c2 100644 --- a/testapp/Sentinel.NLogViewer.Wpf.TestApp/MainWindow.xaml.cs +++ b/testapp/Sentinel.NLogViewer.Wpf.TestApp/MainWindow.xaml.cs @@ -4,6 +4,7 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; +using System.Windows.Threading; using System.Windows; using System.Windows.Controls; using Sentinel.NLogViewer.Wpf; @@ -31,7 +32,7 @@ public partial class MainWindow : Window public MainWindow() { - Title = $"NLogViewer TestApp v{Assembly.GetEntryAssembly().GetName().Version} - framework v{AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}"; + Title = $"NLogViewer TestApp v{Assembly.GetEntryAssembly()?.GetName().Version} - framework v{AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}"; InitializeComponent(); DataContext = this; @@ -39,7 +40,8 @@ public MainWindow() Stopwatch stopwatch = Stopwatch.StartNew(); Random random = new Random(); - Observable.Interval(TimeSpan.FromMilliseconds(200)).ObserveOn(SynchronizationContext.Current).Subscribe(l => + var sync = SynchronizationContext.Current ?? new DispatcherSynchronizationContext(Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher); + Observable.Interval(TimeSpan.FromMilliseconds(200)).ObserveOn(sync).Subscribe(l => { //if((_CntMessage == 10 || _CntError == 20) && TabControl1.Items.Count > 0) // TabControl1.Items.RemoveAt(0); diff --git a/tests/Sentinel.NLogViewer.App.Tests/Parsers/JsonLogParserTests.cs b/tests/Sentinel.NLogViewer.App.Tests/Parsers/JsonLogParserTests.cs index dd7b1fa..de25b01 100644 --- a/tests/Sentinel.NLogViewer.App.Tests/Parsers/JsonLogParserTests.cs +++ b/tests/Sentinel.NLogViewer.App.Tests/Parsers/JsonLogParserTests.cs @@ -132,8 +132,9 @@ public void Parse_JsonWithException_ParsesException() // Assert Assert.Single(results); - Assert.NotNull(results[0].Exception); - Assert.Contains("Test exception message", results[0].Exception.Message); + var exception = results[0].Exception; + Assert.NotNull(exception); + Assert.Contains("Test exception message", exception.Message); } [Fact] diff --git a/tests/Sentinel.NLogViewer.App.Tests/Parsers/PlainTextParserTests.cs b/tests/Sentinel.NLogViewer.App.Tests/Parsers/PlainTextParserTests.cs index 6d0b5c0..4c494b9 100644 --- a/tests/Sentinel.NLogViewer.App.Tests/Parsers/PlainTextParserTests.cs +++ b/tests/Sentinel.NLogViewer.App.Tests/Parsers/PlainTextParserTests.cs @@ -247,7 +247,6 @@ public void Parse_EmptyLines_IgnoresEmptyLines() " ", "2024-01-15 10:30:45 INFO [Logger] Valid message", "", - null }; // Act diff --git a/tests/Sentinel.NLogViewer.App.Tests/Services/ConfigurationServiceTests.cs b/tests/Sentinel.NLogViewer.App.Tests/Services/ConfigurationServiceTests.cs new file mode 100644 index 0000000..8a8323d --- /dev/null +++ b/tests/Sentinel.NLogViewer.App.Tests/Services/ConfigurationServiceTests.cs @@ -0,0 +1,124 @@ +using System; +using System.IO; +using Sentinel.NLogViewer.App.Services; +using Xunit; + +namespace Sentinel.NLogViewer.App.Tests.Services +{ + /// + /// Tests for portable vs. per-user configuration paths. + /// + public class ConfigurationServiceTests : IDisposable + { + private readonly string? _previousPortableEnv; + + public ConfigurationServiceTests() + { + _previousPortableEnv = Environment.GetEnvironmentVariable(ConfigurationService.PortableEnvironmentVariableName); + } + + public void Dispose() + { + RestorePortableEnv(_previousPortableEnv); + } + + private static void RestorePortableEnv(string? previous) + { + if (previous is null) + Environment.SetEnvironmentVariable(ConfigurationService.PortableEnvironmentVariableName, null); + else + Environment.SetEnvironmentVariable(ConfigurationService.PortableEnvironmentVariableName, previous); + } + + [Fact] + public void ResolveConfigPath_WithMarkerFile_UsesBaseDirectory() + { + Environment.SetEnvironmentVariable(ConfigurationService.PortableEnvironmentVariableName, null); + var dir = Path.Combine(Path.GetTempPath(), "nlv_cfg_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + File.WriteAllText(Path.Combine(dir, ConfigurationService.PortableMarkerFileName), string.Empty); + var path = ConfigurationService.ResolveConfigPath(dir); + Assert.Equal(Path.Combine(dir, "appsettings.json"), path); + } + finally + { + TryDeleteDir(dir); + } + } + + [Fact] + public void ResolveConfigPath_WithEnvironmentOverride_IgnoresMarkerAndUsesBaseDirectory() + { + var dir = Path.Combine(Path.GetTempPath(), "nlv_cfg_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + Environment.SetEnvironmentVariable(ConfigurationService.PortableEnvironmentVariableName, "1"); + try + { + var path = ConfigurationService.ResolveConfigPath(dir); + Assert.Equal(Path.Combine(dir, "appsettings.json"), path); + } + finally + { + TryDeleteDir(dir); + } + } + + [Fact] + public void ResolveConfigPath_WithoutPortable_UseLocalAppData() + { + Environment.SetEnvironmentVariable(ConfigurationService.PortableEnvironmentVariableName, null); + var dir = Path.Combine(Path.GetTempPath(), "nlv_cfg_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var path = ConfigurationService.ResolveConfigPath(dir); + Assert.Contains("Sentinel.NLogViewer.App", path); + Assert.EndsWith("appsettings.json", path); + Assert.Contains( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Sentinel.NLogViewer.App"), + path); + } + finally + { + TryDeleteDir(dir); + } + } + + [Fact] + public void SaveAndLoad_InPortableDirectory_RoundTrips() + { + Environment.SetEnvironmentVariable(ConfigurationService.PortableEnvironmentVariableName, null); + var dir = Path.Combine(Path.GetTempPath(), "nlv_cfg_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, ConfigurationService.PortableMarkerDotFileName), string.Empty); + try + { + var service = new ConfigurationService(dir); + var saved = new AppConfiguration { Language = "en", MaxLogEntriesPerTab = 42 }; + service.SaveConfiguration(saved); + var loaded = service.LoadConfiguration(); + Assert.Equal("en", loaded.Language); + Assert.Equal(42, loaded.MaxLogEntriesPerTab); + } + finally + { + TryDeleteDir(dir); + } + } + + private static void TryDeleteDir(string dir) + { + try + { + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + catch + { + // best effort + } + } + } +} diff --git a/tests/Sentinel.NLogViewer.App.Tests/Services/SingleInstanceServiceTests.cs b/tests/Sentinel.NLogViewer.App.Tests/Services/SingleInstanceServiceTests.cs new file mode 100644 index 0000000..66d8b5c --- /dev/null +++ b/tests/Sentinel.NLogViewer.App.Tests/Services/SingleInstanceServiceTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Sentinel.NLogViewer.App.Services; +using Xunit; + +namespace Sentinel.NLogViewer.App.Tests.Services; + +/// +/// Verifies single-instance ownership and command-line argument forwarding. +/// +public sealed class SingleInstanceServiceTests +{ + /// + /// Ensures that a secondary coordinator forwards all arguments to the primary coordinator. + /// + [Fact] + public async Task ForwardInvocationAsync_WithSecondaryInstance_ForwardsArguments() + { + var uniqueName = Guid.NewGuid().ToString("N"); + var mutexName = $@"Local\Boexler.Sentinel.NLogViewer.Tests.{uniqueName}"; + var pipeName = $"Boexler.Sentinel.NLogViewer.Tests.{uniqueName}"; + using var primary = new SingleInstanceService(mutexName, pipeName); + using var secondary = new SingleInstanceService(mutexName, pipeName); + var receivedInvocation = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + + primary.InvocationReceived += (_, arguments) => receivedInvocation.TrySetResult(arguments); + primary.StartListening(); + + Assert.True(primary.IsPrimaryInstance); + Assert.False(secondary.IsPrimaryInstance); + + await secondary.ForwardInvocationAsync(["first.log", "second.log"]); + var receivedArguments = await receivedInvocation.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(["first.log", "second.log"], receivedArguments); + } + + /// + /// Ensures that an invocation without file arguments still reaches the primary coordinator. + /// + [Fact] + public async Task ForwardInvocationAsync_WithoutArguments_ForwardsActivationRequest() + { + var uniqueName = Guid.NewGuid().ToString("N"); + var mutexName = $@"Local\Boexler.Sentinel.NLogViewer.Tests.{uniqueName}"; + var pipeName = $"Boexler.Sentinel.NLogViewer.Tests.{uniqueName}"; + using var primary = new SingleInstanceService(mutexName, pipeName); + using var secondary = new SingleInstanceService(mutexName, pipeName); + var receivedInvocation = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + + primary.InvocationReceived += (_, arguments) => receivedInvocation.TrySetResult(arguments); + primary.StartListening(); + + await secondary.ForwardInvocationAsync(Array.Empty()); + var receivedArguments = await receivedInvocation.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Empty(receivedArguments); + } +} diff --git a/ui/Sentinel.NLogViewer.Wpf.MaterialDesign/Sentinel.NLogViewer.Wpf.MaterialDesign.csproj b/ui/Sentinel.NLogViewer.Wpf.MaterialDesign/Sentinel.NLogViewer.Wpf.MaterialDesign.csproj index b13dd09..f7493cd 100644 --- a/ui/Sentinel.NLogViewer.Wpf.MaterialDesign/Sentinel.NLogViewer.Wpf.MaterialDesign.csproj +++ b/ui/Sentinel.NLogViewer.Wpf.MaterialDesign/Sentinel.NLogViewer.Wpf.MaterialDesign.csproj @@ -4,7 +4,8 @@ net8-windows true Sentinel.NLogViewer.Wpf.MaterialDesign - true + + false Sentinel.NLogViewer.MaterialDesign diff --git a/ui/Sentinel.NLogViewer.Wpf/Helper/AutoSizedGridView.cs b/ui/Sentinel.NLogViewer.Wpf/Helper/AutoSizedGridView.cs index 05ea927..e0a9bb5 100644 --- a/ui/Sentinel.NLogViewer.Wpf/Helper/AutoSizedGridView.cs +++ b/ui/Sentinel.NLogViewer.Wpf/Helper/AutoSizedGridView.cs @@ -1,6 +1,8 @@ using System; using System.Reactive.Linq; using System.Threading; +using System.Windows; +using System.Windows.Threading; using System.Windows.Controls; using NLog; @@ -21,7 +23,8 @@ protected override void PrepareItem(ListViewItem item) if (info.LoggerName.Length > _MaxLoggerNameLength) { _MaxLoggerNameLength = info.LoggerName.Length; - Observable.Timer(TimeSpan.FromMilliseconds(1)).ObserveOn(SynchronizationContext.Current).Subscribe(l => + var sync = SynchronizationContext.Current ?? new DispatcherSynchronizationContext(Application.Current?.Dispatcher ?? System.Windows.Threading.Dispatcher.CurrentDispatcher); + Observable.Timer(TimeSpan.FromMilliseconds(1)).ObserveOn(sync).Subscribe(l => { foreach (GridViewColumn column in Columns) { diff --git a/ui/Sentinel.NLogViewer.Wpf/Helper/ListViewLayoutManager/ListViewLayoutManager.cs b/ui/Sentinel.NLogViewer.Wpf/Helper/ListViewLayoutManager/ListViewLayoutManager.cs index 591f7e9..199146f 100644 --- a/ui/Sentinel.NLogViewer.Wpf/Helper/ListViewLayoutManager/ListViewLayoutManager.cs +++ b/ui/Sentinel.NLogViewer.Wpf/Helper/ListViewLayoutManager/ListViewLayoutManager.cs @@ -34,12 +34,12 @@ public ScrollBarVisibility VerticalScrollBarVisibility // ########################################################################################## private readonly ListView _ListView; - private ScrollViewer _ScrollViewer; + private ScrollViewer? _ScrollViewer; private bool _Loaded; private bool _Resizing; - private Cursor _ResizeCursor; + private Cursor? _ResizeCursor; private ScrollBarVisibility _VerticalScrollBarVisibility = ScrollBarVisibility.Auto; - private GridViewColumn _AutoSizedColumn; + private GridViewColumn? _AutoSizedColumn; private const double _ZERO_WIDTH_RANGE = 0.1; @@ -112,7 +112,7 @@ protected virtual void ResizeColumns() { Application.Current.Dispatcher.InvokeAsync(delegate { - GridView view = _ListView.View as GridView; + GridView? view = _ListView.View as GridView; if (view == null || view.Columns.Count == 0) { return; @@ -163,7 +163,7 @@ protected virtual void ResizeColumns() } // search the first fill column - GridViewColumn fillColumn = null; + GridViewColumn? fillColumn = null; for (int i = 0; i < view.Columns.Count; i++) { GridViewColumn gridViewColumn = view.Columns[i]; @@ -256,13 +256,17 @@ private void _RegisterEvents(DependencyObject start) { for (int i = 0; i < VisualTreeHelper.GetChildrenCount(start); i++) { - Visual childVisual = VisualTreeHelper.GetChild(start, i) as Visual; - if (childVisual is Thumb) + Visual? childVisual = VisualTreeHelper.GetChild(start, i) as Visual; + if (childVisual == null) { - GridViewColumn gridViewColumn = _FindParentColumn(childVisual); + continue; + } + + if (childVisual is Thumb thumb) + { + GridViewColumn? gridViewColumn = _FindParentColumn(thumb); if (gridViewColumn != null) { - Thumb thumb = childVisual as Thumb; if (ProportionalColumn.IsProportionalColumn(gridViewColumn) || FixedColumn.IsFixedColumn(gridViewColumn) || _IsFillColumn(gridViewColumn)) { @@ -279,14 +283,13 @@ private void _RegisterEvents(DependencyObject start) } } } - else if (childVisual is GridViewColumnHeader) + else if (childVisual is GridViewColumnHeader columnHeader) { - GridViewColumnHeader columnHeader = childVisual as GridViewColumnHeader; columnHeader.SizeChanged += _GridColumnHeaderSizeChanged; } - else if (_ScrollViewer == null && childVisual is ScrollViewer) + else if (_ScrollViewer == null && childVisual is ScrollViewer sv) { - _ScrollViewer = childVisual as ScrollViewer; + _ScrollViewer = sv; _ScrollViewer.ScrollChanged += _ScrollViewerScrollChanged; // assume we do the regulation of the horizontal scrollbar _ScrollViewer.HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden; @@ -301,13 +304,17 @@ private void _UnRegisterEvents(DependencyObject start) { for (int i = 0; i < VisualTreeHelper.GetChildrenCount(start); i++) { - Visual childVisual = VisualTreeHelper.GetChild(start, i) as Visual; - if (childVisual is Thumb) + Visual? childVisual = VisualTreeHelper.GetChild(start, i) as Visual; + if (childVisual == null) + { + continue; + } + + if (childVisual is Thumb thumb) { - GridViewColumn gridViewColumn = _FindParentColumn(childVisual); + GridViewColumn? gridViewColumn = _FindParentColumn(thumb); if (gridViewColumn != null) { - Thumb thumb = childVisual as Thumb; if (ProportionalColumn.IsProportionalColumn(gridViewColumn) || FixedColumn.IsFixedColumn(gridViewColumn) || _IsFillColumn(gridViewColumn)) { @@ -324,22 +331,21 @@ private void _UnRegisterEvents(DependencyObject start) } } } - else if (childVisual is GridViewColumnHeader) + else if (childVisual is GridViewColumnHeader columnHeader) { - GridViewColumnHeader columnHeader = childVisual as GridViewColumnHeader; columnHeader.SizeChanged -= _GridColumnHeaderSizeChanged; } - else if (_ScrollViewer == null && childVisual is ScrollViewer) + else if (childVisual is ScrollViewer sv && ReferenceEquals(sv, _ScrollViewer)) { - _ScrollViewer = childVisual as ScrollViewer; - _ScrollViewer.ScrollChanged -= _ScrollViewerScrollChanged; + sv.ScrollChanged -= _ScrollViewerScrollChanged; + _ScrollViewer = null; } _UnRegisterEvents(childVisual); } } - private GridViewColumn _FindParentColumn(DependencyObject element) + private GridViewColumn? _FindParentColumn(DependencyObject? element) { if (element == null) { @@ -348,7 +354,7 @@ private GridViewColumn _FindParentColumn(DependencyObject element) while (element != null) { - GridViewColumnHeader gridViewColumnHeader = element as GridViewColumnHeader; + GridViewColumnHeader? gridViewColumnHeader = element as GridViewColumnHeader; if (gridViewColumnHeader != null) { return (gridViewColumnHeader).Column; @@ -360,21 +366,25 @@ private GridViewColumn _FindParentColumn(DependencyObject element) return null; } - private GridViewColumnHeader _FindColumnHeader(DependencyObject start, GridViewColumn gridViewColumn) + private GridViewColumnHeader? _FindColumnHeader(DependencyObject? start, GridViewColumn gridViewColumn) { + if (start == null) + { + return null; + } + for (int i = 0; i < VisualTreeHelper.GetChildrenCount(start); i++) { - Visual childVisual = VisualTreeHelper.GetChild(start, i) as Visual; - if (childVisual is GridViewColumnHeader) + Visual? childVisual = VisualTreeHelper.GetChild(start, i) as Visual; + if (childVisual is GridViewColumnHeader gridViewHeader) { - GridViewColumnHeader gridViewHeader = childVisual as GridViewColumnHeader; if (gridViewHeader.Column == gridViewColumn) { return gridViewHeader; } } - GridViewColumnHeader childGridViewHeader = _FindColumnHeader(childVisual, gridViewColumn); + GridViewColumnHeader? childGridViewHeader = _FindColumnHeader(childVisual, gridViewColumn); if (childGridViewHeader != null) { return childGridViewHeader; @@ -386,7 +396,7 @@ private GridViewColumnHeader _FindColumnHeader(DependencyObject start, GridViewC private void _InitColumns() { - GridView view = _ListView.View as GridView; + GridView? view = _ListView.View as GridView; if (view == null) { return; @@ -406,7 +416,7 @@ private void _InitColumns() continue; } - GridViewColumnHeader columnHeader = _FindColumnHeader(_ListView, gridViewColumn); + GridViewColumnHeader? columnHeader = _FindColumnHeader(_ListView, gridViewColumn); if (columnHeader == null) { continue; @@ -465,7 +475,7 @@ private bool _IsFillColumn(GridViewColumn gridViewColumn) return false; } - GridView view = _ListView.View as GridView; + GridView? view = _ListView.View as GridView; if (view == null || view.Columns.Count == 0) { return false; @@ -475,15 +485,13 @@ private bool _IsFillColumn(GridViewColumn gridViewColumn) return isFillColumn.HasValue && isFillColumn.Value; } - private void _ThumbPreviewMouseMove(object sender, MouseEventArgs e) + private void _ThumbPreviewMouseMove(object? sender, MouseEventArgs e) { - Thumb thumb = sender as Thumb; - if (thumb == null) + if (sender is not Thumb thumb) { return; } - - GridViewColumn gridViewColumn = _FindParentColumn(thumb); + GridViewColumn? gridViewColumn = _FindParentColumn(thumb); if (gridViewColumn == null) { return; @@ -529,10 +537,18 @@ private void _ThumbPreviewMouseMove(object sender, MouseEventArgs e) } } - private void _ThumbPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + private void _ThumbPreviewMouseLeftButtonDown(object? sender, MouseButtonEventArgs e) { - Thumb thumb = sender as Thumb; - GridViewColumn gridViewColumn = _FindParentColumn(thumb); + if (sender is not Thumb thumb) + { + return; + } + + GridViewColumn? gridViewColumn = _FindParentColumn(thumb); + if (gridViewColumn == null) + { + return; + } // suppress column resizing for proportional, fixed and range fill columns if (ProportionalColumn.IsProportionalColumn(gridViewColumn) || @@ -543,14 +559,18 @@ private void _ThumbPreviewMouseLeftButtonDown(object sender, MouseButtonEventArg } } - private void _GridColumnWidthChanged(object sender, EventArgs e) + private void _GridColumnWidthChanged(object? sender, EventArgs e) { if (!_Loaded) { return; } - GridViewColumn gridViewColumn = sender as GridViewColumn; + GridViewColumn? gridViewColumn = sender as GridViewColumn; + if (gridViewColumn == null) + { + return; + } // suppress column resizing for proportional and fixed columns if (ProportionalColumn.IsProportionalColumn(gridViewColumn) || FixedColumn.IsFixedColumn(gridViewColumn)) @@ -562,7 +582,7 @@ private void _GridColumnWidthChanged(object sender, EventArgs e) if (RangeColumn.IsRangeColumn(gridViewColumn)) { // special case: auto column width - maybe conflicts with min/max range - if (gridViewColumn != null && gridViewColumn.Width.Equals(double.NaN)) + if (gridViewColumn.Width.Equals(double.NaN)) { _AutoSizedColumn = gridViewColumn; return; // handled by the change header size event @@ -579,15 +599,19 @@ private void _GridColumnWidthChanged(object sender, EventArgs e) } // handle autosized column - private void _GridColumnHeaderSizeChanged(object sender, SizeChangedEventArgs e) + private void _GridColumnHeaderSizeChanged(object? sender, SizeChangedEventArgs e) { if (_AutoSizedColumn == null) { return; } - GridViewColumnHeader gridViewColumnHeader = sender as GridViewColumnHeader; - if (gridViewColumnHeader != null && gridViewColumnHeader.Column == _AutoSizedColumn) + if (sender is not GridViewColumnHeader gridViewColumnHeader) + { + return; + } + + if (gridViewColumnHeader.Column == _AutoSizedColumn) { if (gridViewColumnHeader.Width.Equals(double.NaN)) { @@ -600,7 +624,7 @@ private void _GridColumnHeaderSizeChanged(object sender, SizeChangedEventArgs e) } } - private void _ScrollViewerScrollChanged(object sender, ScrollChangedEventArgs e) + private void _ScrollViewerScrollChanged(object? sender, ScrollChangedEventArgs e) { if (_Loaded && Math.Abs(e.ViewportWidthChange - 0) > _ZERO_WIDTH_RANGE) { @@ -611,7 +635,7 @@ private void _ScrollViewerScrollChanged(object sender, ScrollChangedEventArgs e) private static void _OnLayoutManagerEnabledChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e) { - ListView listView = dependencyObject as ListView; + ListView? listView = dependencyObject as ListView; if (listView != null) { bool enabled = (bool) e.NewValue; diff --git a/ui/Sentinel.NLogViewer.Wpf/Helper/MessageHighlighter.cs b/ui/Sentinel.NLogViewer.Wpf/Helper/MessageHighlighter.cs index 23a0200..b5ebdae 100644 --- a/ui/Sentinel.NLogViewer.Wpf/Helper/MessageHighlighter.cs +++ b/ui/Sentinel.NLogViewer.Wpf/Helper/MessageHighlighter.cs @@ -163,14 +163,14 @@ private static void TryUpdate(TextBlock textBlock) } } - private static T FindAncestor(DependencyObject current) where T : DependencyObject + private static T? FindAncestor(DependencyObject? current) where T : DependencyObject { while (current != null) { if (current is T typed) return typed; current = System.Windows.Media.VisualTreeHelper.GetParent(current); } - return null; + return default; } } } diff --git a/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs b/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs index bdd4bf9..853406b 100644 --- a/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs +++ b/ui/Sentinel.NLogViewer.Wpf/NLogViewer.xaml.cs @@ -14,7 +14,7 @@ namespace Sentinel.NLogViewer.Wpf /// public partial class NLogViewer : NLogViewerBase { - private IDisposable _subscription; + private IDisposable? _subscription; private bool _isListening; /// diff --git a/ui/Sentinel.NLogViewer.Wpf/NLogViewerBase.cs b/ui/Sentinel.NLogViewer.Wpf/NLogViewerBase.cs index 91b9cae..12e9370 100644 --- a/ui/Sentinel.NLogViewer.Wpf/NLogViewerBase.cs +++ b/ui/Sentinel.NLogViewer.Wpf/NLogViewerBase.cs @@ -118,6 +118,11 @@ public override string ToString() /// public class NLogViewerBase : Control { + /// + /// Parses a brush for dependency-property default metadata (hex string). + /// + private static Brush MetadataBrush(string hex) => (Brush)new BrushConverter().ConvertFrom(hex)!; + // ############################################################################################################################## // Dependency Properties // ############################################################################################################################## @@ -146,7 +151,7 @@ public Brush TraceBackground /// public static readonly DependencyProperty TraceBackgroundProperty = DependencyProperty.Register(nameof(TraceBackground), typeof(Brush), typeof(NLogViewerBase), - new PropertyMetadata((Brush) (new BrushConverter().ConvertFrom("#D3D3D3")))); + new PropertyMetadata(MetadataBrush("#D3D3D3"))); /// /// The foreground for the trace output @@ -164,7 +169,7 @@ public Brush TraceForeground /// public static readonly DependencyProperty TraceForegroundProperty = DependencyProperty.Register(nameof(TraceForeground), typeof(Brush), typeof(NLogViewerBase), - new PropertyMetadata((Brush) (new BrushConverter().ConvertFrom("#042271")))); + new PropertyMetadata(MetadataBrush("#042271"))); /// /// The background for the debug output @@ -182,7 +187,7 @@ public Brush DebugBackground /// public static readonly DependencyProperty DebugBackgroundProperty = DependencyProperty.Register(nameof(DebugBackground), typeof(Brush), typeof(NLogViewerBase), - new PropertyMetadata((Brush) (new BrushConverter().ConvertFrom("#90EE90")))); + new PropertyMetadata(MetadataBrush("#90EE90"))); /// /// The foreground for the debug output @@ -200,7 +205,7 @@ public Brush DebugForeground /// public static readonly DependencyProperty DebugForegroundProperty = DependencyProperty.Register(nameof(DebugForeground), typeof(Brush), typeof(NLogViewerBase), - new PropertyMetadata((Brush) (new BrushConverter().ConvertFrom("#042271")))); + new PropertyMetadata(MetadataBrush("#042271"))); /// /// The background for the info output @@ -218,7 +223,7 @@ public Brush InfoBackground /// public static readonly DependencyProperty InfoBackgroundProperty = DependencyProperty.Register(nameof(InfoBackground), typeof(Brush), typeof(NLogViewerBase), - new PropertyMetadata((Brush) (new BrushConverter().ConvertFrom("#0000FF")))); +new PropertyMetadata(MetadataBrush("#0000FF"))); /// /// The foreground for the info output @@ -253,7 +258,7 @@ public Brush WarnBackground /// public static readonly DependencyProperty WarnBackgroundProperty = DependencyProperty.Register(nameof(WarnBackground), typeof(Brush), typeof(NLogViewerBase), - new PropertyMetadata((Brush) (new BrushConverter().ConvertFrom("#FFFF00")))); +new PropertyMetadata(MetadataBrush("#FFFF00"))); /// /// The foreground for the warn output @@ -271,7 +276,7 @@ public Brush WarnForeground /// public static readonly DependencyProperty WarnForegroundProperty = DependencyProperty.Register(nameof(WarnForeground), typeof(Brush), typeof(NLogViewerBase), - new PropertyMetadata((Brush) (new BrushConverter().ConvertFrom("#324B5C")))); +new PropertyMetadata(MetadataBrush("#324B5C"))); /// /// The background for the error output @@ -365,7 +370,7 @@ public Brush TextSearchTermBackground /// public static readonly DependencyProperty TextSearchTermBackgroundProperty = DependencyProperty.Register(nameof(TextSearchTermBackground), typeof(Brush), typeof(NLogViewerBase), - new PropertyMetadata((Brush)(new BrushConverter().ConvertFrom("#FFFFFBE6")))); + new PropertyMetadata(MetadataBrush("#FFFFFBE6"))); /// /// Border brush for plain text search term chips @@ -383,7 +388,7 @@ public Brush TextSearchTermBorderBrush /// public static readonly DependencyProperty TextSearchTermBorderBrushProperty = DependencyProperty.Register(nameof(TextSearchTermBorderBrush), typeof(Brush), typeof(NLogViewerBase), - new PropertyMetadata((Brush)(new BrushConverter().ConvertFrom("#FFFFC107")))); + new PropertyMetadata(MetadataBrush("#FFFFC107"))); /// /// Foreground brush for plain text search term chips @@ -419,7 +424,7 @@ public Brush RegexSearchTermBackground /// public static readonly DependencyProperty RegexSearchTermBackgroundProperty = DependencyProperty.Register(nameof(RegexSearchTermBackground), typeof(Brush), typeof(NLogViewerBase), - new PropertyMetadata((Brush)(new BrushConverter().ConvertFrom("#FFFFFBE6")))); + new PropertyMetadata(MetadataBrush("#FFFFFBE6"))); /// /// Border brush for regex search term chips @@ -437,7 +442,7 @@ public Brush RegexSearchTermBorderBrush /// public static readonly DependencyProperty RegexSearchTermBorderBrushProperty = DependencyProperty.Register(nameof(RegexSearchTermBorderBrush), typeof(Brush), typeof(NLogViewerBase), - new PropertyMetadata((Brush)(new BrushConverter().ConvertFrom("#FFFFC107")))); + new PropertyMetadata(MetadataBrush("#FFFFC107"))); /// /// Foreground brush for regex search term chips @@ -473,7 +478,7 @@ public Brush RegexPrefixForeground /// public static readonly DependencyProperty RegexPrefixForegroundProperty = DependencyProperty.Register(nameof(RegexPrefixForeground), typeof(Brush), typeof(NLogViewerBase), - new PropertyMetadata((Brush)(new BrushConverter().ConvertFrom("#FFFF8F00")))); + new PropertyMetadata(MetadataBrush("#FFFF8F00"))); /// /// Background brush used to highlight matched search text in the message @@ -491,7 +496,7 @@ public Brush SearchHighlightBackground /// public static readonly DependencyProperty SearchHighlightBackgroundProperty = DependencyProperty.Register(nameof(SearchHighlightBackground), typeof(Brush), typeof(NLogViewerBase), - new PropertyMetadata((Brush)(new BrushConverter().ConvertFrom("#FFFF80C0")))); + new PropertyMetadata(MetadataBrush("#FFFF80C0"))); #endregion @@ -1085,7 +1090,7 @@ private void UpdateColumnVisibility() /// Whether the column should be visible /// The original width of the column /// The original index of the column (0=ID, 1=Level, 2=TimeStamp, 3=LoggerName) - private void UpdateColumnVisibility(AutoSizedGridView gridView, GridViewColumn originalColumn, bool showColumn, double originalWidth, int originalIndex) + private void UpdateColumnVisibility(AutoSizedGridView gridView, GridViewColumn? originalColumn, bool showColumn, double originalWidth, int originalIndex) { if (originalColumn == null) return; @@ -1121,25 +1126,25 @@ private int CalculateInsertionIndex(AutoSizedGridView gridView, int originalInde var insertionIndex = 0; // Check ID column (original index 0) - if (originalIndex > 0 && ShowIdColumn && gridView.Columns.Contains(_originalIdColumn)) + if (originalIndex > 0 && ShowIdColumn && _originalIdColumn != null && gridView.Columns.Contains(_originalIdColumn)) { insertionIndex++; } // Check Level column (original index 1) - if (originalIndex > 1 && ShowLevelColumn && gridView.Columns.Contains(_originalLevelColumn)) + if (originalIndex > 1 && ShowLevelColumn && _originalLevelColumn != null && gridView.Columns.Contains(_originalLevelColumn)) { insertionIndex++; } // Check TimeStamp column (original index 2) - if (originalIndex > 2 && ShowTimeStampColumn && gridView.Columns.Contains(_originalTimeStampColumn)) + if (originalIndex > 2 && ShowTimeStampColumn && _originalTimeStampColumn != null && gridView.Columns.Contains(_originalTimeStampColumn)) { insertionIndex++; } // Check LoggerName column (original index 3) - if (originalIndex > 3 && ShowLoggerNameColumn && gridView.Columns.Contains(_originalLoggerNameColumn)) + if (originalIndex > 3 && ShowLoggerNameColumn && _originalLoggerNameColumn != null && gridView.Columns.Contains(_originalLoggerNameColumn)) { insertionIndex++; } @@ -1659,10 +1664,10 @@ public bool ShowControlButtons private Window? _ParentWindow; // Store original column references and widths for restoration - private GridViewColumn _originalIdColumn; - private GridViewColumn _originalLevelColumn; - private GridViewColumn _originalTimeStampColumn; - private GridViewColumn _originalLoggerNameColumn; + private GridViewColumn? _originalIdColumn; + private GridViewColumn? _originalLevelColumn; + private GridViewColumn? _originalTimeStampColumn; + private GridViewColumn? _originalLoggerNameColumn; private double _originalIdColumnWidth = 40; private double _originalLevelColumnWidth = double.NaN; // Auto private double _originalTimeStampColumnWidth = double.NaN; // Auto @@ -1671,7 +1676,7 @@ public bool ShowControlButtons /// /// Reference to the ListView template part for programmatic access /// - private ListView PART_ListView => GetTemplateChild("PART_ListView") as ListView; + private ListView? PART_ListView => GetTemplateChild("PART_ListView") as ListView; #endregion @@ -1809,7 +1814,7 @@ private void _OnUnloaded(object sender, RoutedEventArgs e) DisposeViewerResources(); } - private void _ParentWindowOnClosed(object sender, EventArgs e) + private void _ParentWindowOnClosed(object? sender, EventArgs e) { DisposeViewerResources(); } diff --git a/ui/Sentinel.NLogViewer.Wpf/RelayCommand.cs b/ui/Sentinel.NLogViewer.Wpf/RelayCommand.cs index cb74633..1f1c00c 100644 --- a/ui/Sentinel.NLogViewer.Wpf/RelayCommand.cs +++ b/ui/Sentinel.NLogViewer.Wpf/RelayCommand.cs @@ -180,7 +180,7 @@ public bool CanExecute(object? parameter) return false; } - return _canExecute?.Invoke((T)parameter) ?? true; + return _canExecute?.Invoke((T)parameter!) ?? true; } /// @@ -195,7 +195,7 @@ public void Execute(object? parameter) } else if (parameter == null && !typeof(T).IsValueType) { - _execute((T)parameter); + _execute(default!); } else if (parameter != null) { diff --git a/ui/Sentinel.NLogViewer.Wpf/ScrollingHelper.cs b/ui/Sentinel.NLogViewer.Wpf/ScrollingHelper.cs index 7043ab2..c00c9ca 100644 --- a/ui/Sentinel.NLogViewer.Wpf/ScrollingHelper.cs +++ b/ui/Sentinel.NLogViewer.Wpf/ScrollingHelper.cs @@ -13,13 +13,13 @@ public static void ScrollToEnd(this ListView listView) scrollViewer?.ScrollToEnd(); } - public static Visual GetDescendantByType(Visual element, Type type) + public static Visual? GetDescendantByType(Visual? element, Type type) { if (element != null) { if (element.GetType() != type) { - Visual foundElement = null; + Visual? foundElement = null; (element as FrameworkElement)?.ApplyTemplate(); for (var i = 0; i < VisualTreeHelper.GetChildrenCount(element); i++) diff --git a/ui/Sentinel.NLogViewer.Wpf/Sentinel.NLogViewer.Wpf.csproj b/ui/Sentinel.NLogViewer.Wpf/Sentinel.NLogViewer.Wpf.csproj index 01b4636..d7d1b94 100644 --- a/ui/Sentinel.NLogViewer.Wpf/Sentinel.NLogViewer.Wpf.csproj +++ b/ui/Sentinel.NLogViewer.Wpf/Sentinel.NLogViewer.Wpf.csproj @@ -4,7 +4,8 @@ net8-windows true Sentinel.NLogViewer.Wpf - true + + false Sentinel.NLogViewer diff --git a/ui/Sentinel.NLogViewer.Wpf/Targets/CacheTarget.cs b/ui/Sentinel.NLogViewer.Wpf/Targets/CacheTarget.cs index 8ae9a06..73a5718 100644 --- a/ui/Sentinel.NLogViewer.Wpf/Targets/CacheTarget.cs +++ b/ui/Sentinel.NLogViewer.Wpf/Targets/CacheTarget.cs @@ -34,8 +34,8 @@ public static CacheTarget GetInstance(int defaultMaxCount = 0, string? targetNam predicate = predicate.And(t => t.Name.Equals(targetName, StringComparison.CurrentCultureIgnoreCase) ||t.Name.Equals($"{targetName}_wrapped", StringComparison.CurrentCultureIgnoreCase)); } - var target = (CacheTarget)LogManager.Configuration.AllTargets.FirstOrDefault(predicate.Compile()); - if (target == null) + var found = LogManager.Configuration.AllTargets.FirstOrDefault(predicate.Compile()); + if (found is not CacheTarget target) { target = new CacheTarget(defaultMaxCount) { Name = targetName ?? nameof(CacheTarget)}; LogManager.Configuration.AddTarget(target.Name, target);