From 49b80df94b8e50d86f38aca3cc06c20057c2a8ed Mon Sep 17 00:00:00 2001 From: Mark McDow Date: Sun, 29 Mar 2026 05:57:59 -0400 Subject: [PATCH 1/4] Add multi-channel release system and bump to v0.5.2 ChannelInfo.cs introduces ReleaseChannel enum with per-branch constants for build expiry, feature unlock, telemetry defaults, logging level, and version suffix. Protected from forward merges via .gitattributes merge=ours. App.xaml.cs gains a kill switch (BuildExpiryDays), --reset flag for clean reinstall, and channel-aware Serilog minimum level. LicenseService bypasses validation when UnlockAllForTesting is set. SettingsService applies channel- aware telemetry default on fresh installs. BrandingInfo.Version bumped to 0.5.2. BuildDate constant added for CI stamping. FullVersion property composes display string from version, channel suffix, and build timestamp. CI updated: dev branch trigger, channel-aware version composition, BuildDate stamp step, and rolling-alpha job that keeps latest-alpha release current. --- .gitattributes | 5 ++ .github/workflows/build.yml | 89 ++++++++++++++++++++-- CHANGELOG.md | 38 +++++++++ src/PortPane/App.xaml.cs | 44 ++++++++++- src/PortPane/BrandingInfo.cs | 29 ++++++- src/PortPane/ChannelInfo.cs | 52 +++++++++++++ src/PortPane/Logging/LogFileHeaderHooks.cs | 2 +- src/PortPane/Models/AppSettings.cs | 1 + src/PortPane/Services/LicenseService.cs | 3 + src/PortPane/Services/SettingsService.cs | 8 +- src/PortPane/Services/TelemetryService.cs | 2 +- src/PortPane/ViewModels/MainViewModel.cs | 2 +- 12 files changed, 258 insertions(+), 17 deletions(-) create mode 100644 src/PortPane/ChannelInfo.cs diff --git a/.gitattributes b/.gitattributes index e04af24..c1c0d78 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 89f380e..f7b5285 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,6 +11,7 @@ # 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) @@ -18,15 +19,21 @@ # 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" @@ -35,7 +42,7 @@ name: Build · Test · Publish · Release on: push: - branches: [ main ] + branches: [ main, dev ] tags: [ 'v*' ] paths-ignore: - '**/*.md' @@ -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)) @@ -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: > @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e7a35..52a3547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,44 @@ 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 + +### 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` diff --git a/src/PortPane/App.xaml.cs b/src/PortPane/App.xaml.cs index b152bbf..de94584 100644 --- a/src/PortPane/App.xaml.cs +++ b/src/PortPane/App.xaml.cs @@ -30,6 +30,41 @@ protected override void OnStartup(StartupEventArgs e) return; } + // ── Build expiry check (alpha/beta only) ────────────────────────────── + if (ChannelInfo.BuildExpiryDays > 0 && !string.IsNullOrEmpty(BrandingInfo.BuildDate) + && 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")); + string dataDir = isPortable + ? Path.Combine(AppContext.BaseDirectory, "PortPane-Data") + : Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + BrandingInfo.SuiteName, BrandingInfo.AppName); + 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) ────────── @@ -40,7 +75,7 @@ protected override void OnStartup(StartupEventArgs e) 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(); @@ -171,8 +206,11 @@ private static void ReconfigureLogging(ISettingsService settings) 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, diff --git a/src/PortPane/BrandingInfo.cs b/src/PortPane/BrandingInfo.cs index e89b898..39b692e 100644 --- a/src/PortPane/BrandingInfo.cs +++ b/src/PortPane/BrandingInfo.cs @@ -9,7 +9,34 @@ public static class BrandingInfo 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"; + + /// + /// ISO 8601 UTC build timestamp. Empty string in source — patched by CI at + /// publish time. Used by App.xaml.cs to enforce ChannelInfo.BuildExpiryDays. + /// + public const string BuildDate = ""; + + /// + /// Full version string for display, logging, and telemetry. + /// Composed from Version + ChannelInfo.VersionSuffix at runtime. + /// Alpha builds also show the BuildDate stamp when available. + /// + public static string FullVersion + { + get + { + if (string.IsNullOrEmpty(ChannelInfo.VersionSuffix)) + return Version; + + if (ChannelInfo.Channel == ReleaseChannel.Alpha && !string.IsNullOrEmpty(BuildDate) + && DateTimeOffset.TryParse(BuildDate, null, + System.Globalization.DateTimeStyles.RoundtripKind, out var dt)) + return $"{Version}-alpha.{dt:yyyyMMdd}"; + + return $"{Version}-{ChannelInfo.VersionSuffix}"; + } + } public const string AuthorName = "Mark McDow"; public const string AuthorCallsign = "N4TEK"; public const string AuthorCompany = "My Computer Guru LLC"; diff --git a/src/PortPane/ChannelInfo.cs b/src/PortPane/ChannelInfo.cs new file mode 100644 index 0000000..84dd26b --- /dev/null +++ b/src/PortPane/ChannelInfo.cs @@ -0,0 +1,52 @@ +namespace PortPane; + +public enum ReleaseChannel { Alpha, Beta, Stable } + +/// +/// Release channel constants. This file is intentionally different on each branch +/// (alpha/dev, beta, main) and must never be overwritten by a merge. +/// See .gitattributes — merge=ours ensures this file is always preserved on the +/// target branch when merging code forward from alpha → beta → main. +/// +/// To promote code forward: +/// git checkout beta → git merge dev (ChannelInfo.cs stays as beta values) +/// git checkout main → git merge beta (ChannelInfo.cs stays as main values) +/// +public static class ChannelInfo +{ + /// Current release channel for this branch. + public const ReleaseChannel Channel = ReleaseChannel.Alpha; + + /// + /// When true, LicenseService returns a Personal-tier license automatically. + /// Eliminates license friction for alpha testers. Set false on beta and main. + /// + public const bool UnlockAllForTesting = true; + + /// + /// Default value for TelemetryEnabled on a fresh install. + /// Alpha uses opt-out (true) to maximize diagnostic data from known testers. + /// Beta and main use opt-in (false) per the privacy policy. + /// + public const bool TelemetryOnByDefault = true; + + /// + /// When true, Serilog minimum level is Debug. When false, Information. + /// Alpha builds log everything; beta and main log only informational events. + /// + public const bool VerboseLogging = true; + + /// + /// Number of days after BuildDate before the app refuses to start. + /// 0 = no expiry (used on main). Alpha: 14 days. Beta: 60 days. + /// Requires BrandingInfo.BuildDate to be stamped by CI at publish time. + /// + public const int BuildExpiryDays = 14; + + /// + /// Version suffix appended by CI when composing the full artifact version. + /// Alpha: "alpha" (CI also appends .yyyyMMdd-HHmm timestamp). + /// Beta: "beta". Stable: empty string. + /// + public const string VersionSuffix = "alpha"; +} diff --git a/src/PortPane/Logging/LogFileHeaderHooks.cs b/src/PortPane/Logging/LogFileHeaderHooks.cs index d2354c5..01c7355 100644 --- a/src/PortPane/Logging/LogFileHeaderHooks.cs +++ b/src/PortPane/Logging/LogFileHeaderHooks.cs @@ -33,7 +33,7 @@ private static string BuildHeader() {{line}} PortPane by ShackDesk — Application Log Project : https://github.com/Computer-Tsu/shackdesk-portpane - Suite : ShackDesk | App: PortPane | Version: {{BrandingInfo.Version}} + Suite : ShackDesk | App: PortPane | Version: {{BrandingInfo.FullVersion}} Author : {{BrandingInfo.AuthorName}} ({{BrandingInfo.AuthorCallsign}}) — {{BrandingInfo.AuthorCompany}} Support : {{BrandingInfo.SupportURL}} Log opened: {{now}} diff --git a/src/PortPane/Models/AppSettings.cs b/src/PortPane/Models/AppSettings.cs index a07c55a..35ece1c 100644 --- a/src/PortPane/Models/AppSettings.cs +++ b/src/PortPane/Models/AppSettings.cs @@ -39,6 +39,7 @@ public sealed class AppSettings // Lifecycle public bool FirstRunComplete { get; set; } = false; + public string LastSeenVersion { get; set; } = string.Empty; // License public string LicenseKey { get; set; } = string.Empty; diff --git a/src/PortPane/Services/LicenseService.cs b/src/PortPane/Services/LicenseService.cs index ce5b513..da64560 100644 --- a/src/PortPane/Services/LicenseService.cs +++ b/src/PortPane/Services/LicenseService.cs @@ -196,6 +196,9 @@ private static LicenseInfo FreeTier() private LicenseInfo LoadAndValidate() { + if (ChannelInfo.UnlockAllForTesting) + return new LicenseInfo(LicenseTier.Personal, "Alpha Tester", null, "personal", null, IsValid: true); + if (!File.Exists(LicenseFilePath)) return FreeTier(); try diff --git a/src/PortPane/Services/SettingsService.cs b/src/PortPane/Services/SettingsService.cs index eb77aeb..415257d 100644 --- a/src/PortPane/Services/SettingsService.cs +++ b/src/PortPane/Services/SettingsService.cs @@ -55,7 +55,10 @@ private AppSettings Load() if (!File.Exists(path)) { Log.Debug("No settings file found; using defaults"); - return new AppSettings(); + var defaults = new AppSettings(); + if (ChannelInfo.TelemetryOnByDefault) + defaults.TelemetryEnabled = true; + return defaults; } try @@ -152,7 +155,8 @@ private static string SerializeWithHeader(AppSettings settings) ["_comment_39"] = " FirstRunComplete bool Set to true after first-run dialog is dismissed.", ["_comment_40"] = " LicenseKey string Commercial license key (Base64). Leave empty for GPL.", ["_comment_41"] = " PortableMode bool Informational. Actual detection uses portable.txt.", - ["_comment_42"] = "════════════════════════════════════════════════════════════════════", + ["_comment_42"] = " LastSeenVersion string App version recorded at last launch. Used for new-version prompts.", + ["_comment_43"] = "════════════════════════════════════════════════════════════════════", }; // Append all settings fields after the comment block. diff --git a/src/PortPane/Services/TelemetryService.cs b/src/PortPane/Services/TelemetryService.cs index 491f29d..e2c48b1 100644 --- a/src/PortPane/Services/TelemetryService.cs +++ b/src/PortPane/Services/TelemetryService.cs @@ -120,7 +120,7 @@ private static object BuildPayload(string eventName, { report_id = Guid.NewGuid().ToString(), app = BrandingInfo.AppName, - version = BrandingInfo.Version, + version = BrandingInfo.FullVersion, @event = eventName, os = Environment.OSVersion.VersionString, timestamp = DateTimeOffset.UtcNow, diff --git a/src/PortPane/ViewModels/MainViewModel.cs b/src/PortPane/ViewModels/MainViewModel.cs index 2c71b68..87224fb 100644 --- a/src/PortPane/ViewModels/MainViewModel.cs +++ b/src/PortPane/ViewModels/MainViewModel.cs @@ -84,7 +84,7 @@ public string Title LicenseTier.EmComm => $" [{_license.Current.Licensee}]", _ => string.Empty }; - return $"{BrandingInfo.FullName} {BrandingInfo.Version}{tier}"; + return $"{BrandingInfo.FullName} {BrandingInfo.FullVersion}{tier}"; } } From cc5c72111492ea7fdcc37571e7b8f0272526999c Mon Sep 17 00:00:00 2001 From: Mark McDow Date: Mon, 30 Mar 2026 22:02:56 -0400 Subject: [PATCH 2/4] v0.5.2-alpha: multi-channel release system, alpha watermark, build expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces ChannelInfo.cs (already committed) driving channel-specific behaviour across all three future branches (alpha/beta/stable). Changes in this commit: - BrandingInfo: Version → 0.5.2, adds BuildDate (CI-stamped), FullVersion (composed with channel suffix), DaysRemaining (countdown to expiry) - LicenseService: internal unlockForTesting constructor so tests can opt out of the alpha Personal-tier override independently of ChannelInfo - LicenseValidationTests: three tests updated to use unlockForTesting:false, fixing the CI build failure caused by UnlockAllForTesting=true on alpha - MainViewModel: IsPreRelease, ChannelBadgeText, BuildExpiryText properties for the drag strip watermark binding - MainWindow.xaml: ALPHA/BETA badge + days-remaining countdown in drag strip, right-aligned, hidden on stable builds via BoolToVis on IsPreRelease - README: dev/main build status badges, alpha/stable download channel table - CHANGELOG: [0.5.1-beta] promoted, [Unreleased] documents 0.5.2 additions --- CHANGELOG.md | 4 ++ README.md | 11 ++-- .../UnitTests/LicenseValidationTests.cs | 8 +-- src/PortPane/BrandingInfo.cs | 19 +++++++ src/PortPane/Services/LicenseService.cs | 8 ++- src/PortPane/ViewModels/MainViewModel.cs | 19 +++++++ src/PortPane/Views/MainWindow.xaml | 52 ++++++++++++++----- 7 files changed, 98 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a3547..8892316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,10 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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 ### Changed diff --git a/README.md b/README.md index 2b0fc17..53baf23 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. diff --git a/src/PortPane.Tests/UnitTests/LicenseValidationTests.cs b/src/PortPane.Tests/UnitTests/LicenseValidationTests.cs index 8c74e30..ab3320b 100644 --- a/src/PortPane.Tests/UnitTests/LicenseValidationTests.cs +++ b/src/PortPane.Tests/UnitTests/LicenseValidationTests.cs @@ -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 ────────────────────────────────────────────────── @@ -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 diff --git a/src/PortPane/BrandingInfo.cs b/src/PortPane/BrandingInfo.cs index 39b692e..8e8c7c4 100644 --- a/src/PortPane/BrandingInfo.cs +++ b/src/PortPane/BrandingInfo.cs @@ -37,6 +37,25 @@ public static string FullVersion return $"{Version}-{ChannelInfo.VersionSuffix}"; } } + /// + /// 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). + /// + public static int? DaysRemaining + { + get + { + if (ChannelInfo.BuildExpiryDays <= 0 || string.IsNullOrEmpty(BuildDate)) + return null; + if (!DateTimeOffset.TryParse(BuildDate, null, + 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"; diff --git a/src/PortPane/Services/LicenseService.cs b/src/PortPane/Services/LicenseService.cs index da64560..05d67ba 100644 --- a/src/PortPane/Services/LicenseService.cs +++ b/src/PortPane/Services/LicenseService.cs @@ -86,12 +86,16 @@ public sealed class LicenseService : ILicenseService private static readonly string LicenseFilePath = LicensePath(); private static readonly string HashFilePath = LicensePath() + ".sha256"; + private readonly bool _unlockForTesting; private LicenseInfo _current = FreeTier(); public LicenseInfo Current => _current; - public LicenseService() + public LicenseService() : this(ChannelInfo.UnlockAllForTesting) { } + + internal LicenseService(bool unlockForTesting) { + _unlockForTesting = unlockForTesting; _current = LoadAndValidate(); } @@ -196,7 +200,7 @@ private static LicenseInfo FreeTier() private LicenseInfo LoadAndValidate() { - if (ChannelInfo.UnlockAllForTesting) + if (_unlockForTesting) return new LicenseInfo(LicenseTier.Personal, "Alpha Tester", null, "personal", null, IsValid: true); if (!File.Exists(LicenseFilePath)) return FreeTier(); diff --git a/src/PortPane/ViewModels/MainViewModel.cs b/src/PortPane/ViewModels/MainViewModel.cs index 87224fb..92f19e2 100644 --- a/src/PortPane/ViewModels/MainViewModel.cs +++ b/src/PortPane/ViewModels/MainViewModel.cs @@ -88,6 +88,25 @@ public string Title } } + // ── Channel / build info (alpha/beta only) ──────────────────────────────── + + public bool IsPreRelease => ChannelInfo.Channel != ReleaseChannel.Stable; + public string ChannelBadgeText => ChannelInfo.Channel == ReleaseChannel.Alpha ? "ALPHA" : "BETA"; + public string BuildExpiryText + { + get + { + int? days = BrandingInfo.DaysRemaining; + if (days == null) return string.Empty; + return days switch + { + 0 => "Expires today", + 1 => "1 day remaining", + _ => $"{days} days remaining" + }; + } + } + // Commands public ICommand RefreshAllCommand { get; } public ICommand ToggleChromeCommand { get; } diff --git a/src/PortPane/Views/MainWindow.xaml b/src/PortPane/Views/MainWindow.xaml index 5ed2a4d..8d92e60 100644 --- a/src/PortPane/Views/MainWindow.xaml +++ b/src/PortPane/Views/MainWindow.xaml @@ -40,20 +40,44 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + From 0e22b157f073911da0e0788b30f9194bf7aadaa7 Mon Sep 17 00:00:00 2001 From: Mark McDow Date: Mon, 30 Mar 2026 22:19:44 -0400 Subject: [PATCH 3/4] Fix chrome menu disappearing after 5 seconds (closes #8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 5-second DispatcherTimer auto-hide with a focus/hover model: - Mouse enters window → chrome visible (hover, unpinned) - Left-click anywhere → chrome pinned (stays until window loses focus) - Alt / Alt+letter → chrome pinned so WPF keyboard menu navigation works - Window loses focus (Deactivated) → chrome hidden, pin cleared - Mouse leaves without clicking → chrome hidden if not pinned Removes DispatcherTimer and using System.Windows.Threading entirely. No impact on hardware refresh — audio and COM port updates are WMI event-driven and have no dependency on the chrome visibility timer. --- CHANGELOG.md | 7 ++++ src/PortPane/Views/MainWindow.xaml | 5 ++- src/PortPane/Views/MainWindow.xaml.cs | 54 ++++++++++++++++++--------- 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8892316..3521cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,13 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 diff --git a/src/PortPane/Views/MainWindow.xaml b/src/PortPane/Views/MainWindow.xaml index 8d92e60..b979fbc 100644 --- a/src/PortPane/Views/MainWindow.xaml +++ b/src/PortPane/Views/MainWindow.xaml @@ -14,7 +14,10 @@ MinHeight="200" Topmost="{Binding IsAlwaysOnTop}" d:DataContext="{d:DesignInstance Type=vm:MainViewModel}" - MouseLeftButtonDown="Window_MouseLeftButtonDown"> + MouseLeftButtonDown="Window_MouseLeftButtonDown" + MouseEnter="Window_MouseEnter" + MouseLeave="Window_MouseLeave" + Deactivated="Window_Deactivated"> diff --git a/src/PortPane/Views/MainWindow.xaml.cs b/src/PortPane/Views/MainWindow.xaml.cs index f9b2d16..550d21c 100644 --- a/src/PortPane/Views/MainWindow.xaml.cs +++ b/src/PortPane/Views/MainWindow.xaml.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using System.Windows; using System.Windows.Input; -using System.Windows.Threading; using Microsoft.Extensions.DependencyInjection; using PortPane.Services; using PortPane.ViewModels; @@ -13,7 +12,7 @@ public partial class MainWindow : Window { private readonly MainViewModel _vm; private readonly ISettingsService _settings; - private readonly DispatcherTimer _chromehideTimer; + private bool _chromePinned; public MainWindow(MainViewModel viewModel, ISettingsService settings) { @@ -22,16 +21,6 @@ public MainWindow(MainViewModel viewModel, ISettingsService settings) _vm = viewModel; _settings = settings; - _chromehideTimer = new DispatcherTimer - { - Interval = TimeSpan.FromSeconds(5) - }; - _chromehideTimer.Tick += (_, _) => - { - _vm.IsChromeVisible = false; - _chromehideTimer.Stop(); - }; - // Restore window position var pos = settings.Current.WindowPosition; Left = pos.X; @@ -50,17 +39,46 @@ private void DragStrip_MouseDown(object sender, MouseButtonEventArgs e) } // ── Chrome reveal/hide ──────────────────────────────────────────────────── + // Hover : mouse enters window → show chrome (unpinned) + // Click : any left-click → pin chrome (stays until window loses focus) + // Alt : Alt / Alt+letter → pin chrome so keyboard menu navigation works + // Blur : window deactivated → hide chrome and clear pin + // Leave : mouse exits window without a click → hide chrome if not pinned - private void Window_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + private void ShowChrome() => _vm.IsChromeVisible = true; + + private void PinChrome() { - RevealChrome(); + _chromePinned = true; + _vm.IsChromeVisible = true; } - private void RevealChrome() + private void HideChrome() { - _vm.IsChromeVisible = true; - _chromehideTimer.Stop(); - _chromehideTimer.Start(); + _chromePinned = false; + _vm.IsChromeVisible = false; + } + + private void Window_MouseEnter(object sender, System.Windows.Input.MouseEventArgs e) + => ShowChrome(); + + private void Window_MouseLeave(object sender, System.Windows.Input.MouseEventArgs e) + { + if (!_chromePinned) + _vm.IsChromeVisible = false; + } + + private void Window_Deactivated(object sender, EventArgs e) + => HideChrome(); + + private void Window_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + => PinChrome(); + + protected override void OnPreviewKeyDown(KeyEventArgs e) + { + if (e.Key == Key.System) + PinChrome(); + base.OnPreviewKeyDown(e); } // ── Window control buttons ──────────────────────────────────────────────── From e599162936d836c1d6b286bc9e531e5c0eb5ff10 Mon Sep 17 00:00:00 2001 From: Mark McDow Date: Mon, 30 Mar 2026 22:27:31 -0400 Subject: [PATCH 4/4] Fix markdown lint: table separator spacing in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 53baf23..8ed7246 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Device Manager. ## Download | 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. |