Skip to content

Commit 8720b7e

Browse files
Geeven SinghCopilot
andcommitted
Phase 2.3: Auto-update banner UI + IUpdateService Check/Download/Apply split
Refactors the Phase 2.1 IUpdateService surface from a single 'check + queue silently' method into three: CheckAsync (returns UpdateCheckResult), DownloadAsync, ApplyOnNextLaunchAsync. The split is what UpdateNotificationViewModel needs to sequence the banner UX (NotifyOnly mode: check at startup; user clicks Install to fire download + apply; Automatic mode: chain all three in the background and show the banner once the apply is queued). UpdateCheckResult is a small inert DTO that lets the view-model pass detection state across method calls without dragging Velopack types into ViewModels. The OpaqueHandle property carries the Velopack UpdateInfo for the production adapter; NullUpdateService just returns the NoUpdateAvailable singleton from CheckAsync and no-ops the others. UpdateNotificationViewModel owns the banner state machine. Constructed once per app lifetime (in App.OnStartup) with the IUpdateService chosen via VelopackUpdateService.TryCreateForInstalled(includePreReleases) and a getMode callback that reads AppSettings.AutoUpdate live (so flips in the Settings dialog take effect on the next StartAsync without recreating the VM). Wires through the IncludePreReleases setting that Phase 2.2 added but left inert — VelopackUpdateService now passes the flag to GithubSource's ctor. MainWindow.xaml gains a single-row Border at the top, above the existing ContentControl, bound via MainWindow.AttachUpdateNotification (called from App.OnStartup before window.Show). Stays Visibility=Collapsed until IsBannerVisible flips. Shows status text + an Install button (NotifyOnly only) + a Dismiss button. The existing loading-overlay Grid gains Grid.RowSpan=2 so it still veils the whole window during context switches. Behavior matrix after this commit: - AutoUpdate=Disabled: still short-circuits at the App level (no Check fires, banner stays hidden) - AutoUpdate=NotifyOnly: check fires; if available, banner shows with Install button; user-initiated download + apply-on-next-launch - AutoUpdate=Automatic: check + download + queue all fire silently; banner appears post-queue so user knows v X is pending Three pieces deliberately deferred to a Phase 2.4 follow-up: periodic re-check timer (UpdateCheckCadence is still inert; only startup check fires), persistent 'skip this version' across launches (Dismiss is session-scoped only), and friendlier enum value converters for the Settings ComboBoxes (still show raw enum names like StartupOnly/EverySixHours). Tests: 1381 passing (+10 new — 8 UpdateNotificationViewModel state-transition tests over a fake IUpdateService, 2 reshuffled NullUpdateService tests covering the new method surface), 1 skipped. 0 warnings, 0 errors in dotnet build -c Release. CHANGELOG [Unreleased] documents the banner and the now-wired IncludePreReleases. AI-Local-Session: 4519f6b6-393a-4476-8efa-410e5396c3a9 AI-Cloud-Session: 72f9e474-60ab-42c2-b2a0-28fee827cbbb Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9533088 commit 8720b7e

11 files changed

Lines changed: 632 additions & 92 deletions

CHANGELOG.md

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,32 @@ body. Keep section headings exact and write notes in Markdown.
1414

1515
### Added
1616

17+
- **Auto-update banner at the top of the main window.** Replaces
18+
the silent placeholder from the previous Phase 2.1 / 2.2
19+
iterations:
20+
- In `Automatic` mode, the banner appears once a downloaded
21+
update is queued ("Update vX.Y.Z ready — will install on next
22+
launch") so users know what is happening.
23+
- In `NotifyOnly` mode (the default), the banner appears as soon
24+
as an update is detected, with an **Install** button that
25+
triggers the download + apply-on-next-launch sequence.
26+
- Either mode's banner has a **Dismiss** button that hides it
27+
for the rest of the session. (A persistent "skip this
28+
version" gesture is planned as a follow-up.)
1729
- **Settings → Updates section.** Three new settings control how
1830
DiffViewer handles available updates from GitHub Releases. The
1931
settings are inert on portable / from-source builds (those have
2032
never checked for updates) and become live when the upcoming
2133
Velopack-installed channel ships:
2234
- **Auto-update behavior**`NotifyOnly` (default for new and
2335
migrated installs), `Automatic`, or `Disabled`. `NotifyOnly`
24-
is the safe default: silent auto-updates are opt-in. The
25-
banner UX that distinguishes `NotifyOnly` from `Disabled` lands
26-
in a follow-up.
36+
is the safe default: silent auto-updates are opt-in.
2737
- **Check frequency**`StartupOnly` / `Hourly` / `EverySixHours`
2838
/ `Daily` (default) / `Weekly`. Currently only the startup
29-
check fires; the periodic timer that honors this setting lands
30-
in a follow-up.
31-
- **Include pre-release versions** — off by default. Currently
32-
ignored (the update check is hardcoded stable-only); will be
33-
wired through to the Velopack source in a follow-up.
39+
check fires; the periodic timer that honors this setting is
40+
a planned follow-up.
41+
- **Include pre-release versions** — off by default. Now wired
42+
through to the underlying GitHub release source.
3443
- `settings.json` schema bump v6 → v7. The migration is a no-op:
3544
all three new fields default to safe values, so pre-v7 files load
3645
with the same effective behaviour they had before.
Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Threading;
22
using System.Threading.Tasks;
3+
using DiffViewer.Models;
34
using DiffViewer.Services;
45
using FluentAssertions;
56
using Xunit;
@@ -9,28 +10,57 @@ namespace DiffViewer.Tests.Services;
910
public sealed class NullUpdateServiceTests
1011
{
1112
[Fact]
12-
public async Task CheckAndQueueUpdateAsync_Completes_WithoutThrowing()
13+
public async Task CheckAsync_ReturnsNoUpdateAvailableSingleton()
1314
{
1415
var sut = new NullUpdateService();
1516

16-
var act = async () => await sut.CheckAndQueueUpdateAsync(CancellationToken.None);
17+
var result = await sut.CheckAsync(CancellationToken.None);
18+
19+
result.Should().BeSameAs(UpdateCheckResult.NoUpdateAvailable);
20+
result.IsAvailable.Should().BeFalse();
21+
}
22+
23+
[Fact]
24+
public async Task DownloadAsync_NoOps_ForNoUpdateAvailable()
25+
{
26+
var sut = new NullUpdateService();
27+
28+
var act = async () => await sut.DownloadAsync(
29+
UpdateCheckResult.NoUpdateAvailable,
30+
CancellationToken.None);
31+
32+
await act.Should().NotThrowAsync();
33+
}
34+
35+
[Fact]
36+
public async Task ApplyOnNextLaunchAsync_NoOps_ForNoUpdateAvailable()
37+
{
38+
var sut = new NullUpdateService();
39+
40+
var act = async () => await sut.ApplyOnNextLaunchAsync(
41+
UpdateCheckResult.NoUpdateAvailable,
42+
CancellationToken.None);
1743

1844
await act.Should().NotThrowAsync();
1945
}
2046

2147
[Fact]
22-
public async Task CheckAndQueueUpdateAsync_HonorsAlreadyCancelledToken_AsNoOp()
48+
public async Task AllMethods_HonorAlreadyCancelledToken_AsNoOp()
2349
{
2450
// The no-op is deliberately tolerant of an already-cancelled
2551
// token — there's no work to abandon and surfacing an OCE
26-
// would be noise. Phase 2.3 may revisit this when real
52+
// would be noise. Phase 2.4 may revisit this when real
2753
// implementations start accepting and obeying the token.
2854
var sut = new NullUpdateService();
2955
using var cts = new CancellationTokenSource();
3056
await cts.CancelAsync();
3157

32-
var act = async () => await sut.CheckAndQueueUpdateAsync(cts.Token);
58+
var check = async () => await sut.CheckAsync(cts.Token);
59+
var download = async () => await sut.DownloadAsync(UpdateCheckResult.NoUpdateAvailable, cts.Token);
60+
var apply = async () => await sut.ApplyOnNextLaunchAsync(UpdateCheckResult.NoUpdateAvailable, cts.Token);
3361

34-
await act.Should().NotThrowAsync();
62+
await check.Should().NotThrowAsync();
63+
await download.Should().NotThrowAsync();
64+
await apply.Should().NotThrowAsync();
3565
}
3666
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
using System.Collections.Generic;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using DiffViewer.Models;
5+
using DiffViewer.Services;
6+
using DiffViewer.ViewModels;
7+
using FluentAssertions;
8+
using Xunit;
9+
10+
namespace DiffViewer.Tests.ViewModels;
11+
12+
public sealed class UpdateNotificationViewModelTests
13+
{
14+
[Fact]
15+
public async Task StartAsync_WhenDisabled_DoesNotCheck()
16+
{
17+
var fake = new FakeUpdateService();
18+
var sut = new UpdateNotificationViewModel(fake, getMode: () => AutoUpdateMode.Disabled);
19+
20+
await sut.StartAsync(CancellationToken.None);
21+
22+
fake.CheckCalls.Should().Be(0);
23+
sut.IsBannerVisible.Should().BeFalse();
24+
}
25+
26+
[Fact]
27+
public async Task StartAsync_WhenAutomatic_ChecksDownloadsAndQueuesSilently_ThenShowsBanner()
28+
{
29+
var available = NewAvailable("1.5.0");
30+
var fake = new FakeUpdateService { CheckResult = available };
31+
var sut = new UpdateNotificationViewModel(fake, getMode: () => AutoUpdateMode.Automatic);
32+
33+
await sut.StartAsync(CancellationToken.None);
34+
35+
fake.CheckCalls.Should().Be(1);
36+
fake.DownloadCalls.Should().ContainSingle().Which.Should().BeSameAs(available);
37+
fake.ApplyOnNextLaunchCalls.Should().ContainSingle().Which.Should().BeSameAs(available);
38+
sut.IsBannerVisible.Should().BeTrue();
39+
sut.ShowInstallButton.Should().BeFalse();
40+
sut.StatusText.Should().Contain("1.5.0").And.Contain("next launch");
41+
}
42+
43+
[Fact]
44+
public async Task StartAsync_WhenNotifyOnly_ChecksOnly_ThenShowsBannerWithInstallButton()
45+
{
46+
var available = NewAvailable("2.0.0");
47+
var fake = new FakeUpdateService { CheckResult = available };
48+
var sut = new UpdateNotificationViewModel(fake, getMode: () => AutoUpdateMode.NotifyOnly);
49+
50+
await sut.StartAsync(CancellationToken.None);
51+
52+
fake.CheckCalls.Should().Be(1);
53+
fake.DownloadCalls.Should().BeEmpty();
54+
fake.ApplyOnNextLaunchCalls.Should().BeEmpty();
55+
sut.IsBannerVisible.Should().BeTrue();
56+
sut.ShowInstallButton.Should().BeTrue();
57+
sut.StatusText.Should().Contain("2.0.0").And.NotContain("next launch");
58+
}
59+
60+
[Fact]
61+
public async Task StartAsync_WhenNoUpdateAvailable_LeavesBannerHidden()
62+
{
63+
var fake = new FakeUpdateService { CheckResult = UpdateCheckResult.NoUpdateAvailable };
64+
var sut = new UpdateNotificationViewModel(fake, getMode: () => AutoUpdateMode.NotifyOnly);
65+
66+
await sut.StartAsync(CancellationToken.None);
67+
68+
fake.CheckCalls.Should().Be(1);
69+
fake.DownloadCalls.Should().BeEmpty();
70+
sut.IsBannerVisible.Should().BeFalse();
71+
sut.ShowInstallButton.Should().BeFalse();
72+
}
73+
74+
[Fact]
75+
public async Task Install_AfterNotifyOnlyCheck_DownloadsAndQueues_HidesInstallButton()
76+
{
77+
var available = NewAvailable("3.1.2");
78+
var fake = new FakeUpdateService { CheckResult = available };
79+
var sut = new UpdateNotificationViewModel(fake, getMode: () => AutoUpdateMode.NotifyOnly);
80+
await sut.StartAsync(CancellationToken.None);
81+
82+
await sut.InstallCommand.ExecuteAsync(null);
83+
84+
fake.DownloadCalls.Should().ContainSingle().Which.Should().BeSameAs(available);
85+
fake.ApplyOnNextLaunchCalls.Should().ContainSingle().Which.Should().BeSameAs(available);
86+
sut.ShowInstallButton.Should().BeFalse();
87+
sut.IsBannerVisible.Should().BeTrue();
88+
sut.StatusText.Should().Contain("3.1.2").And.Contain("next launch");
89+
}
90+
91+
[Fact]
92+
public async Task Install_WithNoPendingUpdate_NoOps()
93+
{
94+
// Reach Install without StartAsync having found anything: e.g.
95+
// a future code path that wires Install directly to the
96+
// command. The VM should defend against the missing _pending
97+
// by doing nothing rather than dereferencing null.
98+
var fake = new FakeUpdateService();
99+
var sut = new UpdateNotificationViewModel(fake, getMode: () => AutoUpdateMode.NotifyOnly);
100+
101+
await sut.InstallCommand.ExecuteAsync(null);
102+
103+
fake.DownloadCalls.Should().BeEmpty();
104+
fake.ApplyOnNextLaunchCalls.Should().BeEmpty();
105+
}
106+
107+
[Fact]
108+
public async Task Dismiss_HidesBanner_DoesNotCancelQueuedApply()
109+
{
110+
// The "Dismiss" UX is session-scoped — it hides the banner but
111+
// doesn't roll back the queued ApplyOnNextLaunch from the
112+
// Automatic-mode flow. The update still installs on next exit;
113+
// the user just doesn't keep seeing the notification.
114+
var available = NewAvailable("1.0.0");
115+
var fake = new FakeUpdateService { CheckResult = available };
116+
var sut = new UpdateNotificationViewModel(fake, getMode: () => AutoUpdateMode.Automatic);
117+
await sut.StartAsync(CancellationToken.None);
118+
sut.IsBannerVisible.Should().BeTrue();
119+
120+
sut.DismissCommand.Execute(null);
121+
122+
sut.IsBannerVisible.Should().BeFalse();
123+
fake.ApplyOnNextLaunchCalls.Should().HaveCount(1); // unchanged
124+
}
125+
126+
[Fact]
127+
public async Task GetMode_ReadAtEachStart_AllowsLiveModeChanges()
128+
{
129+
// The constructor takes a Func<AutoUpdateMode> rather than a
130+
// snapshot so changes to AppSettings.AutoUpdate take effect on
131+
// the next StartAsync (without recreating the view-model).
132+
// Demonstrates the contract for callers (and prevents a
133+
// future refactor from accidentally capturing a snapshot).
134+
var mode = AutoUpdateMode.Disabled;
135+
var fake = new FakeUpdateService { CheckResult = NewAvailable("9.9.9") };
136+
var sut = new UpdateNotificationViewModel(fake, getMode: () => mode);
137+
138+
await sut.StartAsync(CancellationToken.None);
139+
fake.CheckCalls.Should().Be(0);
140+
141+
mode = AutoUpdateMode.NotifyOnly;
142+
await sut.StartAsync(CancellationToken.None);
143+
fake.CheckCalls.Should().Be(1);
144+
}
145+
146+
private static UpdateCheckResult NewAvailable(string version) =>
147+
new() { IsAvailable = true, Version = version, OpaqueHandle = new object() };
148+
149+
private sealed class FakeUpdateService : IUpdateService
150+
{
151+
public UpdateCheckResult CheckResult { get; set; } = UpdateCheckResult.NoUpdateAvailable;
152+
public int CheckCalls { get; private set; }
153+
public List<UpdateCheckResult> DownloadCalls { get; } = new();
154+
public List<UpdateCheckResult> ApplyOnNextLaunchCalls { get; } = new();
155+
156+
public Task<UpdateCheckResult> CheckAsync(CancellationToken ct)
157+
{
158+
CheckCalls++;
159+
return Task.FromResult(CheckResult);
160+
}
161+
162+
public Task DownloadAsync(UpdateCheckResult update, CancellationToken ct)
163+
{
164+
DownloadCalls.Add(update);
165+
return Task.CompletedTask;
166+
}
167+
168+
public Task ApplyOnNextLaunchAsync(UpdateCheckResult update, CancellationToken ct)
169+
{
170+
ApplyOnNextLaunchCalls.Add(update);
171+
return Task.CompletedTask;
172+
}
173+
}
174+
}

DiffViewer/App.xaml.cs

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using DiffViewer.Rendering;
99
using DiffViewer.Services;
1010
using DiffViewer.Utility;
11+
using DiffViewer.ViewModels;
1112
using ICSharpCode.AvalonEdit.Highlighting;
1213
using Velopack;
1314

@@ -18,6 +19,7 @@ public partial class App : Application
1819
private CancellationTokenSource? _shutdownCts;
1920
private MainWindowCoordinator? _coordinator;
2021
private HttpClient? _httpClient;
22+
private UpdateNotificationViewModel? _updateNotification;
2123

2224
// WPF's auto-generated Program.Main doesn't give Velopack a seam
2325
// to intercept its install/uninstall/restart args before WPF spins
@@ -155,6 +157,24 @@ protected override async void OnStartup(StartupEventArgs e)
155157
// contract that the inner content templates depend on.
156158
window.Tag = _coordinator;
157159
_coordinator.CurrentChanged += (_, _) => window.DataContext = _coordinator.Current;
160+
161+
// Auto-update notification view-model (Phase 2.3). Constructed
162+
// once per app lifetime — updates are app-wide, not per-context.
163+
// Wired to the right IUpdateService at construction time based on
164+
// whether we're running from a Velopack-installed location, with
165+
// the user's IncludePreReleases preference flowing through to
166+
// the underlying GithubSource. The banner's visibility is driven
167+
// entirely by the view-model's state machine, started below
168+
// after window.Show().
169+
var prereleases = settingsService.Current.IncludePreReleases;
170+
IUpdateService updateService =
171+
VelopackUpdateService.TryCreateForInstalled(prereleases)
172+
?? (IUpdateService)new NullUpdateService();
173+
_updateNotification = new UpdateNotificationViewModel(
174+
updateService,
175+
getMode: () => settingsService.Current.AutoUpdate);
176+
window.AttachUpdateNotification(_updateNotification);
177+
158178
window.Closed += async (_, _) =>
159179
{
160180
if (_coordinator is not null) await _coordinator.DisposeCurrentAsync();
@@ -170,25 +190,15 @@ protected override async void OnStartup(StartupEventArgs e)
170190

171191
window.Show();
172192

173-
// Auto-update lifecycle (Phase 2.1 + 2.2). Fires a single
174-
// background check at startup IF the user has opted in via
175-
// AppSettings.AutoUpdate; Phase 2.2 defaults to NotifyOnly so
176-
// no check fires for new installs until either the user flips
177-
// the setting or Phase 2.3 redefines NotifyOnly to mean
178-
// "check + show banner, don't apply silently." For now,
179-
// anything other than AutoUpdateMode.Automatic short-circuits
180-
// here. Velopack-installed copies get the real service;
181-
// portable / dev launches get a no-op. Phase 2.3 will add the
182-
// banner UI; Phase 3 will start producing Velopack-installed
183-
// copies in the wild.
184-
if (settingsService.Current.AutoUpdate == AutoUpdateMode.Automatic)
185-
{
186-
IUpdateService updateService =
187-
VelopackUpdateService.TryCreateForInstalled()
188-
?? (IUpdateService)new NullUpdateService();
189-
var updateCt = _shutdownCts.Token;
190-
_ = Task.Run(() => updateService.CheckAndQueueUpdateAsync(updateCt));
191-
}
193+
// Auto-update lifecycle (Phase 2.1 -> 2.3). The
194+
// UpdateNotificationViewModel state machine decides what to do
195+
// based on the user's AutoUpdate setting (Disabled -> no-op;
196+
// NotifyOnly -> check + show banner with Install button;
197+
// Automatic -> check + download + queue + show banner). Fire-
198+
// and-forget on a background task; the VM hops back to the UI
199+
// thread between awaits via ConfigureAwait(true).
200+
var updateCt = _shutdownCts.Token;
201+
_ = Task.Run(() => _updateNotification!.StartAsync(updateCt));
192202
}
193203

194204
protected override void OnExit(ExitEventArgs e)

0 commit comments

Comments
 (0)