Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
*.json linguist-detectable=true
*.md linguist-detectable=true

# Channel isolation — each branch keeps its own version of this file during merges.
# When promoting code forward (dev → beta → main), the target branch's ChannelInfo.cs
# is always preserved unchanged. See ChannelInfo.cs for full merge workflow notes.
src/PortPane/ChannelInfo.cs merge=ours

# Binary files — no line ending conversion
*.ico binary
*.png binary
Expand Down
89 changes: 81 additions & 8 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,29 @@
# artifacts when a version tag is pushed.
#
# Triggers :
# - push to dev — build, test, publish; updates rolling latest-alpha release
# - push to main — build, run tests, publish exe artifact
# - push of v* tag — publish + build installer + create GitHub Release
# - pull_request to main — build and run tests only (no publish)
# - workflow_dispatch (manual) — always builds; two optional inputs:
# skip_tests: skip xUnit tests (useful when you just need a compile check)
# publish_artifact: upload PortPane.exe artifact even without a tag/push to main
#
# Release channels (controlled by src/PortPane/ChannelInfo.cs on each branch):
# dev — Alpha: rapid builds, 14-day expiry, all features unlocked, verbose logging
# beta — Beta: 60-day expiry, opt-in telemetry, normal logging
# main — Stable: no expiry, release tags only
#
# Required secrets/variables : None for build/test. GitHub token is provided
# automatically by Actions for the release job (contents: write permission).
# Future: SIGNING_PASSWORD when code signing is enabled.
#
# Outputs / artifacts :
# - test-results-{sha} : xUnit .trx test results (always)
# - PortPane-{version}-{sha} : PortPane.exe + SHA-256 hash (main/tags)
# - PortPane-Installer-{tag} : Inno Setup installer .exe (tags only)
# - GitHub Release : All artifacts + CHANGELOG notes (tags only)
# - test-results-{sha} : xUnit .trx test results (always)
# - PortPane-{version}-{sha} : PortPane.exe + SHA-256 hash (main/dev/tags)
# - PortPane-Installer-{tag} : Inno Setup installer .exe (tags only)
# - GitHub Release (latest-alpha) : Rolling alpha exe updated on every dev push
# - GitHub Release (versioned) : All artifacts + CHANGELOG notes (tags only)
#
# Manual trigger : Go to Actions tab → "Build · Test · Publish · Release" →
# "Run workflow" → select branch → click "Run workflow"
Expand All @@ -35,7 +42,7 @@ name: Build · Test · Publish · Release

on:
push:
branches: [ main ]
branches: [ main, dev ]
tags: [ 'v*' ]
paths-ignore:
- '**/*.md'
Expand Down Expand Up @@ -122,6 +129,7 @@ jobs:
if: >
github.event_name != 'pull_request' &&
(github.ref == 'refs/heads/main' ||
github.ref == 'refs/heads/dev' ||
startsWith(github.ref, 'refs/tags/v') ||
(github.event_name == 'workflow_dispatch' && inputs.publish_artifact))

Expand All @@ -143,14 +151,34 @@ jobs:
- name: Restore NuGet packages
run: dotnet restore ${{ env.SOLUTION }}

- name: Extract version from BrandingInfo
- name: Extract and compose version from BrandingInfo and ChannelInfo
id: version
shell: pwsh
run: |
$version = (Select-String -Path 'src/PortPane/BrandingInfo.cs' `
$base = (Select-String 'src/PortPane/BrandingInfo.cs' `
-Pattern 'Version\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value
$suffix = (Select-String 'src/PortPane/ChannelInfo.cs' `
-Pattern 'VersionSuffix\s*=\s*"([^"]*)"').Matches[0].Groups[1].Value
$channel = (Select-String 'src/PortPane/ChannelInfo.cs' `
-Pattern 'Channel\s*=\s*ReleaseChannel\.(\w+)').Matches[0].Groups[1].Value
$stamp = [DateTime]::UtcNow.ToString("yyyyMMdd-HHmm")
$version = switch ($channel) {
'Alpha' { "$base-$suffix.$stamp" }
'Beta' { "$base-$suffix" }
default { $base }
}
echo "version=$version" >> $env:GITHUB_OUTPUT
echo "Version: $version"
echo "channel=$channel" >> $env:GITHUB_OUTPUT
echo "Version: $version (channel: $channel)"

- name: Stamp BuildDate into BrandingInfo
shell: pwsh
run: |
$now = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
$file = 'src/PortPane/BrandingInfo.cs'
(Get-Content $file) -replace '(BuildDate\s*=\s*)"[^"]*"', "`$1`"$now`"" |
Set-Content $file
echo "Stamped BuildDate = $now"

- name: Publish — single self-contained exe
run: >
Expand Down Expand Up @@ -305,3 +333,48 @@ jobs:
draft: false
prerelease: ${{ contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}
generate_release_notes: false

# ─────────────────────────────────────────────────────────────────────
rolling-alpha:
name: Update Rolling Alpha Release
runs-on: windows-latest
needs: publish
if: github.ref == 'refs/heads/dev'

permissions:
contents: write

steps:
- name: Download exe artifact
uses: actions/download-artifact@v8
with:
pattern: PortPane-*-${{ github.sha }}
path: ./release
merge-multiple: true

- name: Rename artifact with composed version
shell: pwsh
run: |
$version = '${{ needs.publish.outputs.version }}'
Get-ChildItem './release/${{ env.APP_EXE }}' -ErrorAction SilentlyContinue |
Rename-Item -NewName "PortPane-$version-win-x64.exe"
Get-ChildItem './release/${{ env.APP_EXE }}.sha256' -ErrorAction SilentlyContinue |
Rename-Item -NewName "PortPane-$version-win-x64.exe.sha256"

- name: Update rolling latest-alpha release
uses: softprops/action-gh-release@v2
with:
tag_name: latest-alpha
name: "Latest Alpha — ${{ needs.publish.outputs.version }}"
body: |
Automated alpha build from the `dev` branch.

**Version:** `${{ needs.publish.outputs.version }}`
**Commit:** `${{ github.sha }}`

This build has a 14-day expiry. Always download the current version from this page.
Not recommended for general use — for testing purposes only.
prerelease: true
files: |
./release/PortPane-*.exe
./release/PortPane-*.sha256
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,55 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- `ChannelInfo.cs`: multi-channel release system — `ReleaseChannel` enum (`Alpha`, `Beta`, `Stable`)
with per-branch constants controlling build expiry, feature unlock, telemetry default,
logging verbosity, and version suffix; protected from forward merges via `.gitattributes` `merge=ours`
- `BrandingInfo.FullVersion`: computed property composing base version, channel suffix, and
CI-stamped build date (e.g. `0.5.2-alpha.20260329`) for display, logging, and telemetry
- `BrandingInfo.BuildDate`: CI-stamped ISO 8601 UTC constant; empty in source, patched at publish time
- `AppSettings.LastSeenVersion`: records running version at each launch for future new-version prompts
- `App.xaml.cs` — build expiry kill switch: blocks launch with download link when alpha/beta build
exceeds `ChannelInfo.BuildExpiryDays` past CI stamp date
- `App.xaml.cs` — `--reset` flag: wipes all user data and relaunches fresh; enables clean
reinstall workflow for alpha/beta testers moving between builds
- `build.yml` — `dev` branch trigger: push to `dev` builds, tests, and publishes automatically
- `build.yml` — channel-aware version composition: reads `ChannelInfo.VersionSuffix` and appends
`yyyyMMdd-HHmm` timestamp for alpha builds; beta and stable use suffix or base only
- `build.yml` — `rolling-alpha` job: updates a persistent `latest-alpha` pre-release tag on
every `dev` push so testers always have one bookmark to the current alpha exe
- `build.yml` — `Stamp BuildDate` step: patches `BrandingInfo.BuildDate` at publish time so
the compiled exe carries an accurate expiry reference
- `BrandingInfo.DaysRemaining`: computed property returning whole days until build expiry,
or `null` for stable builds or unstamped local builds
- Drag strip channel watermark: always-visible `ALPHA` / `BETA` badge with days-remaining
countdown on the right side of the drag handle; hidden on stable builds

### Fixed

- Chrome menu bar auto-hides after 5 seconds making the menu unusable; replaced
5-second `DispatcherTimer` with focus/hover model: chrome appears on mouse-enter,
pins on click or Alt key (enabling keyboard menu navigation), and hides only when
the window loses focus — closes #8

### Changed

- `BrandingInfo.Version` stripped of channel suffix (was `"0.5.1-beta"`); suffix now provided
exclusively by `ChannelInfo.VersionSuffix` on each branch
- `LicenseService`: `UnlockAllForTesting = true` returns Personal-tier license automatically,
bypassing all validation — eliminates license friction for alpha testers
- `SettingsService`: fresh installs now apply `ChannelInfo.TelemetryOnByDefault` as the default
telemetry state (alpha: opt-out / true; beta and stable: opt-in / false)
- `SetupSerilog`: minimum log level driven by `ChannelInfo.VerboseLogging`
(alpha: `Debug`; beta and stable: `Information`)
- All display, logging, and telemetry version references updated from `BrandingInfo.Version`
to `BrandingInfo.FullVersion`

---

## [0.5.1-beta] — 2026-03-28

### Fixed

- CI build failure (NETSDK1135): changed `net8.0-windows` to `net8.0-windows10.0.17763.0`
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ When you plug in a USB radio interface, PortPane instantly shows you which audio
COM ports Windows assigned — so you can configure your digital mode software without opening
Device Manager.

[![Build](https://github.com/Computer-Tsu/shackdesk-portpane/actions/workflows/build.yml/badge.svg)](https://github.com/Computer-Tsu/shackdesk-portpane/actions/workflows/build.yml)
[![dev](https://github.com/Computer-Tsu/shackdesk-portpane/actions/workflows/build.yml/badge.svg?branch=dev)](https://github.com/Computer-Tsu/shackdesk-portpane/actions/workflows/build.yml)
[![main](https://github.com/Computer-Tsu/shackdesk-portpane/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/Computer-Tsu/shackdesk-portpane/actions/workflows/build.yml)
[![Alpha](https://img.shields.io/badge/alpha-0.5.2-orange)](https://github.com/Computer-Tsu/shackdesk-portpane/releases)
[![Stable](https://img.shields.io/github/v/release/Computer-Tsu/shackdesk-portpane?label=stable&color=brightgreen)](https://github.com/Computer-Tsu/shackdesk-portpane/releases/latest)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE-GPL.md)
[![Version](https://img.shields.io/badge/version-0.5.1--beta-orange)](CHANGELOG.md)
[![Last Commit](https://img.shields.io/github/last-commit/Computer-Tsu/shackdesk-portpane)](https://github.com/Computer-Tsu/shackdesk-portpane/commits/main)
[![Repo Size](https://img.shields.io/github/repo-size/Computer-Tsu/shackdesk-portpane)](https://github.com/Computer-Tsu/shackdesk-portpane)
[![.NET 8](https://img.shields.io/badge/.NET-8.0-512BD4)](https://dotnet.microsoft.com/download/dotnet/8.0)
Expand All @@ -27,7 +29,10 @@ Device Manager.

## Download

**[→ Download the latest release](https://github.com/Computer-Tsu/shackdesk-portpane/releases)**
| Channel | Link | Notes |
| --- | --- | --- |
| **Alpha** | [Latest Alpha release](https://github.com/Computer-Tsu/shackdesk-portpane/releases/tag/latest-alpha) | Updated on every `dev` push. 14-day expiry. For testers. |
| **Stable** | [Latest stable release](https://github.com/Computer-Tsu/shackdesk-portpane/releases/latest) | No stable release yet — coming soon. |

Download `PortPane.exe`. Run it directly. No installation required.

Expand Down
8 changes: 4 additions & 4 deletions src/PortPane.Tests/UnitTests/LicenseValidationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ public class LicenseValidationTests

[Fact]
public void LicenseService_DefaultTier_IsFree()
=> Assert.Equal(LicenseTier.Free, new LicenseService().Current.Tier);
=> Assert.Equal(LicenseTier.Free, new LicenseService(unlockForTesting: false).Current.Tier);

[Fact]
public void LicenseService_FreeTier_IsAlwaysValid()
=> Assert.True(new LicenseService().Current.IsValid);
=> Assert.True(new LicenseService(unlockForTesting: false).Current.IsValid);

[Fact]
public void LicenseService_FreeTier_Licensee_IsNull()
=> Assert.Null(new LicenseService().Current.Licensee);
=> Assert.Null(new LicenseService(unlockForTesting: false).Current.Licensee);

// ── Feature availability ──────────────────────────────────────────────────

Expand Down Expand Up @@ -82,7 +82,7 @@ public async Task LicenseService_TamperedLicenseFile_RevertsToFree()
// If a license file has been modified, the SHA-256 hash won't match.
// The service should silently revert to Free.
// This test verifies that ActivateAsync rejects and doesn't throw.
var svc = new LicenseService();
var svc = new LicenseService(unlockForTesting: false);
bool result = await svc.ActivateAsync("garbage=base64==data");
Assert.False(result);
// Service should remain Free
Expand Down
44 changes: 41 additions & 3 deletions src/PortPane/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,41 @@
return;
}

// ── Build expiry check (alpha/beta only) ──────────────────────────────
if (ChannelInfo.BuildExpiryDays > 0 && !string.IsNullOrEmpty(BrandingInfo.BuildDate)

Check warning

Code scanning / CodeQL

Constant condition Warning

Condition always evaluates to 'false'.

Copilot Autofix

AI 18 days ago

In general, to fix a constant condition you either (1) remove the redundant constant part if it does not affect behavior, or (2) replace it with the correct, non-constant predicate that reflects the intended logic. Here, CodeQL indicates ChannelInfo.BuildExpiryDays > 0 is always true, so it does not influence whether the if block runs. The actual gating factors are !string.IsNullOrEmpty(BrandingInfo.BuildDate) and the success of DateTimeOffset.TryParse.

The best behavior‑preserving change is to simplify the condition by removing the constant subcondition and leaving the truly variable checks. That keeps the build-expiry feature working exactly as before, but avoids the misleading constant comparison and satisfies the analyzer. Concretely, in src/PortPane/App.xaml.cs, change the if at lines 34–37 to:

if (!string.IsNullOrEmpty(BrandingInfo.BuildDate)
    && DateTimeOffset.TryParse(BrandingInfo.BuildDate, null,
        System.Globalization.DateTimeStyles.RoundtripKind, out var buildDate))
{
    ...
}

No new methods, fields, or imports are required.


Suggested changeset 1
src/PortPane/App.xaml.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/PortPane/App.xaml.cs b/src/PortPane/App.xaml.cs
--- a/src/PortPane/App.xaml.cs
+++ b/src/PortPane/App.xaml.cs
@@ -31,7 +31,7 @@
         }
 
         // ── Build expiry check (alpha/beta only) ──────────────────────────────
-        if (ChannelInfo.BuildExpiryDays > 0 && !string.IsNullOrEmpty(BrandingInfo.BuildDate)
+        if (!string.IsNullOrEmpty(BrandingInfo.BuildDate)
             && DateTimeOffset.TryParse(BrandingInfo.BuildDate, null,
                 System.Globalization.DateTimeStyles.RoundtripKind, out var buildDate))
         {
EOF
@@ -31,7 +31,7 @@
}

// ── Build expiry check (alpha/beta only) ──────────────────────────────
if (ChannelInfo.BuildExpiryDays > 0 && !string.IsNullOrEmpty(BrandingInfo.BuildDate)
if (!string.IsNullOrEmpty(BrandingInfo.BuildDate)
&& DateTimeOffset.TryParse(BrandingInfo.BuildDate, null,
System.Globalization.DateTimeStyles.RoundtripKind, out var buildDate))
{
Copilot is powered by AI and may make mistakes. Always verify output.

Check warning

Code scanning / CodeQL

Constant condition Warning

Condition always evaluates to 'true'.

Copilot Autofix

AI 18 days ago

In general, to fix a constant condition you either (1) remove the redundant part if it’s truly invariant, or (2) adjust the logic so it matches the intended behavior, ensuring that no sub-condition is trivially always true/false under normal operation.

Here, CodeQL says string.IsNullOrEmpty(BrandingInfo.BuildDate) is always true. Under that assumption, the current guard:

if (ChannelInfo.BuildExpiryDays > 0 && !string.IsNullOrEmpty(BrandingInfo.BuildDate)
    && DateTimeOffset.TryParse(...))
{
    ...
}

has a middle term that is always false, so the whole if is never entered and the build-expiry feature is dead. The minimal behavior-preserving change is to remove the logically impossible check against BrandingInfo.BuildDate and rely solely on the TryParse call to decide whether a valid build date is available. DateTimeOffset.TryParse already handles null and empty strings safely (it just returns false), so the extra !string.IsNullOrEmpty is redundant in any case.

Concretely, in src/PortPane/App.xaml.cs around line 34, update the if condition to drop !string.IsNullOrEmpty(BrandingInfo.BuildDate) and keep only the expiry-days check plus the TryParse call. No additional imports, methods, or definitions are needed; we only adjust the condition expression.

Suggested changeset 1
src/PortPane/App.xaml.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/PortPane/App.xaml.cs b/src/PortPane/App.xaml.cs
--- a/src/PortPane/App.xaml.cs
+++ b/src/PortPane/App.xaml.cs
@@ -31,7 +31,7 @@
         }
 
         // ── Build expiry check (alpha/beta only) ──────────────────────────────
-        if (ChannelInfo.BuildExpiryDays > 0 && !string.IsNullOrEmpty(BrandingInfo.BuildDate)
+        if (ChannelInfo.BuildExpiryDays > 0
             && DateTimeOffset.TryParse(BrandingInfo.BuildDate, null,
                 System.Globalization.DateTimeStyles.RoundtripKind, out var buildDate))
         {
EOF
@@ -31,7 +31,7 @@
}

// ── Build expiry check (alpha/beta only) ──────────────────────────────
if (ChannelInfo.BuildExpiryDays > 0 && !string.IsNullOrEmpty(BrandingInfo.BuildDate)
if (ChannelInfo.BuildExpiryDays > 0
&& DateTimeOffset.TryParse(BrandingInfo.BuildDate, null,
System.Globalization.DateTimeStyles.RoundtripKind, out var buildDate))
{
Copilot is powered by AI and may make mistakes. Always verify output.
&& DateTimeOffset.TryParse(BrandingInfo.BuildDate, null,
System.Globalization.DateTimeStyles.RoundtripKind, out var buildDate))
{
var expiry = buildDate.AddDays(ChannelInfo.BuildExpiryDays);
if (DateTimeOffset.UtcNow > expiry)
{
MessageBox.Show(
$"This {BrandingInfo.FullVersion} build expired on {expiry:yyyy-MM-dd}.\n\n" +
$"Download the latest version at:\n{BrandingInfo.RepoURL}/releases",
$"{BrandingInfo.AppName} — Build Expired",
MessageBoxButton.OK, MessageBoxImage.Warning);
Shutdown();
return;
}
}

// ── Reset flag (wipes all user data and relaunches fresh) ─────────────
if (e.Args.Contains("--reset", StringComparer.OrdinalIgnoreCase))
{
bool isPortable = File.Exists(Path.Combine(AppContext.BaseDirectory, "portable.txt"));

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.

Copilot Autofix

AI 18 days ago

General fix: When composing paths where the intent is “base directory + relative subpath/file”, prefer Path.Join instead of Path.Combine. Path.Join does not treat later absolute segments in a way that discards earlier ones; it simply concatenates them with directory separators as needed. For cases where absolute paths are expected and must override earlier segments, Path.Combine may still be appropriate, but here we clearly want to append "portable.txt" to the base directory.

Best fix for this code: Replace the single usage of Path.Combine at line 54 with Path.Join, leaving the rest of the logic unchanged. This keeps functionality identical—AppContext.BaseDirectory plus "portable.txt"—but removes the specific CodeQL‑flagged pattern. No extra imports are needed because Path is in System.IO, which is already available in the base class library; and the file already references other System.* namespaces, so adding using System.IO; is optional for this edit (the existing code compiles because Path is fully qualified via System.IO.Path implicitly from mscorlib/System.Private.CoreLib). We only touch the shown snippet inside src/PortPane/App.xaml.cs.

Concretely:

  • In OnStartup, in the --reset block, update:
bool isPortable = File.Exists(Path.Combine(AppContext.BaseDirectory, "portable.txt"));

to:

bool isPortable = File.Exists(Path.Join(AppContext.BaseDirectory, "portable.txt"));

No other code changes are required.


Suggested changeset 1
src/PortPane/App.xaml.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/PortPane/App.xaml.cs b/src/PortPane/App.xaml.cs
--- a/src/PortPane/App.xaml.cs
+++ b/src/PortPane/App.xaml.cs
@@ -51,7 +51,7 @@
         // ── Reset flag (wipes all user data and relaunches fresh) ─────────────
         if (e.Args.Contains("--reset", StringComparer.OrdinalIgnoreCase))
         {
-            bool isPortable = File.Exists(Path.Combine(AppContext.BaseDirectory, "portable.txt"));
+            bool isPortable = File.Exists(Path.Join(AppContext.BaseDirectory, "portable.txt"));
             string dataDir = isPortable
                 ? Path.Combine(AppContext.BaseDirectory, "PortPane-Data")
                 : Path.Combine(
EOF
@@ -51,7 +51,7 @@
// ── Reset flag (wipes all user data and relaunches fresh) ─────────────
if (e.Args.Contains("--reset", StringComparer.OrdinalIgnoreCase))
{
bool isPortable = File.Exists(Path.Combine(AppContext.BaseDirectory, "portable.txt"));
bool isPortable = File.Exists(Path.Join(AppContext.BaseDirectory, "portable.txt"));
string dataDir = isPortable
? Path.Combine(AppContext.BaseDirectory, "PortPane-Data")
: Path.Combine(
Copilot is powered by AI and may make mistakes. Always verify output.
string dataDir = isPortable
? Path.Combine(AppContext.BaseDirectory, "PortPane-Data")

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.

Copilot Autofix

AI 18 days ago

In general, to avoid Path.Combine silently discarding earlier segments when later segments may be absolute, you should either ensure all later segments are relative, or use Path.Join, which concatenates segments without treating absolute segments specially. Here, we want “base directory + fixed subfolder name” semantics, and the best fix is to use Path.Join instead of Path.Combine for those paths that are conceptually “base + child” and not meant to re-root.

Concretely, in src/PortPane/App.xaml.cs within the --reset handling block, change:

  • Path.Combine(AppContext.BaseDirectory, "PortPane-Data") to Path.Join(AppContext.BaseDirectory, "PortPane-Data").

This preserves the effective path today (because "PortPane-Data" is relative), but removes the risk of dropping the base directory if that string ever becomes absolute. No additional imports are required because Path.Join is in System.IO.Path, already in scope via System / System.IO being part of the BCL; the file already uses File and Directory, so the runtime environment clearly supports these APIs. Other Path.Combine calls in the snippet (those involving Environment.GetFolderPath and branding strings) are correctly using Combine to handle rooted paths and should remain unchanged.

Suggested changeset 1
src/PortPane/App.xaml.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/PortPane/App.xaml.cs b/src/PortPane/App.xaml.cs
--- a/src/PortPane/App.xaml.cs
+++ b/src/PortPane/App.xaml.cs
@@ -53,7 +53,7 @@
         {
             bool isPortable = File.Exists(Path.Combine(AppContext.BaseDirectory, "portable.txt"));
             string dataDir = isPortable
-                ? Path.Combine(AppContext.BaseDirectory, "PortPane-Data")
+                ? Path.Join(AppContext.BaseDirectory, "PortPane-Data")
                 : Path.Combine(
                     Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
                     BrandingInfo.SuiteName, BrandingInfo.AppName);
EOF
@@ -53,7 +53,7 @@
{
bool isPortable = File.Exists(Path.Combine(AppContext.BaseDirectory, "portable.txt"));
string dataDir = isPortable
? Path.Combine(AppContext.BaseDirectory, "PortPane-Data")
? Path.Join(AppContext.BaseDirectory, "PortPane-Data")
: Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
BrandingInfo.SuiteName, BrandingInfo.AppName);
Copilot is powered by AI and may make mistakes. Always verify output.
: Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
BrandingInfo.SuiteName, BrandingInfo.AppName);
Comment on lines +57 to +59

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.

Copilot Autofix

AI 18 days ago

In general, to avoid Path.Combine silently dropping earlier arguments when later ones are absolute, use Path.Join when you are concatenating path segments and you do not want absolute later segments to override earlier ones. Path.Join will concatenate segments using the directory separator without giving precedence to any argument.

For this specific case, replace the use of Path.Combine for dataDir’s non-portable path with Path.Join. This preserves the intended behavior for normal relative SuiteName and AppName values while preventing them from unexpectedly overriding the base %AppData% path if they were ever set to absolute paths. No additional imports are required because System.IO.Path is already available in the base class library.

Concretely:

  • In src/PortPane/App.xaml.cs, around lines 55–59, change:
    • : Path.Combine(Environment.GetFolderPath(...), BrandingInfo.SuiteName, BrandingInfo.AppName);
    • to:
    • : Path.Join(Environment.GetFolderPath(...), BrandingInfo.SuiteName, BrandingInfo.AppName);
  • Leave the other Path.Combine calls unchanged, as they combine a base directory with known, simple file or subdirectory names and are not part of the flagged issue.
Suggested changeset 1
src/PortPane/App.xaml.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/PortPane/App.xaml.cs b/src/PortPane/App.xaml.cs
--- a/src/PortPane/App.xaml.cs
+++ b/src/PortPane/App.xaml.cs
@@ -54,7 +54,7 @@
             bool isPortable = File.Exists(Path.Combine(AppContext.BaseDirectory, "portable.txt"));
             string dataDir = isPortable
                 ? Path.Combine(AppContext.BaseDirectory, "PortPane-Data")
-                : Path.Combine(
+                : Path.Join(
                     Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
                     BrandingInfo.SuiteName, BrandingInfo.AppName);
             if (Directory.Exists(dataDir))
EOF
@@ -54,7 +54,7 @@
bool isPortable = File.Exists(Path.Combine(AppContext.BaseDirectory, "portable.txt"));
string dataDir = isPortable
? Path.Combine(AppContext.BaseDirectory, "PortPane-Data")
: Path.Combine(
: Path.Join(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
BrandingInfo.SuiteName, BrandingInfo.AppName);
if (Directory.Exists(dataDir))
Copilot is powered by AI and may make mistakes. Always verify output.
if (Directory.Exists(dataDir))
Directory.Delete(dataDir, recursive: true);
System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(Environment.ProcessPath!) { UseShellExecute = true });
Shutdown();
return;
}

VelopackApp.Build().Run();

// ── Bootstrap logging (before DI so DI errors are captured) ──────────
Expand All @@ -40,7 +75,7 @@
AppDomain.CurrentDomain.UnhandledException += OnDomainException;

Log.Information("{AppName} {Version} starting. Fingerprint: {FP}",
BrandingInfo.AppName, BrandingInfo.Version, Attribution.Fingerprint);
BrandingInfo.AppName, BrandingInfo.FullVersion, Attribution.Fingerprint);

// ── Dependency injection ──────────────────────────────────────────────
var services = new ServiceCollection();
Expand Down Expand Up @@ -171,8 +206,11 @@
private static void SetupSerilog(string logDir)
{
Directory.CreateDirectory(logDir);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
var logConfig = new LoggerConfiguration();
logConfig = ChannelInfo.VerboseLogging
? logConfig.MinimumLevel.Debug()
: logConfig.MinimumLevel.Information();
Log.Logger = logConfig
.WriteTo.File(
path: Path.Combine(logDir, "portpane-.log"),
rollingInterval: RollingInterval.Day,
Expand Down
48 changes: 47 additions & 1 deletion src/PortPane/BrandingInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,53 @@
public const string AppName = "PortPane";
public const string SuiteName = "ShackDesk";
public const string FullName = "PortPane by ShackDesk";
public const string Version = "0.5.1-beta";
public const string Version = "0.5.2";

/// <summary>
/// ISO 8601 UTC build timestamp. Empty string in source — patched by CI at
/// publish time. Used by App.xaml.cs to enforce ChannelInfo.BuildExpiryDays.
/// </summary>
public const string BuildDate = "";

/// <summary>
/// Full version string for display, logging, and telemetry.
/// Composed from Version + ChannelInfo.VersionSuffix at runtime.
/// Alpha builds also show the BuildDate stamp when available.
/// </summary>
public static string FullVersion
{
get
{
if (string.IsNullOrEmpty(ChannelInfo.VersionSuffix))

Check warning on line 29 in src/PortPane/BrandingInfo.cs

View workflow job for this annotation

GitHub Actions / Verify C# formatting

Add braces to 'if' statement.

Check warning

Code scanning / CodeQL

Constant condition Warning

Condition always evaluates to 'true'.

Copilot Autofix

AI 18 days ago

General approach: normalize ChannelInfo.VersionSuffix into a local variable and base all conditions on that local, ensuring that the condition is not provably constant to the analyzer and clarifying the logic. This avoids removing any behavior while addressing the constant‑condition warning.

Concrete fix in src/PortPane/BrandingInfo.cs, property FullVersion (lines 25–38):

  1. At the top of the getter, introduce var suffix = ChannelInfo.VersionSuffix ?? string.Empty;.
  2. Replace if (string.IsNullOrEmpty(ChannelInfo.VersionSuffix)) with if (string.IsNullOrEmpty(suffix)).
  3. In the final return, replace $"{Version}-{ChannelInfo.VersionSuffix}" with $"{Version}-{suffix}".

No new imports or types are required; we use only string.Empty, which is already in System. This keeps behavior the same: if the suffix is null or empty, FullVersion returns Version. For alpha builds, we still generate the special alpha string; if that doesn’t apply, we append the normalized suffix.

Suggested changeset 1
src/PortPane/BrandingInfo.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/PortPane/BrandingInfo.cs b/src/PortPane/BrandingInfo.cs
--- a/src/PortPane/BrandingInfo.cs
+++ b/src/PortPane/BrandingInfo.cs
@@ -26,7 +26,8 @@
     {
         get
         {
-            if (string.IsNullOrEmpty(ChannelInfo.VersionSuffix))
+            var suffix = ChannelInfo.VersionSuffix ?? string.Empty;
+            if (string.IsNullOrEmpty(suffix))
                 return Version;
 
             if (ChannelInfo.Channel == ReleaseChannel.Alpha && !string.IsNullOrEmpty(BuildDate)
@@ -34,7 +35,7 @@
                     System.Globalization.DateTimeStyles.RoundtripKind, out var dt))
                 return $"{Version}-alpha.{dt:yyyyMMdd}";
 
-            return $"{Version}-{ChannelInfo.VersionSuffix}";
+            return $"{Version}-{suffix}";
         }
     }
     /// <summary>
EOF
@@ -26,7 +26,8 @@
{
get
{
if (string.IsNullOrEmpty(ChannelInfo.VersionSuffix))
var suffix = ChannelInfo.VersionSuffix ?? string.Empty;
if (string.IsNullOrEmpty(suffix))
return Version;

if (ChannelInfo.Channel == ReleaseChannel.Alpha && !string.IsNullOrEmpty(BuildDate)
@@ -34,7 +35,7 @@
System.Globalization.DateTimeStyles.RoundtripKind, out var dt))
return $"{Version}-alpha.{dt:yyyyMMdd}";

return $"{Version}-{ChannelInfo.VersionSuffix}";
return $"{Version}-{suffix}";
}
}
/// <summary>
Copilot is powered by AI and may make mistakes. Always verify output.
return Version;

if (ChannelInfo.Channel == ReleaseChannel.Alpha && !string.IsNullOrEmpty(BuildDate)

Check warning on line 32 in src/PortPane/BrandingInfo.cs

View workflow job for this annotation

GitHub Actions / Verify C# formatting

Add braces to 'if' statement.

Check warning

Code scanning / CodeQL

Constant condition Warning

Condition always evaluates to 'true'.

Copilot Autofix

AI 18 days ago

In general, to fix a constant condition you either (1) remove the redundant constant subcondition if it doesn’t affect behavior, or (2) refactor so that the condition can truly vary at runtime (for example, by not using a const/readonly value that is always the same). Here, we must not assume or change the behavior outside this snippet, so we should preserve the runtime behavior while eliminating the constant part that CodeQL flags.

Given that CodeQL believes ChannelInfo.Channel == ReleaseChannel.Alpha is always true, the behavior of FullVersion today is: if ChannelInfo.VersionSuffix is null/empty, return Version; otherwise, if BuildDate is non‑empty and parseable, return an alpha‑style string ${Version}-alpha.{yyyyMMdd}; otherwise, return ${Version}-{ChannelInfo.VersionSuffix}. Removing just the ChannelInfo.Channel == ReleaseChannel.Alpha && from the condition keeps exactly the same behavior under the current assumption that the channel is always Alpha, while eliminating the constant expression. So, in src/PortPane/BrandingInfo.cs, inside the FullVersion getter, change the if at lines 32–35 to drop the ChannelInfo.Channel == ReleaseChannel.Alpha && portion and leave the rest intact. No new imports or helpers are needed.

Suggested changeset 1
src/PortPane/BrandingInfo.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/PortPane/BrandingInfo.cs b/src/PortPane/BrandingInfo.cs
--- a/src/PortPane/BrandingInfo.cs
+++ b/src/PortPane/BrandingInfo.cs
@@ -29,7 +29,7 @@
             if (string.IsNullOrEmpty(ChannelInfo.VersionSuffix))
                 return Version;
 
-            if (ChannelInfo.Channel == ReleaseChannel.Alpha && !string.IsNullOrEmpty(BuildDate)
+            if (!string.IsNullOrEmpty(BuildDate)
                 && DateTimeOffset.TryParse(BuildDate, null,
                     System.Globalization.DateTimeStyles.RoundtripKind, out var dt))
                 return $"{Version}-alpha.{dt:yyyyMMdd}";
EOF
@@ -29,7 +29,7 @@
if (string.IsNullOrEmpty(ChannelInfo.VersionSuffix))
return Version;

if (ChannelInfo.Channel == ReleaseChannel.Alpha && !string.IsNullOrEmpty(BuildDate)
if (!string.IsNullOrEmpty(BuildDate)
&& DateTimeOffset.TryParse(BuildDate, null,
System.Globalization.DateTimeStyles.RoundtripKind, out var dt))
return $"{Version}-alpha.{dt:yyyyMMdd}";
Copilot is powered by AI and may make mistakes. Always verify output.

Check warning

Code scanning / CodeQL

Constant condition Warning

Condition always evaluates to 'true'.

Copilot Autofix

AI 18 days ago

In general, to fix this constant condition we must ensure BuildDate is not a compile‑time constant. Changing it from const to a runtime‑assigned value (e.g., static readonly or a normal static field) prevents the compiler from treating it as a constant and allows string.IsNullOrEmpty(BuildDate) to depend on the CI‑provided value as intended.

The single best, minimal‑behavior‑change fix here is:

  • Change public const string BuildDate = ""; to public static readonly string BuildDate = "";.

This preserves the public API surface (a static string field named BuildDate), keeps the default empty value in source, and allows the CI process or configuration system to override it without fighting constant inlining. After this change, string.IsNullOrEmpty(BuildDate) on line 32 (and line 49) will no longer be a constant expression, so the alpha/version and expiry logic will work as designed.

No new methods or special imports are needed; we only adjust the field declaration for BuildDate in src/PortPane/BrandingInfo.cs.

Suggested changeset 1
src/PortPane/BrandingInfo.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/PortPane/BrandingInfo.cs b/src/PortPane/BrandingInfo.cs
--- a/src/PortPane/BrandingInfo.cs
+++ b/src/PortPane/BrandingInfo.cs
@@ -15,7 +15,7 @@
     /// ISO 8601 UTC build timestamp. Empty string in source — patched by CI at
     /// publish time. Used by App.xaml.cs to enforce ChannelInfo.BuildExpiryDays.
     /// </summary>
-    public const string BuildDate         = "";
+    public static readonly string BuildDate = "";
 
     /// <summary>
     /// Full version string for display, logging, and telemetry.
EOF
@@ -15,7 +15,7 @@
/// ISO 8601 UTC build timestamp. Empty string in source — patched by CI at
/// publish time. Used by App.xaml.cs to enforce ChannelInfo.BuildExpiryDays.
/// </summary>
public const string BuildDate = "";
public static readonly string BuildDate = "";

/// <summary>
/// Full version string for display, logging, and telemetry.
Copilot is powered by AI and may make mistakes. Always verify output.
&& DateTimeOffset.TryParse(BuildDate, null,
System.Globalization.DateTimeStyles.RoundtripKind, out var dt))
return $"{Version}-alpha.{dt:yyyyMMdd}";

return $"{Version}-{ChannelInfo.VersionSuffix}";
}
}
/// <summary>
/// Whole days remaining before this build expires, or null if no expiry applies.
/// Returns null when: channel is Stable, BuildExpiryDays is 0, or BuildDate was not stamped by CI.
/// Returns 0 on the expiry day itself (not negative).
/// </summary>
public static int? DaysRemaining
{
get
{
if (ChannelInfo.BuildExpiryDays <= 0 || string.IsNullOrEmpty(BuildDate))

Check warning on line 49 in src/PortPane/BrandingInfo.cs

View workflow job for this annotation

GitHub Actions / Verify C# formatting

Add braces to 'if' statement.

Check warning

Code scanning / CodeQL

Constant condition Warning

Condition always evaluates to 'true'.

Copilot Autofix

AI 18 days ago

In general, the way to fix a “constant condition” is to either remove the redundant check if it is genuinely impossible, or to restructure the code so that the condition is expressed via a non-constant value (for example, a method or property) when the behavior is intended to remain configurable. Here we want to preserve the documented semantics: DaysRemaining should return null when BuildExpiryDays is zero or negative (or when BuildDate is empty/invalid). Therefore, the best change is to move the “has expiry” logic into a separate static property that the analyzer cannot fold into a constant, and then use that property in DaysRemaining.

Concretely, in src/PortPane/BrandingInfo.cs, within the BrandingInfo class, we will introduce a new static private helper property, for example private static bool IsExpiryEnabled => ChannelInfo.BuildExpiryDays > 0;. We then adjust the first if inside the DaysRemaining getter from:

if (ChannelInfo.BuildExpiryDays <= 0 || string.IsNullOrEmpty(BuildDate))
    return null;

to:

if (!IsExpiryEnabled || string.IsNullOrEmpty(BuildDate))
    return null;

This preserves the runtime behavior (still returns null when BuildExpiryDays <= 0) but removes the direct constant comparison that CodeQL believes is always false, since IsExpiryEnabled is not a compile-time constant even if BuildExpiryDays is. No new imports or external dependencies are required; we stay entirely within the shown file and keep existing functionality intact.

Suggested changeset 1
src/PortPane/BrandingInfo.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/PortPane/BrandingInfo.cs b/src/PortPane/BrandingInfo.cs
--- a/src/PortPane/BrandingInfo.cs
+++ b/src/PortPane/BrandingInfo.cs
@@ -37,16 +37,26 @@
             return $"{Version}-{ChannelInfo.VersionSuffix}";
         }
     }
+
     /// <summary>
+    /// Indicates whether build expiry is enabled for the current channel/configuration.
+    /// Kept as a helper to avoid constant-condition analysis on BuildExpiryDays.
+    /// </summary>
+    private static bool IsExpiryEnabled => ChannelInfo.BuildExpiryDays > 0;
+
+    /// <summary>
     /// Whole days remaining before this build expires, or null if no expiry applies.
     /// Returns null when: channel is Stable, BuildExpiryDays is 0, or BuildDate was not stamped by CI.
     /// Returns 0 on the expiry day itself (not negative).
     /// </summary>
+    /// Returns null when: channel is Stable, BuildExpiryDays is 0, or BuildDate was not stamped by CI.
+    /// Returns 0 on the expiry day itself (not negative).
+    /// </summary>
     public static int? DaysRemaining
     {
         get
         {
-            if (ChannelInfo.BuildExpiryDays <= 0 || string.IsNullOrEmpty(BuildDate))
+            if (!IsExpiryEnabled || string.IsNullOrEmpty(BuildDate))
                 return null;
             if (!DateTimeOffset.TryParse(BuildDate, null,
                     System.Globalization.DateTimeStyles.RoundtripKind, out var buildDate))
EOF
@@ -37,16 +37,26 @@
return $"{Version}-{ChannelInfo.VersionSuffix}";
}
}

/// <summary>
/// Indicates whether build expiry is enabled for the current channel/configuration.
/// Kept as a helper to avoid constant-condition analysis on BuildExpiryDays.
/// </summary>
private static bool IsExpiryEnabled => ChannelInfo.BuildExpiryDays > 0;

/// <summary>
/// Whole days remaining before this build expires, or null if no expiry applies.
/// Returns null when: channel is Stable, BuildExpiryDays is 0, or BuildDate was not stamped by CI.
/// Returns 0 on the expiry day itself (not negative).
/// </summary>
/// Returns null when: channel is Stable, BuildExpiryDays is 0, or BuildDate was not stamped by CI.
/// Returns 0 on the expiry day itself (not negative).
/// </summary>
public static int? DaysRemaining
{
get
{
if (ChannelInfo.BuildExpiryDays <= 0 || string.IsNullOrEmpty(BuildDate))
if (!IsExpiryEnabled || string.IsNullOrEmpty(BuildDate))
return null;
if (!DateTimeOffset.TryParse(BuildDate, null,
System.Globalization.DateTimeStyles.RoundtripKind, out var buildDate))
Copilot is powered by AI and may make mistakes. Always verify output.

Check warning

Code scanning / CodeQL

Constant condition Warning

Condition always evaluates to 'true'.

Copilot Autofix

AI 18 days ago

In general, to fix constant-condition issues caused by const values that are meant to change at build time, replace const with a non-constant field or property so that the analyzer cannot treat the value as compile-time constant, and initialize it in a way that still supports CI patching but keeps semantics unchanged at runtime.

Here, the best targeted fix is to change BuildDate from a const string to a non-const, read-only field (or an auto-property) initialized to the empty string. This preserves the current behavior when CI does not stamp a date (it stays empty, and the code still returns null), while allowing CI to overwrite it during the build (for example, via source rewriting or resource injection). From the perspective of CodeQL and the C# compiler, BuildDate is no longer a compile-time constant, so string.IsNullOrEmpty(BuildDate) is no longer a constant expression; the condition on line 49 becomes genuinely data-dependent.

Concretely, in src/PortPane/BrandingInfo.cs, change the declaration on line 18 from public const string BuildDate = ""; to public static readonly string BuildDate = "";. No other logic needs to change, because all existing usages of BuildDate (string checks and parsing) work identically with a static readonly field. No new imports or helper methods are required.

Suggested changeset 1
src/PortPane/BrandingInfo.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/PortPane/BrandingInfo.cs b/src/PortPane/BrandingInfo.cs
--- a/src/PortPane/BrandingInfo.cs
+++ b/src/PortPane/BrandingInfo.cs
@@ -15,7 +15,7 @@
     /// ISO 8601 UTC build timestamp. Empty string in source — patched by CI at
     /// publish time. Used by App.xaml.cs to enforce ChannelInfo.BuildExpiryDays.
     /// </summary>
-    public const string BuildDate         = "";
+    public static readonly string BuildDate = "";
 
     /// <summary>
     /// Full version string for display, logging, and telemetry.
EOF
@@ -15,7 +15,7 @@
/// ISO 8601 UTC build timestamp. Empty string in source — patched by CI at
/// publish time. Used by App.xaml.cs to enforce ChannelInfo.BuildExpiryDays.
/// </summary>
public const string BuildDate = "";
public static readonly string BuildDate = "";

/// <summary>
/// Full version string for display, logging, and telemetry.
Copilot is powered by AI and may make mistakes. Always verify output.
return null;
if (!DateTimeOffset.TryParse(BuildDate, null,

Check warning on line 51 in src/PortPane/BrandingInfo.cs

View workflow job for this annotation

GitHub Actions / Verify C# formatting

Add braces to 'if' statement.
System.Globalization.DateTimeStyles.RoundtripKind, out var buildDate))
return null;
int days = (int)Math.Ceiling((buildDate.AddDays(ChannelInfo.BuildExpiryDays) - DateTimeOffset.UtcNow).TotalDays);
return Math.Max(0, days);
}
}

public const string AuthorName = "Mark McDow";
public const string AuthorCallsign = "N4TEK";
public const string AuthorCompany = "My Computer Guru LLC";
Expand Down
Loading
Loading