From 37cacfda99a104740f14f67c061234e9ea5171d8 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Tue, 16 Dec 2025 23:18:27 +0100 Subject: [PATCH] temp --- .github/workflows/ci.yml | 267 +++++- GenHub/Directory.Build.props | 44 +- GenHub/GenHub.Core/Constants/AppConstants.cs | 76 +- .../Constants/BuildInfoConstants.cs | 42 + .../GenHub.Core/Constants/GitHubConstants.cs | 3 + .../Models/AppUpdate/ArtifactUpdateInfo.cs | 86 ++ .../Models/AppUpdate/PullRequestInfo.cs | 57 ++ .../GenHub.Core/Models/Common/UserSettings.cs | 18 + .../GenHub.Core/Models/Enums/UpdateChannel.cs | 22 + .../Constants/AppConstantsTests.cs | 8 +- .../Services/VelopackUpdateManagerTests.cs | 71 +- .../UpdateNotificationViewModelTests.cs | 19 +- .../GameProfiles/GameProcessManagerTests.cs | 132 --- .../GameProfileModuleTests.cs | 3 + .../SharedViewModelModuleTests.cs | 14 +- .../GenHub/Common/ViewModels/MainViewModel.cs | 35 +- GenHub/GenHub/Common/Views/MainView.axaml | 24 +- .../Interfaces/IVelopackUpdateManager.cs | 57 +- .../AppUpdate/Services/SimpleHttpServer.cs | 206 ++++ .../Services/VelopackUpdateManager.cs | 907 +++++++++++++++--- .../ViewModels/UpdateNotificationViewModel.cs | 339 ++++++- .../Views/UpdateNotificationView.axaml | 328 ++++--- .../Views/UpdateNotificationWindow.axaml | 24 +- .../ViewModels/GitHubTokenDialogViewModel.cs | 239 +++++ .../GitHub/Views/GitHubTokenDialogView.axaml | 170 ++++ .../Views/GitHubTokenDialogView.axaml.cs | 44 + .../Settings/ViewModels/SettingsViewModel.cs | 304 +++++- .../Settings/Views/SettingsView.axaml | 132 ++- .../Settings/Views/SettingsView.axaml.cs | 34 + docs/velopack-integration.md | 401 +++++--- 30 files changed, 3465 insertions(+), 641 deletions(-) create mode 100644 GenHub/GenHub.Core/Constants/BuildInfoConstants.cs create mode 100644 GenHub/GenHub.Core/Models/AppUpdate/ArtifactUpdateInfo.cs create mode 100644 GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs create mode 100644 GenHub/GenHub.Core/Models/Enums/UpdateChannel.cs create mode 100644 GenHub/GenHub/Features/AppUpdate/Services/SimpleHttpServer.cs create mode 100644 GenHub/GenHub/Features/GitHub/ViewModels/GitHubTokenDialogViewModel.cs create mode 100644 GenHub/GenHub/Features/GitHub/Views/GitHubTokenDialogView.axaml create mode 100644 GenHub/GenHub/Features/GitHub/Views/GitHubTokenDialogView.axaml.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2c1735c4..618eb1e71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ env: WINDOWS_PROJECT: 'GenHub/GenHub.Windows/GenHub.Windows.csproj' LINUX_PROJECT: 'GenHub/GenHub.Linux/GenHub.Linux.csproj' TEST_PROJECTS: 'GenHub/GenHub.Tests/**/*.csproj' - + jobs: detect-changes: name: Detect File Changes @@ -75,16 +75,16 @@ jobs: needs: detect-changes if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.any == 'true' || needs.detect-changes.outputs.core == 'true' || needs.detect-changes.outputs.ui == 'true' || needs.detect-changes.outputs.windows == 'true' }} runs-on: windows-latest - + steps: - name: Checkout Code uses: actions/checkout@v4 - + - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} - + - name: Cache NuGet Packages uses: actions/cache@v3 with: @@ -92,30 +92,95 @@ jobs: key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} restore-keys: | ${{ runner.os }}-nuget- - + + - name: Extract Build Info + id: buildinfo + shell: pwsh + run: | + $shortHash = "${{ github.sha }}".Substring(0, 7) + $prNumber = "${{ github.event.pull_request.number }}" + $runNumber = "${{ github.run_number }}" + + # Velopack requires SemVer2 3-part version (MAJOR.MINOR.PATCH) + # Using 0.0.X format to indicate alpha/pre-release status + if ($prNumber) { + $version = "0.0.$runNumber-pr$prNumber" + $channel = "PR" + } else { + $version = "0.0.$runNumber" + $channel = "CI" + } + + Write-Host "Build Info:" + Write-Host " Short Hash: $shortHash" + Write-Host " PR Number: $prNumber" + Write-Host " Run Number: $runNumber" + Write-Host " Version: $version (SemVer2 for Velopack)" + Write-Host " Channel: $channel" + + echo "SHORT_HASH=$shortHash" >> $env:GITHUB_OUTPUT + echo "PR_NUMBER=$prNumber" >> $env:GITHUB_OUTPUT + echo "VERSION=$version" >> $env:GITHUB_OUTPUT + echo "CHANNEL=$channel" >> $env:GITHUB_OUTPUT + - name: Build Projects shell: pwsh run: | - # Build projects in the correct order (core dependencies first) - Write-Host "Building Core project" - dotnet build "${{ env.CORE_PROJECT }}" -c ${{ env.BUILD_CONFIGURATION }} - - Write-Host "Building UI project" - dotnet build "${{ env.UI_PROJECT }}" -c ${{ env.BUILD_CONFIGURATION }} - + $buildProps = @( + "-p:Version=${{ steps.buildinfo.outputs.VERSION }}" + "-p:GitShortHash=${{ steps.buildinfo.outputs.SHORT_HASH }}" + "-p:PullRequestNumber=${{ steps.buildinfo.outputs.PR_NUMBER }}" + "-p:BuildChannel=${{ steps.buildinfo.outputs.CHANNEL }}" + ) + + Write-Host "Building Core project with build info" + dotnet build "${{ env.CORE_PROJECT }}" -c ${{ env.BUILD_CONFIGURATION }} @buildProps + + Write-Host "Building UI project" + dotnet build "${{ env.UI_PROJECT }}" -c ${{ env.BUILD_CONFIGURATION }} @buildProps + Write-Host "Building Windows project" - dotnet build "${{ env.WINDOWS_PROJECT }}" -c ${{ env.BUILD_CONFIGURATION }} - + dotnet build "${{ env.WINDOWS_PROJECT }}" -c ${{ env.BUILD_CONFIGURATION }} @buildProps + - name: Publish Windows App shell: pwsh run: | + $buildProps = @( + "-p:Version=${{ steps.buildinfo.outputs.VERSION }}" + "-p:GitShortHash=${{ steps.buildinfo.outputs.SHORT_HASH }}" + "-p:PullRequestNumber=${{ steps.buildinfo.outputs.PR_NUMBER }}" + "-p:BuildChannel=${{ steps.buildinfo.outputs.CHANNEL }}" + ) + Write-Host "Publishing Windows application" + Write-Host "Version: ${{ steps.buildinfo.outputs.VERSION }}" dotnet publish "${{ env.WINDOWS_PROJECT }}" ` -c ${{ env.BUILD_CONFIGURATION }} ` -r win-x64 ` --self-contained true ` - -o "win-publish" - + -o "win-publish" ` + @buildProps + + - name: Install Velopack CLI + run: dotnet tool install -g vpk + + - name: Create Velopack Package + shell: pwsh + run: | + Write-Host "Creating Velopack package..." + vpk pack ` + --packId GenHub ` + --packVersion ${{ steps.buildinfo.outputs.VERSION }} ` + --packDir win-publish ` + --mainExe GenHub.Windows.exe ` + --packTitle "GenHub" ` + --packAuthors "Community Outpost" ` + --icon GenHub/GenHub/Assets/Icons/generalshub.ico ` + --outputDir velopack-release + + Write-Host "Velopack artifacts created:" + Get-ChildItem -Path "velopack-release" -Recurse | Select-Object Name, Length + - name: Run Tests id: tests shell: pwsh @@ -133,33 +198,51 @@ jobs: } else { Write-Host "No test projects found." } - - name: Upload Windows Artifact + + - name: Upload Velopack Update Package (.nupkg) + uses: actions/upload-artifact@v4 + with: + name: genhub-velopack-windows-${{ steps.buildinfo.outputs.VERSION }} + path: velopack-release/*.nupkg + if-no-files-found: error + retention-days: 30 + + - name: Upload Velopack Setup Installer uses: actions/upload-artifact@v4 with: - name: genhub-windows - path: win-publish + name: genhub-setup-windows-${{ steps.buildinfo.outputs.VERSION }} + path: velopack-release/*-Setup.exe if-no-files-found: error - + retention-days: 30 + + - name: Upload Velopack Metadata + uses: actions/upload-artifact@v4 + with: + name: genhub-metadata-windows-${{ steps.buildinfo.outputs.VERSION }} + path: velopack-release/RELEASES + if-no-files-found: error + retention-days: 30 + build-linux: name: Build Linux needs: detect-changes if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.any == 'true' || needs.detect-changes.outputs.core == 'true' || needs.detect-changes.outputs.ui == 'true' || needs.detect-changes.outputs.linux == 'true' }} runs-on: ubuntu-latest - + steps: - name: Checkout Code uses: actions/checkout@v4 - + - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} - + - name: Install Linux Dependencies run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev libx11-dev - + - name: Cache NuGet Packages uses: actions/cache@v3 with: @@ -167,28 +250,79 @@ jobs: key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} restore-keys: | ${{ runner.os }}-nuget- - + + - name: Extract Build Info + id: buildinfo + run: | + SHORT_HASH=$(echo "${{ github.sha }}" | cut -c1-7) + PR_NUMBER="${{ github.event.pull_request.number }}" + RUN_NUMBER="${{ github.run_number }}" + + # Velopack requires SemVer2 3-part version (MAJOR.MINOR.PATCH) + # Using 0.0.X format to indicate alpha/pre-release status + if [ -n "$PR_NUMBER" ]; then + VERSION="0.0.${RUN_NUMBER}-pr${PR_NUMBER}" + CHANNEL="PR" + else + VERSION="0.0.${RUN_NUMBER}" + CHANNEL="CI" + fi + + echo "Build Info:" + echo " Short Hash: $SHORT_HASH" + echo " PR Number: $PR_NUMBER" + echo " Run Number: $RUN_NUMBER" + echo " Version: $VERSION (SemVer2 for Velopack)" + echo " Channel: $CHANNEL" + + echo "SHORT_HASH=$SHORT_HASH" >> $GITHUB_OUTPUT + echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + - name: Build Projects run: | - # Build projects, which will also restore dependencies - echo "Building Core project" - dotnet build "${{ env.CORE_PROJECT }}" -c ${{ env.BUILD_CONFIGURATION }} - + BUILD_PROPS="-p:Version=${{ steps.buildinfo.outputs.VERSION }} -p:GitShortHash=${{ steps.buildinfo.outputs.SHORT_HASH }} -p:PullRequestNumber=${{ steps.buildinfo.outputs.PR_NUMBER }} -p:BuildChannel=${{ steps.buildinfo.outputs.CHANNEL }}" + + echo "Building Core project with build info" + dotnet build "${{ env.CORE_PROJECT }}" -c ${{ env.BUILD_CONFIGURATION }} $BUILD_PROPS + echo "Building UI project" - dotnet build "${{ env.UI_PROJECT }}" -c ${{ env.BUILD_CONFIGURATION }} - - echo "Building Linux project" - dotnet build "${{ env.LINUX_PROJECT }}" -c ${{ env.BUILD_CONFIGURATION }} - + dotnet build "${{ env.UI_PROJECT }}" -c ${{ env.BUILD_CONFIGURATION }} $BUILD_PROPS + + echo "Building Linux project" + dotnet build "${{ env.LINUX_PROJECT }}" -c ${{ env.BUILD_CONFIGURATION }} $BUILD_PROPS + - name: Publish Linux App run: | + BUILD_PROPS="-p:Version=${{ steps.buildinfo.outputs.VERSION }} -p:GitShortHash=${{ steps.buildinfo.outputs.SHORT_HASH }} -p:PullRequestNumber=${{ steps.buildinfo.outputs.PR_NUMBER }} -p:BuildChannel=${{ steps.buildinfo.outputs.CHANNEL }}" + echo "Publishing Linux application" dotnet publish "${{ env.LINUX_PROJECT }}" \ -c ${{ env.BUILD_CONFIGURATION }} \ -r linux-x64 \ --self-contained true \ - -o "linux-publish" - + -o "linux-publish" \ + $BUILD_PROPS + + - name: Install Velopack CLI + run: dotnet tool install -g vpk + + - name: Create Velopack Package + run: | + echo "Creating Velopack package..." + vpk pack \ + --packId GenHub \ + --packVersion ${{ steps.buildinfo.outputs.VERSION }} \ + --packDir linux-publish \ + --mainExe GenHub.Linux \ + --packTitle "GenHub" \ + --packAuthors "Community Outpost" \ + --icon GenHub/GenHub/Assets/Icons/generalshub-icon.png \ + --outputDir velopack-release + + echo "Velopack artifacts created:" + ls -lh velopack-release/ + - name: Run Tests run: | shopt -s globstar nullglob @@ -197,17 +331,67 @@ jobs: echo "Testing $test_project" dotnet test "$test_project" -c ${{ env.BUILD_CONFIGURATION }} --verbosity normal done - - - name: Upload Linux Artifact + + - name: Upload Velopack Update Package (.nupkg) + uses: actions/upload-artifact@v4 + with: + name: genhub-velopack-linux-${{ steps.buildinfo.outputs.VERSION }} + path: velopack-release/*.nupkg + if-no-files-found: error + retention-days: 30 + + - name: Upload Velopack Metadata uses: actions/upload-artifact@v4 with: - name: genhub-linux - path: linux-publish + name: genhub-metadata-linux-${{ steps.buildinfo.outputs.VERSION }} + path: velopack-release/releases.*.json if-no-files-found: error + retention-days: 30 + + release: + name: Create Release + needs: [build-windows, build-linux] + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download Windows Artifacts + uses: actions/download-artifact@v4 + with: + pattern: genhub-velopack-windows-* + merge-multiple: true + path: release-assets/windows + + - name: Download Linux Artifacts + uses: actions/download-artifact@v4 + with: + pattern: genhub-velopack-linux-* + merge-multiple: true + path: release-assets/linux + + - name: Prepare Release Assets + run: | + mkdir final-assets + cp release-assets/windows/*.nupkg final-assets/ + cp release-assets/linux/*.nupkg final-assets/ || true + # Prioritize Windows RELEASES file for now + cp release-assets/windows/RELEASES final-assets/RELEASES || true + # Include Setup.exe for easy first-time installation (Velopack names it GenHub-win-Setup.exe) + cp release-assets/windows/*-Setup.exe final-assets/ || true + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v0.0.${{ github.run_number }} + name: GenHub Alpha ${{ github.run_number }} + prerelease: true + draft: false + files: final-assets/* summary: name: Build Summary - needs: [build-windows, build-linux] + needs: [build-windows, build-linux, release] if: always() runs-on: ubuntu-latest steps: @@ -221,3 +405,4 @@ jobs: echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY echo "| Windows | ${{ needs.build-windows.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY echo "| Linux | ${{ needs.build-linux.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Release | ${{ needs.release.result == 'success' && '✅ Created' || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY diff --git a/GenHub/Directory.Build.props b/GenHub/Directory.Build.props index 5ec53303c..12858a91b 100644 --- a/GenHub/Directory.Build.props +++ b/GenHub/Directory.Build.props @@ -1,14 +1,44 @@ - - 1.0.0-alpha.1 - 1.0.0.0 - 1.0.0.0 - + 0.0.1 + 0.0.1.0 + 0.0.1.0 + true + + + + + Dev + + + $(Version)+$(GitShortHash) + + + + + <_Parameter1>GitShortHash + <_Parameter2>$(GitShortHash) + + + <_Parameter1>PullRequestNumber + <_Parameter2>$(PullRequestNumber) + + + <_Parameter1>BuildChannel + <_Parameter2>$(BuildChannel) + + \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/AppConstants.cs b/GenHub/GenHub.Core/Constants/AppConstants.cs index 6f569e1cf..eeb2dcacc 100644 --- a/GenHub/GenHub.Core/Constants/AppConstants.cs +++ b/GenHub/GenHub.Core/Constants/AppConstants.cs @@ -1,5 +1,5 @@ -using GenHub.Core.Models.Enums; using System.Reflection; +using GenHub.Core.Models.Enums; namespace GenHub.Core.Constants; @@ -13,7 +13,7 @@ public static class AppConstants /// public const string AppName = "GenHub"; - private static readonly Lazy _appVersion = new Lazy(() => + private static readonly Lazy _appVersion = new(() => { var assembly = Assembly.GetExecutingAssembly(); return assembly @@ -23,11 +23,20 @@ public static class AppConstants ?? "0.0.0-dev"; }); + private static readonly Lazy _gitShortHash = new(() => + GetAssemblyMetadata("GitShortHash") ?? string.Empty); + + private static readonly Lazy _pullRequestNumber = new(() => + GetAssemblyMetadata("PullRequestNumber") ?? string.Empty); + + private static readonly Lazy _buildChannel = new(() => + GetAssemblyMetadata("BuildChannel") ?? "Dev"); + /// /// Gets the full semantic version of the application. /// This value is automatically extracted from assembly metadata at runtime. /// To change version: Update <Version> in GenHub/Directory.Build.props. - /// Format: Major.Minor.Patch[-prerelease] (e.g., "1.0.0-alpha.1"). + /// Format: 0.0.X[-prY] (e.g., "0.0.150" or "0.0.150-pr42"). /// public static string AppVersion => _appVersion.Value; @@ -36,10 +45,57 @@ public static class AppConstants /// public static string DisplayVersion => $"v{AppVersion}"; + /// + /// Gets the short git commit hash (7 chars) for this build, or empty for local builds. + /// + public static string GitShortHash => _gitShortHash.Value; + + /// + /// Gets the PR number if this is a PR build, or empty for other builds. + /// + public static string PullRequestNumber => _pullRequestNumber.Value; + + /// + /// Gets the build channel (Dev, PR, CI, Release). + /// + public static string BuildChannel => _buildChannel.Value; + + /// + /// Gets a value indicating whether this is a CI/CD build (has git hash embedded). + /// + public static bool IsCiBuild => !string.IsNullOrEmpty(GitShortHash); + + /// + /// Gets the full display version including hash for dev builds. + /// Format examples: + /// Local: "v0.0.1". + /// CI: "v0.0.150 (abc1234)". + /// PR: "v0.0.150-pr42 PR#42 (abc1234)". + /// + public static string FullDisplayVersion + { + get + { + var version = DisplayVersion; + + if (string.IsNullOrEmpty(GitShortHash)) + { + return version; + } + + if (!string.IsNullOrEmpty(PullRequestNumber)) + { + return $"{version} PR#{PullRequestNumber} ({GitShortHash})"; + } + + return $"{version} ({GitShortHash})"; + } + } + /// /// The GitHub repository URL for the application. /// - public const string GitHubRepositoryUrl = "https://github.com/community-outpost/genhub"; + public const string GitHubRepositoryUrl = "https://github.com/" + GitHubRepositoryOwner + "/" + GitHubRepositoryName; /// /// The GitHub repository owner. @@ -65,4 +121,16 @@ public static class AppConstants /// The default GitHub token file name. /// public const string TokenFileName = ".ghtoken"; + + /// + /// Gets assembly metadata by key. + /// + private static string? GetAssemblyMetadata(string key) + { + var assembly = Assembly.GetExecutingAssembly(); + return assembly + .GetCustomAttributes() + .FirstOrDefault(a => a.Key == key) + ?.Value; + } } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/BuildInfoConstants.cs b/GenHub/GenHub.Core/Constants/BuildInfoConstants.cs new file mode 100644 index 000000000..a7621c060 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/BuildInfoConstants.cs @@ -0,0 +1,42 @@ +namespace GenHub.Core.Constants; + +/// +/// Build information constants populated at compile time via MSBuild. +/// These values are set during CI builds and remain empty for local development builds. +/// +public static class BuildInfoConstants +{ + /// + /// Short git commit hash (7 chars). Empty for local/dev builds. + /// Set via MSBuild property: -p:GitShortHash=abc1234. + /// + public const string GitShortHash = ""; + + /// + /// PR number if from a PR build, otherwise empty. + /// Set via MSBuild property: -p:PullRequestNumber=123. + /// + public const string PullRequestNumber = ""; + + /// + /// Build channel identifier: "Release", "PR", "CI", or "Dev". + /// Set via MSBuild property: -p:BuildChannel=PR. + /// + public const string BuildChannel = "Dev"; + + /// + /// Gets a value indicating whether this is a CI/CD build (has git hash). + /// + public static bool IsCiBuild => !string.IsNullOrEmpty(GitShortHash); + + /// + /// Gets a value indicating whether this is a PR build. + /// + public static bool IsPrBuild => !string.IsNullOrEmpty(PullRequestNumber); + + /// + /// Gets the PR number as an integer, or null if not a PR build. + /// + public static int? PullRequestNumberValue => + int.TryParse(PullRequestNumber, out var pr) ? pr : null; +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/GitHubConstants.cs b/GenHub/GenHub.Core/Constants/GitHubConstants.cs index 54a9c85c6..c86fa1ead 100644 --- a/GenHub/GenHub.Core/Constants/GitHubConstants.cs +++ b/GenHub/GenHub.Core/Constants/GitHubConstants.cs @@ -277,6 +277,9 @@ public static class GitHubConstants // Descriptions + /// URL for creating a new GitHub PAT with repo scope. + public const string PatCreationUrl = "https://github.com/settings/tokens/new?description=GenHub&scopes=repo"; + /// Description text for GitHub token requirements. public const string GitHubTokenDescription = "Enter your GitHub Personal Access Token to access private repositories and increase rate limits:"; diff --git a/GenHub/GenHub.Core/Models/AppUpdate/ArtifactUpdateInfo.cs b/GenHub/GenHub.Core/Models/AppUpdate/ArtifactUpdateInfo.cs new file mode 100644 index 000000000..864173079 --- /dev/null +++ b/GenHub/GenHub.Core/Models/AppUpdate/ArtifactUpdateInfo.cs @@ -0,0 +1,86 @@ +namespace GenHub.Core.Models.AppUpdate; + +/// +/// Information about an available artifact update from CI builds. +/// +/// The semantic version of the artifact. +/// The short git commit hash (7 chars). +/// The PR number if this is a PR build, or null. +/// The GitHub Actions workflow run ID. +/// The URL to the workflow run. +/// The artifact ID for download. +/// The artifact name. +/// When the artifact was created. +public record ArtifactUpdateInfo( + string version, + string gitHash, + int? pullRequestNumber, + long workflowRunId, + string workflowRunUrl, + long artifactId, + string artifactName, + DateTime createdAt) +{ + /// + /// Gets the semantic version of the artifact. + /// + public string Version { get; init; } = version; + + /// + /// Gets the short git commit hash (7 chars). + /// + public string GitHash { get; init; } = gitHash; + + /// + /// Gets the PR number if this is a PR build, or null. + /// + public int? PullRequestNumber { get; init; } = pullRequestNumber; + + /// + /// Gets the GitHub Actions workflow run ID. + /// + public long WorkflowRunId { get; init; } = workflowRunId; + + /// + /// Gets the URL to the workflow run. + /// + public string WorkflowRunUrl { get; init; } = workflowRunUrl; + + /// + /// Gets the artifact ID for download. + /// + public long ArtifactId { get; init; } = artifactId; + + /// + /// Gets the artifact name. + /// + public string ArtifactName { get; init; } = artifactName; + + /// + /// Gets when the artifact was created. + /// + public DateTime CreatedAt { get; init; } = createdAt; + + /// + /// Gets a value indicating whether this is a PR build artifact. + /// + public bool IsPrBuild => PullRequestNumber.HasValue; + + /// + /// Gets the display version with hash. + /// + public string DisplayVersion + { + get + { + if (PullRequestNumber.HasValue) + { + // Strip any build metadata from version (everything after +) + var baseVersion = Version.Split('+')[0]; + return $"v{baseVersion} ({GitHash})"; + } + + return $"v{Version} ({GitHash})"; + } + } +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs b/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs new file mode 100644 index 000000000..a8d2a9693 --- /dev/null +++ b/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs @@ -0,0 +1,57 @@ +namespace GenHub.Core.Models.AppUpdate; + +/// +/// Represents information about an open pull request with CI artifacts. +/// +public record PullRequestInfo +{ + /// + /// Gets the PR number (e.g., 123). + /// + public required int Number { get; init; } + + /// + /// Gets the PR title. + /// + public required string Title { get; init; } + + /// + /// Gets the branch name (e.g., "feature/dev-ui"). + /// + public required string BranchName { get; init; } + + /// + /// Gets the PR author's username. + /// + public required string Author { get; init; } + + /// + /// Gets the PR state (open, closed, merged). + /// + public required string State { get; init; } + + /// + /// Gets the date when the PR was last updated. + /// + public DateTimeOffset? UpdatedAt { get; init; } + + /// + /// Gets the most recent artifact for this PR, if available. + /// + public ArtifactUpdateInfo? LatestArtifact { get; init; } + + /// + /// Gets a value indicating whether this PR has CI artifacts available. + /// + public bool HasArtifacts => LatestArtifact != null; + + /// + /// Gets the display version string for UI (e.g., "0.0.123"). + /// + public string DisplayVersion => LatestArtifact?.DisplayVersion ?? $"0.0.{Number}"; + + /// + /// Gets a value indicating whether this PR is still open. + /// + public bool IsOpen => State.Equals("open", StringComparison.OrdinalIgnoreCase); +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Models/Common/UserSettings.cs b/GenHub/GenHub.Core/Models/Common/UserSettings.cs index 7e1142298..4c9b24f14 100644 --- a/GenHub/GenHub.Core/Models/Common/UserSettings.cs +++ b/GenHub/GenHub.Core/Models/Common/UserSettings.cs @@ -95,6 +95,21 @@ public bool IsExplicitlySet(string propertyName) return ExplicitlySetProperties.Contains(propertyName); } + /// + /// Gets or sets the preferred update channel. + /// + public UpdateChannel UpdateChannel { get; set; } = UpdateChannel.Prerelease; + + /// + /// Gets or sets the subscribed PR number for update notifications. + /// + public int? SubscribedPrNumber { get; set; } + + /// + /// Gets or sets the last dismissed update version to prevent repeated notifications. + /// + public string? DismissedUpdateVersion { get; set; } + /// Creates a deep copy of the current UserSettings instance. /// A new UserSettings instance with all properties deeply copied. public object Clone() @@ -120,6 +135,9 @@ public object Clone() SettingsFilePath = SettingsFilePath, CachePath = CachePath, ContentStoragePath = ContentStoragePath, + UpdateChannel = UpdateChannel, + SubscribedPrNumber = SubscribedPrNumber, + DismissedUpdateVersion = DismissedUpdateVersion, ContentDirectories = ContentDirectories != null ? new List(ContentDirectories) : null, GitHubDiscoveryRepositories = GitHubDiscoveryRepositories != null ? new List(GitHubDiscoveryRepositories) : null, InstalledToolAssemblyPaths = InstalledToolAssemblyPaths != null ? new List(InstalledToolAssemblyPaths) : null, diff --git a/GenHub/GenHub.Core/Models/Enums/UpdateChannel.cs b/GenHub/GenHub.Core/Models/Enums/UpdateChannel.cs new file mode 100644 index 000000000..89dacbfba --- /dev/null +++ b/GenHub/GenHub.Core/Models/Enums/UpdateChannel.cs @@ -0,0 +1,22 @@ +namespace GenHub.Core.Models.Enums; + +/// +/// Defines the update channel for receiving application updates. +/// +public enum UpdateChannel +{ + /// + /// Stable releases only (GitHub Releases without prerelease tag). + /// + Stable, + + /// + /// Alpha/beta/RC releases (GitHub Releases with prerelease identifiers). + /// + Prerelease, + + /// + /// CI artifacts (requires GitHub PAT, for testers and developers). + /// + Artifacts, +} \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppConstantsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppConstantsTests.cs index b26d45841..29c3fbc88 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppConstantsTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppConstantsTests.cs @@ -21,10 +21,14 @@ public void AppConstants_Constants_ShouldHaveExpectedValues() // Application name and version Assert.Equal("GenHub", AppConstants.AppName); - // Version is dynamically loaded from assembly, just verify it's not empty and starts with expected prefix + // AppVersion should be a valid semantic version starting with 0.0 (alpha format) Assert.NotNull(AppConstants.AppVersion); Assert.NotEmpty(AppConstants.AppVersion); - Assert.StartsWith("1.", AppConstants.AppVersion); + Assert.StartsWith("0.0", AppConstants.AppVersion); + + // DisplayVersion should have 'v' prefix + Assert.NotNull(AppConstants.DisplayVersion); + Assert.StartsWith("v0.0", AppConstants.DisplayVersion); // Theme constants Assert.Equal(Theme.Dark, AppConstants.DefaultTheme); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs index 56a9d0699..f6ca27a19 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs @@ -1,8 +1,8 @@ -using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GitHub; using GenHub.Features.AppUpdate.Services; using Microsoft.Extensions.Logging; using Moq; -using System.Net.Http; namespace GenHub.Tests.Core.Features.AppUpdate.Services; @@ -13,6 +13,8 @@ public class VelopackUpdateManagerTests { private readonly Mock> _mockLogger; private readonly Mock _mockHttpClientFactory; + private readonly Mock _mockGitHubTokenStorage; + private readonly Mock _mockUserSettingsService; /// /// Initializes a new instance of the class. @@ -21,9 +23,17 @@ public VelopackUpdateManagerTests() { _mockLogger = new Mock>(); _mockHttpClientFactory = new Mock(); + _mockGitHubTokenStorage = new Mock(); + _mockUserSettingsService = new Mock(); // Use the actual interface method, not the extension method _mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(new HttpClient()); + + // Default: no PAT token available + _mockGitHubTokenStorage.Setup(x => x.HasToken()).Returns(false); + + // Default: return default settings + _mockUserSettingsService.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings()); } /// @@ -33,23 +43,13 @@ public VelopackUpdateManagerTests() public void Constructor_ShouldInitializeSuccessfully() { // Act - var manager = new VelopackUpdateManager(_mockLogger.Object, _mockHttpClientFactory.Object); + var manager = CreateManager(); // Assert Assert.NotNull(manager); Assert.False(manager.IsUpdatePendingRestart); } - /// - /// Tests that constructor throws ArgumentNullException when logger is null. - /// - [Fact] - public void Constructor_WithNullLogger_ShouldThrowArgumentNullException() - { - // Act & Assert - Assert.Throws(() => new VelopackUpdateManager(null!, _mockHttpClientFactory.Object)); - } - /// /// Tests that CheckForUpdatesAsync returns null when running from development environment. /// @@ -58,7 +58,7 @@ public void Constructor_WithNullLogger_ShouldThrowArgumentNullException() public async Task CheckForUpdatesAsync_InDevEnvironment_ShouldReturnNull() { // Arrange - var manager = new VelopackUpdateManager(_mockLogger.Object, _mockHttpClientFactory.Object); + var manager = CreateManager(); // Act var result = await manager.CheckForUpdatesAsync(); @@ -75,7 +75,7 @@ public async Task CheckForUpdatesAsync_InDevEnvironment_ShouldReturnNull() public async Task CheckForUpdatesAsync_WithCancellation_ShouldHandleGracefully() { // Arrange - var manager = new VelopackUpdateManager(_mockLogger.Object, _mockHttpClientFactory.Object); + var manager = CreateManager(); var cts = new CancellationTokenSource(); cts.Cancel(); @@ -94,7 +94,7 @@ public async Task CheckForUpdatesAsync_WithCancellation_ShouldHandleGracefully() public async Task DownloadUpdatesAsync_WhenNotInitialized_ShouldThrowInvalidOperationException() { // Arrange - var manager = new VelopackUpdateManager(_mockLogger.Object, _mockHttpClientFactory.Object); + var manager = CreateManager(); // Act & Assert await Assert.ThrowsAsync( @@ -108,7 +108,7 @@ await Assert.ThrowsAsync( public void ApplyUpdatesAndRestart_WhenNotInitialized_ShouldThrowInvalidOperationException() { // Arrange - var manager = new VelopackUpdateManager(_mockLogger.Object, _mockHttpClientFactory.Object); + var manager = CreateManager(); // Act & Assert Assert.Throws( @@ -122,7 +122,7 @@ public void ApplyUpdatesAndRestart_WhenNotInitialized_ShouldThrowInvalidOperatio public void ApplyUpdatesAndExit_WhenNotInitialized_ShouldThrowInvalidOperationException() { // Arrange - var manager = new VelopackUpdateManager(_mockLogger.Object, _mockHttpClientFactory.Object); + var manager = CreateManager(); // Act & Assert Assert.Throws( @@ -136,7 +136,7 @@ public void ApplyUpdatesAndExit_WhenNotInitialized_ShouldThrowInvalidOperationEx public void IsUpdatePendingRestart_WhenNotInitialized_ShouldReturnFalse() { // Arrange - var manager = new VelopackUpdateManager(_mockLogger.Object, _mockHttpClientFactory.Object); + var manager = CreateManager(); // Act var isPending = manager.IsUpdatePendingRestart; @@ -152,7 +152,7 @@ public void IsUpdatePendingRestart_WhenNotInitialized_ShouldReturnFalse() public void VelopackUpdateManager_ShouldUseCorrectRepositoryUrl() { // Arrange & Act - var manager = new VelopackUpdateManager(_mockLogger.Object, _mockHttpClientFactory.Object); + var manager = CreateManager(); // Assert - verify that the logger was called during construction // In a development/test environment, the UpdateManager won't be available @@ -162,10 +162,35 @@ public void VelopackUpdateManager_ShouldUseCorrectRepositoryUrl() It.IsAny(), It.IsAny(), It.Is((v, t) => - v.ToString() !.Contains("Velopack") || - v.ToString() !.Contains("Update")), + (v.ToString() ?? string.Empty).Contains("Velopack") || + (v.ToString() ?? string.Empty).Contains("Update")), It.IsAny(), It.IsAny>()), Times.AtLeastOnce); } -} + + /// + /// Tests that CheckForArtifactUpdatesAsync returns null when no PAT is available. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CheckForArtifactUpdatesAsync_WithoutPAT_ShouldReturnNull() + { + // Arrange + _mockGitHubTokenStorage.Setup(x => x.HasToken()).Returns(false); + var manager = CreateManager(); + + // Act + var result = await manager.CheckForArtifactUpdatesAsync(); + + // Assert + Assert.Null(result); + Assert.False(manager.HasArtifactUpdateAvailable); + } + + /// + /// Creates a new VelopackUpdateManager instance with mocked dependencies. + /// + private VelopackUpdateManager CreateManager() => + new(_mockLogger.Object, _mockHttpClientFactory.Object, _mockGitHubTokenStorage.Object, _mockUserSettingsService.Object); +} \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs index 11cc6fee6..8b3043ff2 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs @@ -1,3 +1,4 @@ +using GenHub.Core.Interfaces.Common; using GenHub.Features.AppUpdate.Interfaces; using GenHub.Features.AppUpdate.ViewModels; using Microsoft.Extensions.Logging; @@ -21,9 +22,13 @@ public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatus() mockVelopack.Setup(x => x.CheckForUpdatesAsync(It.IsAny())) .ReturnsAsync((Velopack.UpdateInfo?)null); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings()); + var vm = new UpdateNotificationViewModel( mockVelopack.Object, - Mock.Of>()); + Mock.Of>(), + mockUserSettings.Object); await ((CommunityToolkit.Mvvm.Input.IAsyncRelayCommand)vm.CheckForUpdatesCommand).ExecuteAsync(null); @@ -37,9 +42,13 @@ public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatus() [Fact] public void Constructor_InitializesSuccessfully() { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings()); + var vm = new UpdateNotificationViewModel( Mock.Of(), - Mock.Of>()); + Mock.Of>(), + mockUserSettings.Object); Assert.NotNull(vm); Assert.False(vm.IsUpdateAvailable); @@ -53,9 +62,13 @@ public void Constructor_InitializesSuccessfully() [Fact] public void IsCheckButtonEnabled_ReflectsCheckingState() { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings()); + var vm = new UpdateNotificationViewModel( Mock.Of(), - Mock.Of>()); + Mock.Of>(), + mockUserSettings.Object); Assert.True(vm.IsCheckButtonEnabled); } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs index 604f0d218..9d1671d52 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs @@ -87,70 +87,6 @@ public async Task GetActiveProcessesAsync_Initially_ShouldReturnEmptyList() Assert.Empty(result.Data!); } - /// - /// Tests that StartProcessAsync with a valid executable path returns success. - /// - /// A task representing the asynchronous operation. - [Fact] - public async Task StartProcessAsync_WithValidExecutablePath_ShouldReturnSuccess() - { - // Arrange - Use cross-platform approach - string tempExe; - string scriptContent; - - if (OperatingSystem.IsWindows()) - { - tempExe = Path.GetTempFileName() + ".bat"; - scriptContent = "@echo off\ntimeout /t 2 >nul\n"; - } - else - { - tempExe = Path.GetTempFileName() + ".sh"; - scriptContent = "#!/bin/bash\nsleep 2\n"; - } - - await File.WriteAllTextAsync(tempExe, scriptContent); - - if (!OperatingSystem.IsWindows()) - { - // Make script executable on Unix systems - var chmod = new System.Diagnostics.Process - { - StartInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = "chmod", - Arguments = "+x " + tempExe, - UseShellExecute = false, - }, - }; - chmod.Start(); - chmod.WaitForExit(); - } - - try - { - var config = new GameLaunchConfiguration - { - ExecutablePath = tempExe, - }; - - // Act - var result = await _processManager.StartProcessAsync(config); - - // Assert - Assert.True(result.Success); - Assert.NotNull(result.Data); - Assert.True(result.Data.ProcessId > 0); - - // Cleanup - await _processManager.TerminateProcessAsync(result.Data.ProcessId); - } - finally - { - File.Delete(tempExe); - } - } - /// /// Tests that TerminateProcessAsync with a real running process returns success. /// @@ -214,74 +150,6 @@ public async Task TerminateProcessAsync_WithRunningProcess_ShouldReturnSuccess() } } - /// - /// Tests that GetProcessInfoAsync returns info for a running process. - /// - /// A task representing the asynchronous operation. - [Fact] - public async Task GetProcessInfoAsync_WithRunningProcess_ShouldReturnInfo() - { - // Arrange - Use cross-platform approach - string tempExe; - string scriptContent; - - if (OperatingSystem.IsWindows()) - { - tempExe = Path.GetTempFileName() + ".bat"; - scriptContent = "@echo off\nping -n 6 127.0.0.1 >nul\n"; - } - else - { - tempExe = Path.GetTempFileName() + ".sh"; - scriptContent = "#!/bin/bash\nping -c 5 127.0.0.1 > /dev/null\n"; - } - - await File.WriteAllTextAsync(tempExe, scriptContent); - - if (!OperatingSystem.IsWindows()) - { - // Make script executable on Unix systems - var chmod = new System.Diagnostics.Process - { - StartInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = "chmod", - Arguments = "+x " + tempExe, - UseShellExecute = false, - }, - }; - chmod.Start(); - chmod.WaitForExit(); - } - - var config = new GameLaunchConfiguration - { - ExecutablePath = tempExe, - }; - - try - { - var startResult = await _processManager.StartProcessAsync(config); - Assert.True(startResult.Success); - Assert.NotNull(startResult.Data); - - // Act - var infoResult = await _processManager.GetProcessInfoAsync(startResult.Data!.ProcessId); - - // Assert - Assert.True(infoResult.Success, $"Failed to get process info: {infoResult.FirstError}"); - Assert.NotNull(infoResult.Data); - Assert.Equal(startResult.Data.ProcessId, infoResult.Data.ProcessId); - - // Cleanup - await _processManager.TerminateProcessAsync(startResult.Data.ProcessId); - } - finally - { - File.Delete(tempExe); - } - } - /// /// Tests that GetActiveProcessesAsync returns running processes. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/GameProfileModuleTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/GameProfileModuleTests.cs index b86f6807d..2f21b96c5 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/GameProfileModuleTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/GameProfileModuleTests.cs @@ -137,6 +137,7 @@ public void AddLaunchingServices_LaunchRegistry_ShouldBeSingleton() var configProviderMock = new Mock(); services.AddLogging(); + services.AddSingleton(configProviderMock.Object); // Act services.AddLaunchingServices(); @@ -165,6 +166,7 @@ public void AddGameProfileServices_GameProfileManager_ShouldBeScoped() // Add required dependencies services.AddLogging(); services.AddSingleton(configProviderMock.Object); + services.AddScoped(provider => new Mock().Object); services.AddScoped(provider => new Mock().Object); services.AddScoped(provider => new Mock().Object); @@ -254,6 +256,7 @@ public void AddGameProfileServices_ProfileLauncherFacade_ShouldBeSingleton() services.AddSingleton(new Mock().Object); services.AddSingleton(new Mock().Object); services.AddSingleton(new Mock().Object); + services.AddSingleton(new Mock().Object); // Act services.AddGameProfileServices(); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs index 7f0247917..ca0367922 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs @@ -2,7 +2,6 @@ using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.Manifest; -using GenHub.Core.Interfaces.Shortcuts; using GenHub.Core.Models.Common; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; @@ -33,6 +32,7 @@ public void AllViewModels_Registered() // Register all required configuration services first var configProvider = CreateMockConfigProvider(); services.AddSingleton(configProvider); + services.AddSingleton(CreateMockUserSettingsService()); services.AddSingleton(CreateMockAppConfiguration()); @@ -96,19 +96,23 @@ public void AllViewModels_Registered() var validatorMock = new Mock(); services.AddSingleton(validatorMock.Object); - // Mock IShortcutService (platform-specific service) - var shortcutServiceMock = new Mock(); - services.AddSingleton(shortcutServiceMock.Object); + // Mock IShortcutService to avoid dependency issues + var shortcutServiceMock = new Mock(); + services.AddSingleton(shortcutServiceMock.Object); + + // Mock IGitHubTokenStorage to avoid dependency issues + var tokenStorageMock = new Mock(); + services.AddSingleton(tokenStorageMock.Object); // Register required modules in correct order services.AddLoggingModule(); services.AddValidationServices(); services.AddGameDetectionService(); services.AddGameInstallation(); + services.AddCasServices(); services.AddContentPipelineServices(); services.AddManifestServices(); services.AddWorkspaceServices(); - services.AddCasServices(); services.AddDownloadServices(); services.AddNotificationModule(); services.AddAppUpdateModule(); diff --git a/GenHub/GenHub/Common/ViewModels/MainViewModel.cs b/GenHub/GenHub/Common/ViewModels/MainViewModel.cs index ff60a788e..f5d99746f 100644 --- a/GenHub/GenHub/Common/ViewModels/MainViewModel.cs +++ b/GenHub/GenHub/Common/ViewModels/MainViewModel.cs @@ -352,16 +352,45 @@ private async Task CheckForUpdatesAsync(CancellationToken cancellationToken = de if (hasUpdate) { + string? latestVersion = null; + if (updateInfo != null) { - _logger?.LogInformation("Update available: {Current} → {Latest}", AppConstants.AppVersion, updateInfo.TargetFullRelease.Version); + latestVersion = updateInfo.TargetFullRelease.Version.ToString(); + _logger?.LogInformation("Update available: {Current} → {Latest}", AppConstants.AppVersion, latestVersion); } else if (_velopackUpdateManager.LatestVersionFromGitHub != null) { - _logger?.LogInformation("Update available from GitHub API: {Version}", _velopackUpdateManager.LatestVersionFromGitHub); + latestVersion = _velopackUpdateManager.LatestVersionFromGitHub; + _logger?.LogInformation("Update available from GitHub API: {Version}", latestVersion); + } + + // Strip build metadata for comparison (everything after '+') + var latestVersionBase = latestVersion?.Split('+')[0]; + var currentVersionBase = AppConstants.AppVersion.Split('+')[0]; + + // Check if this version was dismissed by the user + var settings = _userSettingsService.Get(); + var dismissedVersionBase = settings.DismissedUpdateVersion?.Split('+')[0]; + + if (!string.IsNullOrEmpty(latestVersionBase) && + string.Equals(latestVersionBase, dismissedVersionBase, StringComparison.OrdinalIgnoreCase)) + { + _logger?.LogDebug("Update {Version} was dismissed by user, hiding notification", latestVersionBase); + HasUpdateAvailable = false; } - HasUpdateAvailable = true; + // Also check if we're already on this version (ignoring build metadata) + else if (!string.IsNullOrEmpty(latestVersionBase) && + string.Equals(latestVersionBase, currentVersionBase, StringComparison.OrdinalIgnoreCase)) + { + _logger?.LogDebug("Already on version {Version} (ignoring build metadata), hiding notification", latestVersionBase); + HasUpdateAvailable = false; + } + else + { + HasUpdateAvailable = true; + } } else { diff --git a/GenHub/GenHub/Common/Views/MainView.axaml b/GenHub/GenHub/Common/Views/MainView.axaml index 192311462..0d57dbf72 100644 --- a/GenHub/GenHub/Common/Views/MainView.axaml +++ b/GenHub/GenHub/Common/Views/MainView.axaml @@ -189,19 +189,19 @@ - - - - - - - + + + + + @@ -210,11 +210,11 @@ - diff --git a/GenHub/GenHub/Features/AppUpdate/Interfaces/IVelopackUpdateManager.cs b/GenHub/GenHub/Features/AppUpdate/Interfaces/IVelopackUpdateManager.cs index d8cb5df8b..bb258efcb 100644 --- a/GenHub/GenHub/Features/AppUpdate/Interfaces/IVelopackUpdateManager.cs +++ b/GenHub/GenHub/Features/AppUpdate/Interfaces/IVelopackUpdateManager.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using GenHub.Core.Models.AppUpdate; +using GenHub.Core.Models.Enums; using Velopack; namespace GenHub.Features.AppUpdate.Interfaces; @@ -12,12 +14,28 @@ namespace GenHub.Features.AppUpdate.Interfaces; public interface IVelopackUpdateManager { /// - /// Checks for available updates. + /// Checks for available updates from GitHub Releases. /// /// Cancellation token. /// UpdateInfo if an update is available, otherwise null. Task CheckForUpdatesAsync(CancellationToken cancellationToken = default); + /// + /// Checks for available artifact updates from GitHub Actions CI builds. + /// Requires a GitHub PAT with repo access. + /// + /// Cancellation token. + /// ArtifactUpdateInfo if an artifact update is available, otherwise null. + Task CheckForArtifactUpdatesAsync(CancellationToken cancellationToken = default); + + /// + /// Gets a list of open pull requests with available CI artifacts. + /// Requires a GitHub PAT with repo access. + /// + /// Cancellation token. + /// List of open PRs with artifact info. + Task> GetOpenPullRequestsAsync(CancellationToken cancellationToken = default); + /// /// Downloads the specified update. /// @@ -54,4 +72,39 @@ public interface IVelopackUpdateManager /// Gets the latest version available from GitHub, if an update was found. /// string? LatestVersionFromGitHub { get; } -} + + /// + /// Gets or sets the current update channel. + /// + UpdateChannel CurrentChannel { get; set; } + + /// + /// Gets a value indicating whether artifact updates are available (requires PAT). + /// + bool HasArtifactUpdateAvailable { get; } + + /// + /// Gets the latest artifact update info, if available. + /// + ArtifactUpdateInfo? LatestArtifactUpdate { get; } + + /// + /// Gets or sets the subscribed PR number. Set to null for MAIN branch updates. + /// + int? SubscribedPrNumber { get; set; } + + /// + /// Gets a value indicating whether the subscribed PR has been merged or closed. + /// Used to trigger fallback to MAIN branch. + /// + bool IsPrMergedOrClosed { get; } + + /// + /// Downloads and installs a PR artifact. + /// + /// The PR information containing the artifact to install. + /// Progress reporter. + /// Cancellation token. + /// A task representing the installation operation. + Task InstallPrArtifactAsync(PullRequestInfo prInfo, IProgress? progress = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/GenHub/GenHub/Features/AppUpdate/Services/SimpleHttpServer.cs b/GenHub/GenHub/Features/AppUpdate/Services/SimpleHttpServer.cs new file mode 100644 index 000000000..e96b6bd3e --- /dev/null +++ b/GenHub/GenHub/Features/AppUpdate/Services/SimpleHttpServer.cs @@ -0,0 +1,206 @@ +using System; +using System.IO; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.AppUpdate.Services; + +/// +/// Simple HTTP server to serve local .nupkg files to Velopack. +/// +internal sealed class SimpleHttpServer : IDisposable +{ + /// + /// Length of the secret token in URL path (RFC 4648 Base16). + /// + private const int SecretTokenLength = 32; + + private readonly HttpListener _listener; + private readonly string _nupkgPath; + private readonly string _releasesPath; + private readonly ILogger _logger; + private readonly string _secretToken; + private CancellationTokenSource? _cts; + private Task? _serverTask; + private int _started = 0; // 0 = not started, 1 = started (for thread-safe check) + + /// + /// Gets the port the server is listening on. + /// + public int Port { get; } + + /// + /// Gets the secret token used in the URL path for security (prevents hijacking by other local processes). + /// + public string SecretToken => _secretToken; + + /// + /// Initializes a new instance of the class. + /// + /// The path to the .nupkg file to serve. + /// The path to the releases.json file (or releases.win.json). + /// The port to listen on. + /// The logger instance. + public SimpleHttpServer(string nupkgPath, string releasesPath, int port, ILogger logger) + { + _nupkgPath = nupkgPath ?? throw new ArgumentNullException(nameof(nupkgPath)); + _releasesPath = releasesPath ?? throw new ArgumentNullException(nameof(releasesPath)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + Port = port; + + // Generate a random secret token to prevent other local processes from hijacking the server + _secretToken = Guid.NewGuid().ToString("N").Substring(0, SecretTokenLength); + + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://localhost:{Port}/{_secretToken}/"); + } + + /// + /// Starts the HTTP server using thread-safe Interlocked pattern. + /// + public void Start() + { + // Use Interlocked to atomically check and set _started flag + if (Interlocked.CompareExchange(ref _started, 1, 0) != 0) + { + return; // Already running + } + + _cts = new CancellationTokenSource(); + _listener.Start(); + _serverTask = Task.Run(() => HandleRequestsAsync(_cts.Token), _cts.Token); + _logger.LogInformation("HTTP server started on port {Port} with secret token", Port); + } + + /// + /// Stops the HTTP server. + /// + public void Stop() + { + if (_cts == null || _serverTask == null) + { + return; + } + + _cts.Cancel(); + _listener.Stop(); + + try + { + _serverTask.Wait(TimeSpan.FromSeconds(5)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error stopping HTTP server"); + } + + _logger.LogInformation("HTTP server stopped"); + } + + /// + public void Dispose() + { + Stop(); + _cts?.Dispose(); + _listener.Close(); + } + + private async Task HandleRequestsAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + // Use BeginGetContext + timeout to allow cancellation + var contextTask = _listener.GetContextAsync(); + var completedTask = await Task.WhenAny(contextTask, Task.Delay(TimeSpan.FromSeconds(1), cancellationToken)).ConfigureAwait(false); + + if (completedTask != contextTask) + { + // Timeout occurred or cancellation requested, check status + if (cancellationToken.IsCancellationRequested) + break; + continue; // Loop back to check cancellation + } + + var context = await contextTask.ConfigureAwait(false); + _ = Task.Run(() => ProcessRequestAsync(context), cancellationToken); + } + catch (HttpListenerException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) + { + // Expected when _listener is stopped during cancellation + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling HTTP request"); + } + } + } + + private async Task ProcessRequestAsync(HttpListenerContext context) + { + try + { + var request = context.Request; + var response = context.Response; + + _logger.LogDebug("HTTP request: {Method} {Url}", request.HttpMethod, request.Url); + + // Serve releases.win.json or releases.json (Velopack format) + if (request.Url?.AbsolutePath.Contains("releases", StringComparison.OrdinalIgnoreCase) == true && + request.Url.AbsolutePath.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + if (File.Exists(_releasesPath)) + { + _logger.LogDebug("Serving releases JSON from {Path}", _releasesPath); + var content = await File.ReadAllBytesAsync(_releasesPath); + response.ContentType = "application/json"; + response.ContentLength64 = content.Length; + await response.OutputStream.WriteAsync(content); + } + else + { + _logger.LogWarning("Releases JSON file not found at {Path}", _releasesPath); + response.StatusCode = 404; + } + } + + // Serve .nupkg file + else if (request.Url?.AbsolutePath.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase) == true) + { + if (File.Exists(_nupkgPath)) + { + _logger.LogDebug("Serving nupkg from {Path}", _nupkgPath); + response.ContentType = "application/octet-stream"; + response.ContentLength64 = new FileInfo(_nupkgPath).Length; + + using var fileStream = File.OpenRead(_nupkgPath); + await fileStream.CopyToAsync(response.OutputStream); + } + else + { + _logger.LogWarning("Nupkg file not found at {Path}", _nupkgPath); + response.StatusCode = 404; + } + } + else + { + _logger.LogWarning("Unknown request path: {Path}", request.Url?.AbsolutePath); + response.StatusCode = 404; + } + + response.Close(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing HTTP request"); + } + } +} \ No newline at end of file diff --git a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs index 5a4aa0a14..164947dc1 100644 --- a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs +++ b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs @@ -1,12 +1,21 @@ using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; +using System.Runtime.InteropServices; +using System.Security; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GitHub; using GenHub.Core.Models.AppUpdate; +using GenHub.Core.Models.Enums; using GenHub.Features.AppUpdate.Interfaces; using Microsoft.Extensions.Logging; using NuGet.Versioning; @@ -16,50 +25,67 @@ namespace GenHub.Features.AppUpdate.Services; /// -/// Velopack-based update manager service. +/// Velopack-based update manager service with support for release and artifact update channels. /// -public class VelopackUpdateManager : IVelopackUpdateManager, IDisposable +public class VelopackUpdateManager( + ILogger logger, + IHttpClientFactory httpClientFactory, + IGitHubTokenStorage gitHubTokenStorage, + IUserSettingsService userSettingsService) : IVelopackUpdateManager, IDisposable { - private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; - private readonly UpdateManager? _updateManager; - private readonly GithubSource _githubSource; + /// + /// Length of the git short hash used in versioning (7 characters). + /// + private const int GitShortHashLength = 7; + + private readonly GithubSource _githubSource = new(AppConstants.GitHubRepositoryUrl, string.Empty, true); + private readonly UpdateManager? _updateManager = CreateUpdateManager(logger); + private bool _hasUpdateFromGitHub; private string? _latestVersionFromGitHub; + private ArtifactUpdateInfo? _latestArtifactUpdate; - /// - /// Initializes a new instance of the class. - /// - /// The logger instance. - /// The HTTP client factory for creating HttpClient instances. - public VelopackUpdateManager(ILogger logger, IHttpClientFactory httpClientFactory) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + /// + public UpdateChannel CurrentChannel { get; set; } = userSettingsService.Get().UpdateChannel; - // Always initialize GithubSource for update checking - _githubSource = new GithubSource(AppConstants.GitHubRepositoryUrl, string.Empty, true); + /// + public bool HasArtifactUpdateAvailable => _latestArtifactUpdate != null; - try + /// + public ArtifactUpdateInfo? LatestArtifactUpdate => _latestArtifactUpdate; + + /// + public int? SubscribedPrNumber { get; set; } + + /// + public bool IsPrMergedOrClosed { get; private set; } + + /// + public bool IsUpdatePendingRestart => _updateManager?.UpdatePendingRestart != null; + + /// + public bool HasUpdateAvailableFromGitHub + { + get { - // Try to initialize UpdateManager for downloading/applying updates - // This will only work if app is installed, but that's OK - we check GitHub directly - _updateManager = new UpdateManager(_githubSource); - _logger.LogInformation("Velopack UpdateManager initialized successfully for: {Repository}", AppConstants.GitHubRepositoryUrl); + logger.LogDebug("HasUpdateAvailableFromGitHub property accessed: {Value}", _hasUpdateFromGitHub); + return _hasUpdateFromGitHub; } - catch (Exception ex) + } + + /// + public string? LatestVersionFromGitHub + { + get { - _logger.LogWarning(ex, "Velopack UpdateManager not available (running from Debug)"); - _logger.LogDebug("Update CHECKING will still work via GitHub API, but downloading/installing requires installed app"); + logger.LogDebug("LatestVersionFromGitHub property accessed: '{Value}'", _latestVersionFromGitHub ?? "NULL"); + return _latestVersionFromGitHub; } } - /// - /// Disposes of managed resources. - /// + /// public void Dispose() { - // Dispose UpdateManager if it implements IDisposable if (_updateManager is IDisposable disposableUpdateManager) { disposableUpdateManager.Dispose(); @@ -71,165 +97,446 @@ public void Dispose() /// public async Task CheckForUpdatesAsync(CancellationToken cancellationToken = default) { - _logger.LogInformation("Starting GitHub update check for repository: {Url}", AppConstants.GitHubRepositoryUrl); + logger.LogInformation("Starting GitHub release update check for: {Url}", AppConstants.GitHubRepositoryUrl); + + // Reset per-run state to avoid stale UI + _hasUpdateFromGitHub = false; + _latestVersionFromGitHub = null; try { - // Extract owner and repo from URL - // Format: https://github.com/owner/repo - var uri = new Uri(AppConstants.GitHubRepositoryUrl); - var pathParts = uri.AbsolutePath.Trim('/').Split('/'); - if (pathParts.Length < 2) - { - _logger.LogError("Invalid GitHub repository URL format: {Url}", AppConstants.GitHubRepositoryUrl); - return null; - } - - var owner = pathParts[0]; - var repo = pathParts[1]; - - _logger.LogInformation("🔍 Fetching releases from GitHub API: {Owner}/{Repo}", owner, repo); - - // Call GitHub API to get latest release - var apiUrl = $"https://api.github.com/repos/{owner}/{repo}/releases"; + var apiUrl = $"https://api.github.com/repos/{AppConstants.GitHubRepositoryOwner}/{AppConstants.GitHubRepositoryName}/releases"; using var client = CreateConfiguredHttpClient(); var response = await client.GetAsync(apiUrl, cancellationToken); if (!response.IsSuccessStatusCode) { - _logger.LogError("GitHub API request failed: {StatusCode} - {Reason}", response.StatusCode, response.ReasonPhrase); + logger.LogError("GitHub API request failed: {StatusCode}", response.StatusCode); return null; } - var json = await client.GetStringAsync(apiUrl, cancellationToken); - - JsonElement releases; - try - { - releases = JsonSerializer.Deserialize(json); - } - catch (JsonException ex) - { - _logger.LogError(ex, "Failed to parse GitHub API response as JSON"); - _logger.LogDebug("Raw JSON response: {Json}", json); - return null; - } + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var releases = JsonSerializer.Deserialize(json); if (!releases.ValueKind.Equals(JsonValueKind.Array) || releases.GetArrayLength() == 0) { - _logger.LogWarning("No releases found on GitHub"); + logger.LogWarning("No releases found on GitHub"); return null; } - // Parse current version if (!SemanticVersion.TryParse(AppConstants.AppVersion, out var currentVersion)) { - _logger.LogError("Failed to parse current version: {Version}", AppConstants.AppVersion); + logger.LogError("Failed to parse current version: {Version}", AppConstants.AppVersion); return null; } - _logger.LogDebug("Current version parsed: {Version}, Prerelease: {IsPrerelease}", currentVersion, currentVersion.IsPrerelease); - - // Find the latest release (including prereleases) SemanticVersion? latestVersion = null; - JsonElement? latestRelease = null; - foreach (var release in releases.EnumerateArray()) { var tagName = release.GetProperty("tag_name").GetString(); if (string.IsNullOrEmpty(tagName)) continue; - // Remove 'v' prefix if present var versionString = tagName.TrimStart('v', 'V'); - if (!SemanticVersion.TryParse(versionString, out var releaseVersion)) - { - _logger.LogDebug("Skipping release with invalid version: {TagName}", tagName); continue; - } - _logger.LogDebug("Found release: {Version}, Prerelease: {IsPrerelease}", releaseVersion, releaseVersion.IsPrerelease); + // Filter based on current channel + if (CurrentChannel == UpdateChannel.Stable && releaseVersion.IsPrerelease) + continue; if (latestVersion == null || releaseVersion > latestVersion) { latestVersion = releaseVersion; - latestRelease = release; } } - if (latestVersion == null || latestRelease == null) - { - _logger.LogWarning("No valid releases found"); - return null; - } - - _logger.LogInformation("Latest available version: {Version}", latestVersion); - _logger.LogInformation("Comparing: Current={Current} vs Latest={Latest}", currentVersion, latestVersion); - - // Check if update is available - if (latestVersion <= currentVersion) + if (latestVersion == null || latestVersion <= currentVersion) { - _logger.LogInformation("No update available. Current version {Current} is up to date", currentVersion); + logger.LogInformation("No update available. Current: {Current}", currentVersion); return null; } - _logger.LogInformation("Update available: Current={Current}, Latest={Latest}", currentVersion, latestVersion); - - // Store GitHub update detection result + logger.LogInformation("Update available: {Current} -> {Latest}", currentVersion, latestVersion); _hasUpdateFromGitHub = true; _latestVersionFromGitHub = latestVersion.ToString(); - // If UpdateManager is available, use it to get proper UpdateInfo - // Otherwise, return null (can still show user there's an update, but can't install) + // Try to get UpdateInfo from Velopack if available if (_updateManager != null) { try { - _logger.LogDebug("Calling UpdateManager.CheckForUpdatesAsync()"); - var updateInfo = await _updateManager.CheckForUpdatesAsync(); - - _logger.LogDebug("UpdateManager.CheckForUpdatesAsync() completed. UpdateInfo is null: {IsNull}", updateInfo == null); - if (updateInfo != null) - { - _logger.LogDebug("UpdateInfo version: {Version}", updateInfo.TargetFullRelease.Version); - } - if (updateInfo != null) { - _logger.LogInformation("✅ UpdateManager also confirmed update is available and can be installed"); + logger.LogInformation("Velopack confirmed update available"); return updateInfo; } - else - { - _logger.LogWarning("⚠️ UpdateManager returned NULL - no update found via Velopack (but GitHub says there is one)"); - } } catch (Exception ex) { - _logger.LogError(ex, "UpdateManager.CheckForUpdatesAsync failed"); - _logger.LogWarning("Update is available from GitHub, but cannot be downloaded/installed due to UpdateManager exception"); + logger.LogWarning(ex, "Velopack UpdateManager check failed"); } } - else + + return null; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to check for updates"); + return null; + } + } + + /// + public async Task CheckForArtifactUpdatesAsync(CancellationToken cancellationToken = default) + { + logger.LogInformation("Checking for artifact updates (requires PAT)"); + + // Reset per-run state to avoid stale UI + _latestArtifactUpdate = null; + + // Check if PAT is available + if (!gitHubTokenStorage.HasToken()) + { + logger.LogDebug("No GitHub PAT available, skipping artifact check"); + return null; + } + + try + { + var token = await gitHubTokenStorage.LoadTokenAsync(); + if (token == null) + { + logger.LogWarning("Failed to load GitHub PAT"); + return null; + } + + using var client = CreateConfiguredHttpClient(token); + var owner = AppConstants.GitHubRepositoryOwner; + var repo = AppConstants.GitHubRepositoryName; + + // Get workflow runs for CI + var runsUrl = $"https://api.github.com/repos/{owner}/{repo}/actions/runs?status=success&per_page=10"; + var runsResponse = await client.GetAsync(runsUrl, cancellationToken); + + if (!runsResponse.IsSuccessStatusCode) + { + logger.LogWarning("Failed to fetch workflow runs: {Status}", runsResponse.StatusCode); + return null; + } + + var runsJson = await runsResponse.Content.ReadAsStringAsync(cancellationToken); + var runsData = JsonSerializer.Deserialize(runsJson); + + if (!runsData.TryGetProperty("workflow_runs", out var runs) || runs.GetArrayLength() == 0) { - _logger.LogWarning("⚠️ UpdateManager is NULL - was not initialized successfully"); + logger.LogDebug("No successful workflow runs found"); + return null; } - // Return null but flag is set (UI can show "update available" but disable install) - _logger.LogWarning("⚠️ Update detected via GitHub API but UpdateManager unavailable (running from debug)"); - _logger.LogWarning(" Install the app using Setup.exe to enable automatic updates"); + // Find the latest run with artifacts + foreach (var run in runs.EnumerateArray()) + { + var runId = run.GetProperty("id").GetInt64(); + var runUrl = run.GetProperty("html_url").GetString() ?? string.Empty; + DateTime createdAt; + try + { + createdAt = run.GetProperty("created_at").GetDateTime(); + } + catch (FormatException ex) + { + logger.LogWarning(ex, "Failed to parse created_at date from workflow run"); + createdAt = DateTime.UtcNow; + } + + var headSha = run.GetProperty("head_sha").GetString() ?? string.Empty; + var shortHash = headSha.Length >= GitShortHashLength ? headSha[..GitShortHashLength] : headSha; + + // Check for PR number in the run + int? prNumber = null; + if (run.TryGetProperty("pull_requests", out var prs) && prs.GetArrayLength() > 0) + { + prNumber = prs[0].GetProperty("number").GetInt32(); + } + + // Get artifacts for this run + var artifactsUrl = $"https://api.github.com/repos/{owner}/{repo}/actions/runs/{runId}/artifacts"; + var artifactsResponse = await client.GetAsync(artifactsUrl, cancellationToken); + + if (!artifactsResponse.IsSuccessStatusCode) + continue; + var artifactsJson = await artifactsResponse.Content.ReadAsStringAsync(cancellationToken); + var artifactsData = JsonSerializer.Deserialize(artifactsJson); + + if (!artifactsData.TryGetProperty("artifacts", out var artifacts) || artifacts.GetArrayLength() == 0) + continue; + + // Look for Velopack artifacts + foreach (var artifact in artifacts.EnumerateArray()) + { + var artifactName = artifact.GetProperty("name").GetString() ?? string.Empty; + if (!artifactName.Contains("velopack", StringComparison.OrdinalIgnoreCase)) + continue; + + var artifactId = artifact.GetProperty("id").GetInt64(); + + // Extract version from artifact name (format: genhub-velopack-windows-0.0.160-pr3) + var version = ExtractVersionFromArtifactName(artifactName) ?? "unknown"; + + var artifactInfo = new ArtifactUpdateInfo( + version: version, + gitHash: shortHash, + pullRequestNumber: prNumber, + workflowRunId: runId, + workflowRunUrl: runUrl, + artifactId: artifactId, + artifactName: artifactName, + createdAt: createdAt); + + logger.LogInformation("Found artifact update: {Version} ({Hash})", version, shortHash); + _latestArtifactUpdate = artifactInfo; + return artifactInfo; + } + } + + logger.LogDebug("No Velopack artifacts found in recent workflow runs"); return null; } catch (Exception ex) { - _logger.LogError(ex, "Failed to check for updates"); + logger.LogError(ex, "Failed to check for artifact updates"); return null; } } + /// + public async Task> GetOpenPullRequestsAsync(CancellationToken cancellationToken = default) + { + logger.LogInformation("Fetching open pull requests with artifacts"); + + // Reset merged/closed tracking + IsPrMergedOrClosed = false; + + var results = new List(); + + // Check if PAT is available + if (!gitHubTokenStorage.HasToken()) + { + logger.LogDebug("No GitHub PAT available, skipping PR list fetch"); + return results; + } + + try + { + var token = await gitHubTokenStorage.LoadTokenAsync(); + if (token == null) + { + logger.LogWarning("Failed to load GitHub PAT"); + return results; + } + + using var client = CreateConfiguredHttpClient(token); + var owner = AppConstants.GitHubRepositoryOwner; + var repo = AppConstants.GitHubRepositoryName; + + // Get open pull requests + var prsUrl = $"https://api.github.com/repos/{owner}/{repo}/pulls?state=open&per_page=30"; + var prsResponse = await client.GetAsync(prsUrl, cancellationToken); + + if (!prsResponse.IsSuccessStatusCode) + { + logger.LogWarning("Failed to fetch open PRs: {Status}", prsResponse.StatusCode); + return results; + } + + var prsJson = await prsResponse.Content.ReadAsStringAsync(cancellationToken); + var prsData = JsonSerializer.Deserialize(prsJson); + + if (!prsData.ValueKind.Equals(JsonValueKind.Array)) + { + return results; + } + + // Track if subscribed PR is still open + bool subscribedPrFound = false; + + foreach (var pr in prsData.EnumerateArray()) + { + var prNumber = pr.GetProperty("number").GetInt32(); + var title = pr.GetProperty("title").GetString() ?? "Unknown"; + var branchName = pr.TryGetProperty("head", out var head) + ? head.GetProperty("ref").GetString() ?? "unknown" + : "unknown"; + var author = pr.TryGetProperty("user", out var user) + ? user.GetProperty("login").GetString() ?? "unknown" + : "unknown"; + var state = pr.GetProperty("state").GetString() ?? "open"; + var updatedAt = pr.TryGetProperty("updated_at", out var updatedAtProp) + ? updatedAtProp.GetDateTimeOffset() + : (DateTimeOffset?)null; + + // Check if this is our subscribed PR + if (SubscribedPrNumber == prNumber) + { + subscribedPrFound = true; + IsPrMergedOrClosed = false; + } + + // Find latest artifact for this PR by checking workflow runs + ArtifactUpdateInfo? latestArtifact = null; + + var runsUrl = $"https://api.github.com/repos/{owner}/{repo}/actions/runs?status=success&per_page=5"; + var runsResponse = await client.GetAsync(runsUrl, cancellationToken); + + if (runsResponse.IsSuccessStatusCode) + { + var runsJson = await runsResponse.Content.ReadAsStringAsync(cancellationToken); + var runsData = JsonSerializer.Deserialize(runsJson); + + if (runsData.TryGetProperty("workflow_runs", out var runs)) + { + foreach (var run in runs.EnumerateArray()) + { + // Check if this run is for our PR + if (!run.TryGetProperty("pull_requests", out var runPrs)) + continue; + + bool isForThisPr = false; + foreach (var runPr in runPrs.EnumerateArray()) + { + if (runPr.GetProperty("number").GetInt32() == prNumber) + { + isForThisPr = true; + break; + } + } + + if (!isForThisPr) + continue; + + // Get artifacts for this run + var runId = run.GetProperty("id").GetInt64(); + var runUrl = run.GetProperty("html_url").GetString() ?? string.Empty; + DateTime createdAt; + try + { + createdAt = run.GetProperty("created_at").GetDateTime(); + } + catch (FormatException ex) + { + logger.LogWarning(ex, "Failed to parse created_at date from workflow run"); + createdAt = DateTime.UtcNow; + } + + var headSha = run.GetProperty("head_sha").GetString() ?? string.Empty; + var shortHash = headSha.Length >= GitShortHashLength ? headSha[..GitShortHashLength] : headSha; + + var artifactsUrl = $"https://api.github.com/repos/{owner}/{repo}/actions/runs/{runId}/artifacts"; + var artifactsResponse = await client.GetAsync(artifactsUrl, cancellationToken); + + if (!artifactsResponse.IsSuccessStatusCode) + continue; + + var artifactsJson = await artifactsResponse.Content.ReadAsStringAsync(cancellationToken); + var artifactsData = JsonSerializer.Deserialize(artifactsJson); + + if (!artifactsData.TryGetProperty("artifacts", out var artifacts)) + continue; + + // Collect all velopack artifacts, prefer Windows + ArtifactUpdateInfo? windowsArtifact = null; + ArtifactUpdateInfo? fallbackArtifact = null; + + foreach (var artifact in artifacts.EnumerateArray()) + { + var artifactName = artifact.GetProperty("name").GetString() ?? string.Empty; + + // Look for the .nupkg artifact (genhub-velopack-windows-* or genhub-velopack-linux-*) + // Setup.exe and RELEASES are in separate artifacts to reduce download size + if (!artifactName.Contains("velopack", StringComparison.OrdinalIgnoreCase)) + continue; + + var artifactId = artifact.GetProperty("id").GetInt64(); + var version = ExtractVersionFromArtifactName(artifactName) ?? $"PR{prNumber}"; + + var artifactInfo = new ArtifactUpdateInfo( + version: version, + gitHash: shortHash, + pullRequestNumber: prNumber, + workflowRunId: runId, + workflowRunUrl: runUrl, + artifactId: artifactId, + artifactName: artifactName, + createdAt: createdAt); + + // Prefer Windows artifact + if (artifactName.Contains("windows", StringComparison.OrdinalIgnoreCase)) + { + windowsArtifact = artifactInfo; + break; // Got Windows, stop looking + } + else if (fallbackArtifact == null && !artifactName.Contains("linux", StringComparison.OrdinalIgnoreCase)) + { + fallbackArtifact = artifactInfo; + } + } + + latestArtifact = windowsArtifact ?? fallbackArtifact; + + if (latestArtifact != null) + break; + } + } + } + + var prInfo = new PullRequestInfo + { + Number = prNumber, + Title = title, + BranchName = branchName, + Author = author, + State = state, + UpdatedAt = updatedAt, + LatestArtifact = latestArtifact, + }; + + results.Add(prInfo); + } + + // Update merged/closed status for subscribed PR + if (SubscribedPrNumber.HasValue && !subscribedPrFound) + { + // PR is no longer in open PRs list - check if merged or closed + var prStatusUrl = $"https://api.github.com/repos/{owner}/{repo}/pulls/{SubscribedPrNumber}"; + var statusResponse = await client.GetAsync(prStatusUrl, cancellationToken); + + if (statusResponse.IsSuccessStatusCode) + { + var statusJson = await statusResponse.Content.ReadAsStringAsync(cancellationToken); + var statusData = JsonSerializer.Deserialize(statusJson); + var state = statusData.GetProperty("state").GetString(); + + IsPrMergedOrClosed = state != null && !state.Equals("open", StringComparison.OrdinalIgnoreCase); + if (IsPrMergedOrClosed) + { + logger.LogInformation("Subscribed PR #{PrNumber} has been merged/closed", SubscribedPrNumber); + } + } + } + + logger.LogInformation("Found {Count} open PRs", results.Count); + return results; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to fetch open pull requests"); + return results; + } + } + /// public async Task DownloadUpdatesAsync(UpdateInfo updateInfo, IProgress? progress = null, CancellationToken cancellationToken = default) { @@ -242,9 +549,8 @@ public async Task DownloadUpdatesAsync(UpdateInfo updateInfo, IProgress? velopackProgress = null; if (progress != null) { @@ -269,11 +575,11 @@ public async Task DownloadUpdatesAsync(UpdateInfo updateInfo, IProgress - public bool IsUpdatePendingRestart => _updateManager?.UpdatePendingRestart != null; + public async Task InstallPrArtifactAsync(PullRequestInfo prInfo, IProgress? progress = null, CancellationToken cancellationToken = default) + { + if (prInfo.LatestArtifact == null) + { + throw new InvalidOperationException($"PR #{prInfo.Number} has no artifacts available"); + } - /// - public bool HasUpdateAvailableFromGitHub + if (!gitHubTokenStorage.HasToken()) + { + throw new InvalidOperationException("GitHub PAT required to download PR artifacts"); + } + + SimpleHttpServer? server = null; + string? tempDir = null; + + try + { + progress?.Report(new UpdateProgress { Status = "Downloading PR artifact...", PercentComplete = 0 }); + + var token = await gitHubTokenStorage.LoadTokenAsync(); + if (token == null) + { + throw new InvalidOperationException("Failed to load GitHub PAT"); + } + + using var client = CreateConfiguredHttpClient(token); + var owner = AppConstants.GitHubRepositoryOwner; + var repo = AppConstants.GitHubRepositoryName; + var artifactId = prInfo.LatestArtifact.ArtifactId; + + // GitHub Actions artifacts are returned as ZIP files via the API, + // even if individual files were uploaded. This is a GitHub Actions platform limitation. + // The /zip endpoint is the only way to download artifacts. + var downloadUrl = $"https://api.github.com/repos/{owner}/{repo}/actions/artifacts/{artifactId}/zip"; + logger.LogInformation("Downloading PR #{Number} artifact from {Url}", prInfo.Number, downloadUrl); + + var response = await client.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + // Create temp directory + tempDir = Path.Combine(Path.GetTempPath(), $"genhub-pr{prInfo.Number}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + var zipPath = Path.Combine(tempDir, "artifact.zip"); + using (var fileStream = File.Create(zipPath)) + { + await response.Content.CopyToAsync(fileStream, cancellationToken); + } + + progress?.Report(new UpdateProgress { Status = "Extracting artifact...", PercentComplete = 30 }); + + // Extract the ZIP to access the .nupkg file + // Note: The artifact now contains ONLY the .nupkg file (Setup.exe and RELEASES are separate artifacts) + ZipFile.ExtractToDirectory(zipPath, tempDir); + + // Find .nupkg file - should be the only file in the artifact + var nupkgFiles = Directory.GetFiles(tempDir, "*.nupkg", SearchOption.AllDirectories); + + // Prefer platform-appropriate nupkg based on current runtime + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + var nupkgFile = isWindows + ? nupkgFiles.FirstOrDefault(f => Path.GetFileName(f).Contains("-win", StringComparison.OrdinalIgnoreCase)) + ?? nupkgFiles.FirstOrDefault(f => !Path.GetFileName(f).Contains("-linux", StringComparison.OrdinalIgnoreCase)) + : isLinux + ? nupkgFiles.FirstOrDefault(f => Path.GetFileName(f).Contains("-linux", StringComparison.OrdinalIgnoreCase)) + ?? nupkgFiles.FirstOrDefault() + : nupkgFiles.FirstOrDefault(); + + if (nupkgFile == null) + { + throw new FileNotFoundException("No .nupkg file found in PR artifact"); + } + + logger.LogInformation("Found nupkg: {File}", Path.GetFileName(nupkgFile)); + + // Create releases.win.json (Velopack format) + var releasesPath = Path.Combine(tempDir, "releases.win.json"); + var nupkgFileName = Path.GetFileName(nupkgFile); + var fileInfo = new FileInfo(nupkgFile); + var sha1 = CalculateSHA1(nupkgFile); + var sha256 = CalculateSHA256(nupkgFile); + + // Extract version from nupkg filename (e.g., GenHub-0.0.160-pr3-full.nupkg -> 0.0.160-pr3) + var versionMatch = System.Text.RegularExpressions.Regex.Match(nupkgFileName, @"GenHub-(.+)-full\.nupkg", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + var fileVersion = versionMatch.Success ? versionMatch.Groups[1].Value : prInfo.LatestArtifact.Version; + + var releasesJson = new + { + Assets = new[] + { + new + { + PackageId = "GenHub", + Version = fileVersion, + Type = "Full", + FileName = nupkgFileName, + SHA1 = sha1, + SHA256 = sha256, + Size = fileInfo.Length, + }, + }, + }; + + var jsonContent = System.Text.Json.JsonSerializer.Serialize(releasesJson); + await File.WriteAllTextAsync(releasesPath, jsonContent, cancellationToken); + logger.LogInformation("Created releases.win.json with version {Version}", fileVersion); + + progress?.Report(new UpdateProgress { Status = "Starting local server...", PercentComplete = 50 }); + + // Start HTTP server + var port = FindAvailablePort(); + server = new SimpleHttpServer(nupkgFile, releasesPath, port, logger); + server.Start(); + + progress?.Report(new UpdateProgress { Status = "Preparing update...", PercentComplete = 60 }); + + // For PR artifacts, we bypass Velopack's version check entirely + // This allows users to "switch" to any PR build, even if it's technically "older" + // We construct a VelopackAsset directly from the nupkg file + logger.LogInformation("Bypassing version check for PR artifact installation"); + + var asset = new VelopackAsset + { + PackageId = "GenHub", + Version = NuGet.Versioning.SemanticVersion.Parse(fileVersion), + Type = VelopackAssetType.Full, + FileName = nupkgFileName, + SHA1 = sha1, + SHA256 = sha256, + Size = fileInfo.Length, + }; + + progress?.Report(new UpdateProgress { Status = "Downloading update...", PercentComplete = 70 }); + + // Point Velopack to localhost for download - use the secret token from the server for security + var source = new SimpleWebSource($"http://localhost:{port}/{server.SecretToken}/"); + var localUpdateManager = new UpdateManager(source); + + try + { + // Check for updates - may return null if version check fails + var updateInfo = await localUpdateManager.CheckForUpdatesAsync(); + + if (updateInfo == null) + { + // Version check failed - strip build metadata and try comparing base versions + var currentVersionStr = AppConstants.AppVersion.Split('+')[0]; // Strip build metadata + var targetVersionStr = fileVersion.Split('+')[0]; // Strip build metadata + + logger.LogWarning("Cannot install PR artifact: current version ({Current}) >= target ({Target})", + currentVersionStr, targetVersionStr); + logger.LogInformation("Full versions: current={CurrentFull}, target={TargetFull}", + AppConstants.AppVersion, fileVersion); + + // Check if they're actually the same version (ignoring build metadata) + if (currentVersionStr.Equals(targetVersionStr, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"PR build {fileVersion} is already installed (current: {AppConstants.AppVersion}). " + + $"This is the same version with different build metadata."); + } + else + { + throw new InvalidOperationException( + $"Cannot install PR build {fileVersion}: Current version ({AppConstants.AppVersion}) is newer. " + + $"To install this older PR build, uninstall GenHub first, then run Setup.exe from the PR artifact."); + } + } + + // Download from localhost + await localUpdateManager.DownloadUpdatesAsync(updateInfo, p => + { + progress?.Report(new UpdateProgress + { + Status = "Downloading update...", + PercentComplete = 70 + (int)(p * 0.2), // 70-90% + }); + }); + + progress?.Report(new UpdateProgress { Status = "Installing update...", PercentComplete = 90 }); + + logger.LogInformation("Applying PR #{Number} update and restarting", prInfo.Number); + logger.LogInformation("Update version: {Version}", updateInfo.TargetFullRelease.Version); + logger.LogInformation("Update package: {Package}", updateInfo.TargetFullRelease.FileName); + + try + { + // Use ApplyUpdatesAndRestart to automatically restart the app + logger.LogInformation("Using ApplyUpdatesAndRestart for PR artifact installation"); + localUpdateManager.ApplyUpdatesAndRestart(updateInfo.TargetFullRelease); + + // If we get here, the exit might not have happened immediately + logger.LogWarning("ApplyUpdatesAndExit returned without exiting - waiting for exit..."); + await Task.Delay(5000, cancellationToken); + + logger.LogError("Application did not exit after ApplyUpdatesAndExit. Update may have failed."); + throw new InvalidOperationException("Application did not exit after applying update"); + } + catch (Exception restartEx) + { + logger.LogError(restartEx, "Failed to apply PR artifact update"); + logger.LogError("Update file: {File}", updateInfo.TargetFullRelease.FileName); + logger.LogError("Update version: {Version}", updateInfo.TargetFullRelease.Version); + throw; + } + } + finally + { + // Note: UpdateManager doesn't implement IDisposable, so no cleanup needed + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to install PR artifact"); + progress?.Report(new UpdateProgress { Status = "Installation failed", HasError = true, ErrorMessage = ex.Message }); + throw; + } + finally + { + // Cleanup + server?.Dispose(); + + if (tempDir != null && Directory.Exists(tempDir)) + { + try + { + Directory.Delete(tempDir, recursive: true); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to cleanup temp directory: {Path}", tempDir); + } + } + } + } + + /// + /// Creates the Velopack UpdateManager, or null if running in debug/uninstalled mode. + /// + private static UpdateManager? CreateUpdateManager(ILogger logger) { - get + try { - _logger.LogDebug("HasUpdateAvailableFromGitHub property accessed: {Value}", _hasUpdateFromGitHub); - return _hasUpdateFromGitHub; + var source = new GithubSource(AppConstants.GitHubRepositoryUrl, string.Empty, true); + var manager = new UpdateManager(source); + logger.LogInformation("Velopack UpdateManager initialized for: {Repository}", AppConstants.GitHubRepositoryUrl); + return manager; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Velopack UpdateManager not available (running from Debug)"); + return null; } } - /// - public string? LatestVersionFromGitHub + private static string CalculateSHA1(string filePath) { - get + using var stream = File.OpenRead(filePath); + using var sha1 = System.Security.Cryptography.SHA1.Create(); + var hash = sha1.ComputeHash(stream); + return BitConverter.ToString(hash).Replace("-", string.Empty); + } + + private static string CalculateSHA256(string filePath) + { + using var stream = File.OpenRead(filePath); + using var sha256 = System.Security.Cryptography.SHA256.Create(); + var hash = sha256.ComputeHash(stream); + return BitConverter.ToString(hash).Replace("-", string.Empty); + } + + private static int FindAvailablePort() + { + var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + /// + /// Extracts version from artifact name. + /// Expected format: genhub-velopack-{platform}-{version} + /// Version format: 0.0.X or 0.0.X-prY (e.g., "0.0.150" or "0.0.150-pr42"). + /// + private static string? ExtractVersionFromArtifactName(string artifactName) + { + // Try both windows and linux prefixes + var prefixes = new[] { "genhub-velopack-windows-", "genhub-velopack-linux-" }; + + foreach (var prefix in prefixes) { - _logger.LogDebug("LatestVersionFromGitHub property accessed: '{Value}'", _latestVersionFromGitHub ?? "NULL"); - return _latestVersionFromGitHub; + if (artifactName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + var version = artifactName[prefix.Length..]; + return string.IsNullOrWhiteSpace(version) ? null : version; + } } + + return null; } /// - /// Gets or creates an HttpClient instance with proper configuration. + /// Converts SecureString to plain string. /// - /// An HttpClient instance. - private HttpClient CreateConfiguredHttpClient() + private static string SecureStringToString(SecureString secureString) { - var client = _httpClientFactory.CreateClient(); + var ptr = Marshal.SecureStringToGlobalAllocUnicode(secureString); + try + { + return Marshal.PtrToStringUni(ptr) ?? string.Empty; + } + finally + { + Marshal.ZeroFreeGlobalAllocUnicode(ptr); + } + } + + /// + /// Creates an HttpClient with standard configuration. + /// + private HttpClient CreateConfiguredHttpClient(SecureString? token = null) + { + var client = httpClientFactory.CreateClient(); client.DefaultRequestHeaders.UserAgent.Clear(); client.DefaultRequestHeaders.UserAgent.Add( new ProductInfoHeaderValue("GenHub", AppConstants.AppVersion)); + client.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + + if (token != null) + { + var plainText = SecureStringToString(token); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", plainText); + } + return client; } } diff --git a/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs b/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs index f14851ffe..4624ef27e 100644 --- a/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs +++ b/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.ObjectModel; using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Windows.Input; @@ -7,6 +9,8 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GitHub; using GenHub.Core.Models.AppUpdate; using GenHub.Features.AppUpdate.Interfaces; using Microsoft.Extensions.Logging; @@ -21,6 +25,7 @@ public partial class UpdateNotificationViewModel : ObservableObject, IDisposable { private readonly IVelopackUpdateManager _velopackUpdateManager; private readonly ILogger _logger; + private readonly IUserSettingsService _userSettingsService; private readonly CancellationTokenSource _cancellationTokenSource; private UpdateInfo? _currentUpdateInfo; @@ -91,26 +96,73 @@ public partial class UpdateNotificationViewModel : ObservableObject, IDisposable [ObservableProperty] private string _errorMessage = string.Empty; + [ObservableProperty] + private ObservableCollection _availablePullRequests = new(); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DisplayLatestVersion))] + private PullRequestInfo? _subscribedPr; + + [ObservableProperty] + private bool _isLoadingPullRequests; + + [ObservableProperty] + private bool _hasPat; + + [ObservableProperty] + private bool _showPrMergedWarning; + /// /// Initializes a new instance of the class. /// /// The Velopack update manager. /// The logger. + /// The user settings service. + /// The GitHub token storage. public UpdateNotificationViewModel( IVelopackUpdateManager velopackUpdateManager, - ILogger logger) + ILogger logger, + IUserSettingsService userSettingsService, + IGitHubTokenStorage? gitHubTokenStorage = null) { _velopackUpdateManager = velopackUpdateManager ?? throw new ArgumentNullException(nameof(velopackUpdateManager)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _userSettingsService = userSettingsService ?? throw new ArgumentNullException(nameof(userSettingsService)); _cancellationTokenSource = new CancellationTokenSource(); CheckForUpdatesCommand = new AsyncRelayCommand(CheckForUpdatesAsync, () => !IsChecking); DismissCommand = new RelayCommand(DismissUpdate); - _logger.LogInformation("UpdateNotificationViewModel initialized with Velopack"); + // Check if PAT is available + HasPat = gitHubTokenStorage?.HasToken() ?? false; + + _logger.LogInformation("UpdateNotificationViewModel initialized with Velopack (HasPat={HasPat})", HasPat); + + // Automatically check for updates and load PRs when dialog opens + _ = InitializeAsync(); + } + + /// + /// Initializes the view model by checking for updates and loading PRs. + /// + private async Task InitializeAsync() + { + // Load subscribed PR from settings + var settings = _userSettingsService.Get(); + if (settings.SubscribedPrNumber.HasValue) + { + _velopackUpdateManager.SubscribedPrNumber = settings.SubscribedPrNumber; + _logger.LogInformation("Loaded subscribed PR #{PrNumber} from settings", settings.SubscribedPrNumber); + } + + // Load PRs FIRST so SubscribedPr object is populated before update check + if (HasPat) + { + await LoadPullRequestsAsync(); + } - // Automatically check for updates when dialog opens - _ = CheckForUpdatesAsync(); + // Now check for updates - SubscribedPr will be properly populated + await CheckForUpdatesAsync(); } /// @@ -160,6 +212,13 @@ public string DisplayLatestVersion return "Unknown"; } + // If we are subscribed to a PR and the update matches that PR's latest artifact + if (SubscribedPr?.LatestArtifact != null && + string.Equals(SubscribedPr.LatestArtifact.Version, LatestVersion, StringComparison.OrdinalIgnoreCase)) + { + return SubscribedPr.LatestArtifact.DisplayVersion; + } + return LatestVersion.StartsWith("v", StringComparison.OrdinalIgnoreCase) ? LatestVersion : $"v{LatestVersion}"; @@ -197,16 +256,70 @@ private async Task CheckForUpdatesAsync() _logger.LogInformation("Starting Velopack update check"); + // Check if subscribed to a PR - this takes precedence over main branch releases + if (SubscribedPr?.LatestArtifact != null) + { + // For subscribed PRs, compare versions without build metadata + // Strip everything after '+' to ignore build hashes + var currentVersionBase = CurrentAppVersion.Split('+')[0]; + var prVersionBase = SubscribedPr.LatestArtifact.Version.Split('+')[0]; + + if (!string.Equals(prVersionBase, currentVersionBase, StringComparison.OrdinalIgnoreCase)) + { + // Check if this version was already dismissed + var settings = _userSettingsService.Get(); + if (!string.Equals(prVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + IsUpdateAvailable = true; + LatestVersion = prVersionBase; + ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/pull/{SubscribedPr.Number}"; + StatusMessage = $"New PR build available: {SubscribedPr.LatestArtifact.DisplayVersion}"; + _logger.LogInformation( + "Subscribed to PR #{PrNumber}, new build available: {Version}", + SubscribedPr.Number, + LatestVersion); + return; // Exit early - PR update takes priority + } + else + { + _logger.LogInformation("PR update {Version} was previously dismissed", prVersionBase); + StatusMessage = $"You dismissed the update for PR #{SubscribedPr.Number}"; + return; + } + } + else + { + // We are on the latest PR build + IsUpdateAvailable = false; + StatusMessage = $"You are on the latest build for PR #{SubscribedPr.Number}"; + _logger.LogInformation("Already on latest PR #{PrNumber} build", SubscribedPr.Number); + return; // Exit early - no need to check main branch + } + } + + // Check main branch releases (only if not subscribed to PR) _currentUpdateInfo = await _velopackUpdateManager.CheckForUpdatesAsync(_cancellationTokenSource.Token); - // Check BOTH UpdateInfo (for installed app with working Velopack) AND GitHub flag (for installed app where Velopack has issues) + // Check both UpdateInfo (for installed app with working Velopack) and GitHub flag (for installed app where Velopack has issues) if (_currentUpdateInfo != null) { - IsUpdateAvailable = true; - LatestVersion = _currentUpdateInfo.TargetFullRelease.Version.ToString(); - ReleaseNotesUrl = AppConstants.GitHubRepositoryUrl + "/releases/tag/v" + LatestVersion; - StatusMessage = $"Update available: v{LatestVersion}"; - _logger.LogInformation("Update available from UpdateManager: {Version}", LatestVersion); + var version = _currentUpdateInfo.TargetFullRelease.Version.ToString(); + + // Check if this version was already dismissed + var settings = _userSettingsService.Get(); + if (!string.Equals(version, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + IsUpdateAvailable = true; + LatestVersion = version; + ReleaseNotesUrl = AppConstants.GitHubRepositoryUrl + "/releases/tag/v" + LatestVersion; + StatusMessage = $"Update available: v{LatestVersion}"; + _logger.LogInformation("Update available from UpdateManager: {Version}", LatestVersion); + } + else + { + _logger.LogInformation("Update {Version} was previously dismissed", version); + StatusMessage = "You're up to date!"; + } } else if (_velopackUpdateManager.HasUpdateAvailableFromGitHub) { @@ -217,18 +330,28 @@ private async Task CheckForUpdatesAsync() _velopackUpdateManager.HasUpdateAvailableFromGitHub, githubVersion ?? "NULL"); - IsUpdateAvailable = true; - LatestVersion = githubVersion ?? "Unknown"; - ReleaseNotesUrl = AppConstants.GitHubRepositoryUrl + "/releases/tag/v" + LatestVersion; - StatusMessage = $"Update available: v{LatestVersion}"; - _logger.LogInformation("Update available from GitHub API: {Version}", LatestVersion); + // Check if this version was already dismissed + var settings = _userSettingsService.Get(); + if (!string.Equals(githubVersion, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + IsUpdateAvailable = true; + LatestVersion = githubVersion ?? "Unknown"; + ReleaseNotesUrl = AppConstants.GitHubRepositoryUrl + "/releases/tag/v" + LatestVersion; + StatusMessage = $"Update available: v{LatestVersion}"; + _logger.LogInformation("Update available from GitHub API: {Version}", LatestVersion); + } + else + { + _logger.LogInformation("GitHub update {Version} was previously dismissed", githubVersion); + StatusMessage = "You're up to date!"; + } } else { IsUpdateAvailable = false; LatestVersion = string.Empty; StatusMessage = "You're up to date!"; - _logger.LogInformation("No updates available"); + _logger.LogInformation("No updates available from Velopack/GitHub"); } } catch (Exception ex) @@ -275,6 +398,18 @@ private async Task InstallUpdateAsync() return; } + // 1. Handle PR Artifact Update + // If we are subscribed to a PR and the LatestVersion matches the PR artifact, install that instead + if (SubscribedPr?.LatestArtifact != null && + string.Equals(SubscribedPr.LatestArtifact.Version, LatestVersion, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Installing PR artifact update via InstallUpdateAsync override"); + await InstallPrArtifactAsync(); + return; + } + + // 2. Handle Standard Velopack Update + // If we don't have UpdateInfo, we need to show error that installed app is required if (_currentUpdateInfo == null) { @@ -343,13 +478,80 @@ private async Task InstallUpdateAsync() } /// - /// Dismisses the update notification. + /// Installs the subscribed PR artifact. + /// + [RelayCommand(CanExecute = nameof(CanInstallPrArtifact))] + private async Task InstallPrArtifactAsync() + { + if (SubscribedPr == null || SubscribedPr.LatestArtifact == null) + { + _logger.LogWarning("Cannot install PR artifact - no PR subscribed or no artifact available"); + return; + } + + IsInstalling = true; + HasError = false; + ErrorMessage = string.Empty; + DownloadProgress = 0; + + try + { + _logger.LogInformation("Installing PR #{Number} artifact", SubscribedPr.Number); + + var progress = new Progress(p => + { + Dispatcher.UIThread.InvokeAsync(() => + { + InstallationProgress = p; + StatusMessage = p.Status; + DownloadProgress = p.PercentComplete; + }); + }); + + await _velopackUpdateManager.InstallPrArtifactAsync(SubscribedPr, progress, _cancellationTokenSource.Token); + + // App will restart, this code won't execute + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to install PR artifact"); + HasError = true; + ErrorMessage = $"PR installation failed: {ex.Message}"; + StatusMessage = "PR installation failed"; + InstallationProgress = new UpdateProgress + { + Status = "Installation failed", + HasError = true, + ErrorMessage = ex.Message, + }; + } + finally + { + IsInstalling = false; + } + } + + /// + /// Gets a value indicating whether the PR artifact can be installed. + /// + public bool CanInstallPrArtifact => SubscribedPr?.LatestArtifact != null && !IsInstalling; + + /// + /// Dismisses the update notification and persists the dismissed version. /// private void DismissUpdate() { + // Persist the dismissed version to prevent showing it again + if (!string.IsNullOrEmpty(LatestVersion)) + { + _userSettingsService.Update(s => s.DismissedUpdateVersion = LatestVersion); + _ = _userSettingsService.SaveAsync(); + _logger.LogInformation("Dismissed update version {Version}", LatestVersion); + } + IsUpdateAvailable = false; _currentUpdateInfo = null; - StatusMessage = "Ready to check for updates"; + StatusMessage = "Update dismissed"; HasError = false; ErrorMessage = string.Empty; LatestVersion = string.Empty; @@ -393,4 +595,105 @@ private void UpdateCommandStates() OnPropertyChanged(nameof(InstallButtonText)); InstallUpdateCommand.NotifyCanExecuteChanged(); } + + /// + /// Loads the list of open pull requests with available artifacts. + /// + [RelayCommand] + private async Task LoadPullRequestsAsync() + { + if (!HasPat || IsLoadingPullRequests) + { + return; + } + + IsLoadingPullRequests = true; + AvailablePullRequests.Clear(); + + try + { + _logger.LogInformation("Loading open pull requests with artifacts"); + + var prs = await _velopackUpdateManager.GetOpenPullRequestsAsync(_cancellationTokenSource.Token); + + await Dispatcher.UIThread.InvokeAsync(() => + { + foreach (var pr in prs) + { + AvailablePullRequests.Add(pr); + } + }); + + // Check if we had a subscribed PR that got merged/closed + if (_velopackUpdateManager.IsPrMergedOrClosed && _velopackUpdateManager.SubscribedPrNumber.HasValue) + { + ShowPrMergedWarning = true; + StatusMessage = $"PR #{_velopackUpdateManager.SubscribedPrNumber} has been merged. Select a new PR or switch to MAIN."; + _logger.LogInformation("Subscribed PR has been merged/closed, showing warning"); + } + + // Update subscribed PR info + if (_velopackUpdateManager.SubscribedPrNumber.HasValue) + { + SubscribedPr = AvailablePullRequests.FirstOrDefault(p => p.Number == _velopackUpdateManager.SubscribedPrNumber); + } + + _logger.LogInformation("Loaded {Count} open PRs", AvailablePullRequests.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load pull requests"); + StatusMessage = "Failed to load PRs"; + } + finally + { + IsLoadingPullRequests = false; + } + } + + /// + /// Subscribes to updates from a specific PR. + /// + /// The PR number to subscribe to. + [RelayCommand] + private void SubscribeToPr(int prNumber) + { + _velopackUpdateManager.SubscribedPrNumber = prNumber; + SubscribedPr = AvailablePullRequests.FirstOrDefault(p => p.Number == prNumber); + ShowPrMergedWarning = false; + + // Persist to settings + _userSettingsService.Update(s => s.SubscribedPrNumber = prNumber); + _ = _userSettingsService.SaveAsync(); + + if (SubscribedPr != null) + { + StatusMessage = $"Subscribed to PR #{prNumber}: {SubscribedPr.Title}"; + _logger.LogInformation("Subscribed to PR #{PrNumber}", prNumber); + } + } + + partial void OnSubscribedPrChanged(PullRequestInfo? value) + { + OnPropertyChanged(nameof(CanInstallPrArtifact)); + InstallPrArtifactCommand.NotifyCanExecuteChanged(); + } + + /// + /// Unsubscribes from PR updates and switches to MAIN branch. + /// + [RelayCommand] + private void UnsubscribeFromPr() + { + _velopackUpdateManager.SubscribedPrNumber = null; + SubscribedPr = null; + ShowPrMergedWarning = false; + StatusMessage = "Switched to MAIN branch updates"; + + // Persist to settings + _userSettingsService.Update(s => s.SubscribedPrNumber = null); + _ = _userSettingsService.SaveAsync(); + + _logger.LogInformation("Unsubscribed from PR, switched to MAIN"); + } } diff --git a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml index 006d07a30..597d9f96d 100644 --- a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml +++ b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml @@ -4,98 +4,47 @@ x:Class="GenHub.Features.AppUpdate.Views.UpdateNotificationView" x:DataType="vm:UpdateNotificationViewModel"> - - - - - - - - - - + - - - - - - - - - - - - + - - - + Padding="12,10" + Margin="0,0,0,12"> + + + + + + + + + + - - - - - + + + + + + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -208,7 +310,7 @@ + + + + + public IEnumerable AvailableWorkspaceStrategies => Enum.GetValues(); + /// + /// Gets the available update channels for selection in the UI. + /// + public IEnumerable AvailableUpdateChannels => Enum.GetValues(); + + /// + /// Gets the current application version for display. + /// + public string CurrentVersion => AppConstants.FullDisplayVersion; + /// /// Disposes the ViewModel and its resources. /// @@ -346,6 +398,7 @@ private void LoadSettings() // Load CAS settings CasRootPath = settings.CasConfiguration.CasRootPath; EnableAutomaticGc = settings.CasConfiguration.EnableAutomaticGc; + SelectedUpdateChannel = settings.UpdateChannel; MaxCacheSizeGB = settings.CasConfiguration.MaxCacheSizeBytes / ConversionConstants.BytesPerGigabyte; CasMaxConcurrentOperations = settings.CasConfiguration.MaxConcurrentOperations; CasVerifyIntegrity = settings.CasConfiguration.VerifyIntegrity; @@ -386,6 +439,7 @@ private async Task SaveSettings() settings.AllowBackgroundDownloads = AllowBackgroundDownloads; settings.EnableDetailedLogging = EnableDetailedLogging; settings.DefaultWorkspaceStrategy = DefaultWorkspaceStrategy; + settings.UpdateChannel = SelectedUpdateChannel; settings.DownloadBufferSize = (int)(DownloadBufferSizeKB * ConversionConstants.BytesPerKilobyte); // Convert KB to bytes settings.DownloadTimeoutSeconds = DownloadTimeoutSeconds; settings.DownloadUserAgent = DownloadUserAgent; @@ -632,4 +686,252 @@ private void UpdateMemoryUsage() CurrentMemoryUsage = 0; } } + + /// + /// Gets the status color for the PAT indicator. + /// + public string PatStatusColor => IsPatValid ? "#4CAF50" : "#888888"; + + /// + /// Loads the current PAT status from storage. + /// + private async Task LoadPatStatusAsync() + { + if (_gitHubTokenStorage == null) + { + HasGitHubPat = false; + PatStatusMessage = "Token storage not available"; + return; + } + + try + { + HasGitHubPat = _gitHubTokenStorage.HasToken(); + if (HasGitHubPat) + { + PatStatusMessage = "GitHub PAT configured ✓"; + IsPatValid = true; + + // If Artifacts channel is selected and we have a PAT, load available artifacts + if (SelectedUpdateChannel == UpdateChannel.Artifacts && _updateManager != null) + { + await LoadArtifactsAsync(); + } + } + else + { + PatStatusMessage = "No GitHub PAT configured"; + IsPatValid = false; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load PAT status"); + PatStatusMessage = "Error checking PAT status"; + HasGitHubPat = false; + IsPatValid = false; + } + + await Task.CompletedTask; + } + + /// + /// Tests the entered GitHub PAT by making an API call. + /// + [RelayCommand] + private async Task TestPatAsync() + { + if (string.IsNullOrWhiteSpace(GitHubPatInput)) + { + PatStatusMessage = "Please enter a GitHub PAT"; + return; + } + + if (_gitHubTokenStorage == null) + { + PatStatusMessage = "Token storage not available"; + return; + } + + IsTestingPat = true; + PatStatusMessage = "Testing PAT..."; + + try + { + // Save temporarily to test + using var secureString = new System.Security.SecureString(); + foreach (char c in GitHubPatInput) + { + secureString.AppendChar(c); + } + + await _gitHubTokenStorage.SaveTokenAsync(secureString); + + // Try to check for artifacts to validate the PAT + if (_updateManager != null) + { + // Validate first by making a test call (similar to GitHubTokenDialogViewModel) + // Only save after validation succeeds + try + { + var artifact = await _updateManager.CheckForArtifactUpdatesAsync(); + if (artifact != null || _gitHubTokenStorage.HasToken()) + { + PatStatusMessage = "PAT validated successfully ✓"; + IsPatValid = true; + HasGitHubPat = true; + GitHubPatInput = string.Empty; // Clear input after successful save + return; + } + } + catch + { + // Rollback on validation failure + await _gitHubTokenStorage.DeleteTokenAsync(); + throw; + } + } + + // If we can't fully validate but storage worked, mark as valid + PatStatusMessage = "PAT saved (validation pending)"; + IsPatValid = true; + HasGitHubPat = true; + GitHubPatInput = string.Empty; + } + catch (Exception ex) + { + _logger.LogError(ex, "PAT validation failed"); + PatStatusMessage = $"Invalid PAT: {ex.Message}"; + IsPatValid = false; + } + finally + { + IsTestingPat = false; + } + } + + /// + /// Deletes the stored GitHub PAT. + /// + [RelayCommand] + private async Task DeletePatAsync() + { + if (_gitHubTokenStorage == null) + { + return; + } + + try + { + await _gitHubTokenStorage.DeleteTokenAsync(); + HasGitHubPat = false; + IsPatValid = false; + PatStatusMessage = "GitHub PAT removed"; + AvailableArtifacts.Clear(); + + // Switch to Prerelease channel if on Artifacts + if (SelectedUpdateChannel == UpdateChannel.Artifacts) + { + SelectedUpdateChannel = UpdateChannel.Prerelease; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete PAT"); + PatStatusMessage = $"Error: {ex.Message}"; + } + } + + /// + /// Opens the Update Notification window for browsing updates and managing PR subscriptions. + /// + [RelayCommand] + private void OpenUpdateWindow() + { + try + { + var updateWindow = new Features.AppUpdate.Views.UpdateNotificationWindow(); + updateWindow.Show(); + _logger.LogInformation("Update window opened from Settings"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to open update window"); + } + } + + /// + /// Loads available CI artifacts for selection. + /// + [RelayCommand] + private async Task LoadArtifactsAsync() + { + if (_updateManager == null || !HasGitHubPat) + { + PatStatusMessage = "Configure a GitHub PAT to load artifacts"; + return; + } + + IsLoadingArtifacts = true; + AvailableArtifacts.Clear(); + + try + { + var artifact = await _updateManager.CheckForArtifactUpdatesAsync(); + if (artifact != null) + { + AvailableArtifacts.Add(artifact); + PatStatusMessage = $"Found {AvailableArtifacts.Count} artifact(s)"; + } + else + { + PatStatusMessage = "No artifacts available"; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load artifacts"); + PatStatusMessage = $"Error loading artifacts: {ex.Message}"; + } + finally + { + IsLoadingArtifacts = false; + } + } + + /// + /// Handles update channel change. + /// + partial void OnSelectedUpdateChannelChanged(UpdateChannel value) + { + _logger.LogInformation("Update channel changed to: {Channel}", value); + + // Update the update manager's channel + if (_updateManager != null) + { + _updateManager.CurrentChannel = value; + } + + // Persist setting + _userSettingsService.Update(s => s.UpdateChannel = value); + + // Save settings asynchronously with error handling + _ = _userSettingsService.SaveAsync().ContinueWith(t => + { + if (t.IsFaulted) + { + _logger.LogError(t.Exception, "Failed to save update channel setting"); + } + }); + + // If switching to Artifacts, load available artifacts + if (value == UpdateChannel.Artifacts && HasGitHubPat) + { + _ = LoadArtifactsAsync(); + } + else if (value == UpdateChannel.Artifacts && !HasGitHubPat) + { + PatStatusMessage = "GitHub PAT required for artifact updates"; + } + } } \ No newline at end of file diff --git a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml index bc9209f4a..da037fbbc 100644 --- a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml +++ b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml @@ -56,16 +56,15 @@ -