Skip to content

Commit 0de03aa

Browse files
committed
feat: enhance update checker with startup delay and improved error handling
1 parent 906e0f0 commit 0de03aa

2 files changed

Lines changed: 139 additions & 17 deletions

File tree

.github/workflows/release.yml

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@ jobs:
362362
uses: softprops/action-gh-release@v2
363363
if: startsWith(github.ref, 'refs/tags/')
364364
with:
365+
generate_release_notes: true
365366
files: |
366367
CopyPaste-${{ steps.get_version.outputs.VERSION }}-${{ matrix.arch }}-setup.exe
367368
env:
@@ -506,14 +507,15 @@ jobs:
506507
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
507508

508509
# ─── Microsoft Store Submission ──────────────────────────────────────────
509-
# DISABLED: First Store submission must be done manually via Partner Center.
510-
# To re-enable: change the 'if' condition below to:
511-
# if: startsWith(github.ref, 'refs/tags/')
510+
# Automatically submits MSIX packages to the Microsoft Store.
511+
# Only runs on stable version tags (no pre-release).
512+
# Requires these GitHub secrets: STORE_TENANT_ID, STORE_CLIENT_ID, STORE_CLIENT_SECRET
513+
# And this GitHub variable: STORE_APP_ID
512514
store-publish:
513515
runs-on: windows-latest
514516
needs: store
515517
timeout-minutes: 15
516-
if: false # Disabled until first manual Store submission is approved
518+
if: false #startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-')
517519

518520
env:
519521
STORE_TENANT_ID: ${{ secrets.STORE_TENANT_ID }}
@@ -545,6 +547,57 @@ jobs:
545547
echo "HAS_CREDS=true" >> $env:GITHUB_OUTPUT
546548
}
547549
550+
- name: Extract version from tag
551+
if: steps.check_creds.outputs.HAS_CREDS == 'true'
552+
id: get_version
553+
shell: pwsh
554+
run: |
555+
if ($env:GITHUB_REF -match 'refs/tags/v(.+)') {
556+
$version = $matches[1]
557+
} else {
558+
$version = "1.0.0"
559+
}
560+
echo "VERSION=$version" >> $env:GITHUB_OUTPUT
561+
562+
- name: Get release notes from GitHub Release
563+
if: steps.check_creds.outputs.HAS_CREDS == 'true'
564+
id: release_notes
565+
shell: pwsh
566+
env:
567+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
568+
run: |
569+
$tag = "v${{ steps.get_version.outputs.VERSION }}"
570+
$headers = @{
571+
"Authorization" = "token $env:GH_TOKEN"
572+
"Accept" = "application/vnd.github.v3+json"
573+
}
574+
575+
try {
576+
$release = Invoke-RestMethod `
577+
-Uri "https://api.github.com/repos/${{ github.repository }}/releases/tags/$tag" `
578+
-Headers $headers
579+
580+
$body = $release.body
581+
if ([string]::IsNullOrWhiteSpace($body)) {
582+
$body = "Bug fixes and improvements."
583+
}
584+
585+
# Store listing 'What's new' supports max 1500 characters
586+
if ($body.Length -gt 1500) {
587+
$body = $body.Substring(0, 1497) + "..."
588+
}
589+
590+
# Write multiline output using heredoc delimiter (GitHub Actions recommended)
591+
$delimiter = "RELEASE_NOTES_EOF"
592+
echo "NOTES<<$delimiter" >> $env:GITHUB_OUTPUT
593+
echo $body >> $env:GITHUB_OUTPUT
594+
echo $delimiter >> $env:GITHUB_OUTPUT
595+
Write-Host "Release notes extracted ($($body.Length) chars)"
596+
} catch {
597+
Write-Warning "Failed to fetch release notes: $($_.Exception.Message)"
598+
echo "NOTES=Bug fixes and improvements." >> $env:GITHUB_OUTPUT
599+
}
600+
548601
- name: Download MSIX packages
549602
if: steps.check_creds.outputs.HAS_CREDS == 'true'
550603
uses: actions/download-artifact@v4
@@ -573,15 +626,52 @@ jobs:
573626
--clientId $env:STORE_CLIENT_ID `
574627
--clientSecret $env:STORE_CLIENT_SECRET
575628
576-
- name: Submit to Microsoft Store
629+
- name: Upload packages (without committing)
577630
if: steps.check_creds.outputs.HAS_CREDS == 'true'
578631
shell: pwsh
579632
run: |
580633
$packages = Get-ChildItem -Path packages -Include "*.msixupload","*.msix" -Recurse
581-
Write-Host "Submitting $($packages.Count) package(s) to Microsoft Store..."
634+
Write-Host "Uploading $($packages.Count) package(s) to Microsoft Store..."
582635
$packages | ForEach-Object {
583636
Write-Host " - $($_.Name) ($([math]::Round($_.Length / 1MB, 2)) MB)"
584637
}
585638
586-
$packagePaths = $packages | Select-Object -ExpandProperty FullName
587-
msstore publish --inputDirectory packages --id ${{ vars.STORE_APP_ID }}
639+
if ($packages.Count -eq 0) {
640+
Write-Error "No MSIX packages found in packages directory"
641+
exit 1
642+
}
643+
644+
# msstore publish requires a path/URL as first argument
645+
$firstPackage = $packages[0].FullName
646+
msstore publish $firstPackage --inputDirectory packages --appId ${{ vars.STORE_APP_ID }} --noCommit --verbose
647+
648+
- name: Update Store listing with release notes
649+
if: steps.check_creds.outputs.HAS_CREDS == 'true'
650+
shell: pwsh
651+
env:
652+
RELEASE_NOTES: ${{ steps.release_notes.outputs.NOTES }}
653+
run: |
654+
$appId = "${{ vars.STORE_APP_ID }}"
655+
$notes = $env:RELEASE_NOTES
656+
657+
if ([string]::IsNullOrWhiteSpace($notes)) {
658+
$notes = "Bug fixes and improvements."
659+
}
660+
661+
# Escape special characters for JSON
662+
$escapedNotes = $notes -replace '\\', '\\\\' -replace '"', '\"' -replace "`r", '' -replace "`n", '\n'
663+
664+
# Construct metadata JSON directly (PascalCase required for packaged apps)
665+
$metadataJson = '{"Listings":{"en-us":{"BaseListing":{"ReleaseNotes":"' + $escapedNotes + '"}}}}'
666+
667+
Write-Host "Updating Store listing with release notes ($($notes.Length) chars)..."
668+
msstore submission updateMetadata $appId $metadataJson --skipInitialPolling
669+
Write-Host "Store listing metadata updated"
670+
671+
- name: Commit and publish submission
672+
if: steps.check_creds.outputs.HAS_CREDS == 'true'
673+
shell: pwsh
674+
run: |
675+
Write-Host "Committing Store submission..."
676+
msstore submission publish ${{ vars.STORE_APP_ID }}
677+
Write-Host "Store submission committed - awaiting certification"

CopyPaste.Core/UpdateChecker.cs

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,30 +27,43 @@ public sealed class UpdateChecker : IDisposable
2727
private static readonly Uri _gitHubReleasesUri = new(_gitHubReleasesUrl);
2828
private const string _dismissedVersionFileName = "dismissed_update.txt";
2929

30-
private static readonly TimeSpan _checkInterval = TimeSpan.FromHours(12);
30+
private static readonly TimeSpan _startupDelay = TimeSpan.FromMinutes(1);
3131
private static readonly TimeSpan _httpTimeout = TimeSpan.FromSeconds(15);
3232

3333
private readonly HttpClient _httpClient;
34-
private readonly Timer _timer;
34+
private readonly CancellationTokenSource _cts = new();
3535
private bool _isDisposed;
3636

3737
/// <summary>
3838
/// Raised when a new version is available.
3939
/// </summary>
4040
public event EventHandler<UpdateAvailableEventArgs>? OnUpdateAvailable;
4141

42+
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types",
43+
Justification = "Fire-and-forget task must never throw — unobserved exception would crash the process")]
4244
public UpdateChecker()
4345
{
4446
_httpClient = new HttpClient { Timeout = _httpTimeout };
4547
_httpClient.DefaultRequestHeaders.Add("User-Agent", "CopyPaste-UpdateChecker");
4648
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
4749

48-
_timer = new Timer(
49-
_ => CheckForUpdateAsync().ConfigureAwait(false),
50-
null,
51-
TimeSpan.FromSeconds(30), // First check 30s after startup
52-
_checkInterval
53-
);
50+
// Single check after startup delay — no recurring timer needed
51+
_ = Task.Run(async () =>
52+
{
53+
try
54+
{
55+
await Task.Delay(_startupDelay, _cts.Token).ConfigureAwait(false);
56+
await CheckForUpdateAsync().ConfigureAwait(false);
57+
}
58+
catch (OperationCanceledException)
59+
{
60+
// App closed before the check ran — expected
61+
}
62+
catch (Exception ex)
63+
{
64+
AppLogger.Warn($"Update check unexpected error: {ex.Message}");
65+
}
66+
});
5467
}
5568

5669
/// <summary>
@@ -75,10 +88,14 @@ public static string GetCurrentVersion()
7588
return version != null ? $"{version.Major}.{version.Minor}.{version.Build}" : "0.0.0";
7689
}
7790

91+
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types",
92+
Justification = "Update check is non-critical — unexpected failures must be logged, not crash the app")]
7893
internal async Task CheckForUpdateAsync()
7994
{
8095
if (_isDisposed) return;
8196

97+
AppLogger.Info("Update check started...");
98+
8299
try
83100
{
84101
using var response = await _httpClient.GetAsync(_gitHubReleasesUri).ConfigureAwait(false);
@@ -98,6 +115,12 @@ internal async Task CheckForUpdateAsync()
98115
return;
99116
}
100117

118+
if (release.Prerelease)
119+
{
120+
AppLogger.Info("Update check: latest release is a pre-release, skipping");
121+
return;
122+
}
123+
101124
var latestVersion = release.TagName.TrimStart('v', 'V');
102125
var currentVersion = GetCurrentVersion();
103126

@@ -130,6 +153,14 @@ internal async Task CheckForUpdateAsync()
130153
{
131154
AppLogger.Warn($"Update check failed (parse): {ex.Message}");
132155
}
156+
catch (ObjectDisposedException)
157+
{
158+
// Expected during shutdown — timer may fire while HttpClient is disposing
159+
}
160+
catch (Exception ex)
161+
{
162+
AppLogger.Warn($"Update check failed: {ex.Message}");
163+
}
133164
}
134165

135166
/// <summary>
@@ -230,7 +261,8 @@ public void Dispose()
230261
{
231262
if (_isDisposed) return;
232263
_isDisposed = true;
233-
_timer.Dispose();
264+
_cts.Cancel();
265+
_cts.Dispose();
234266
_httpClient.Dispose();
235267
}
236268
}

0 commit comments

Comments
 (0)