Skip to content

Commit 07ca84b

Browse files
Geeven SinghCopilot
andcommitted
Phase 5: BrowserNotifyUpdateService gives portable users update notifications
Replaces the NullUpdateService fallback for portable launches with a real GitHub-Releases-REST-API-backed adapter. Now every DiffViewer instance -- Velopack-installed AND portable -- surfaces update notifications via the Phase 2.3 banner; the only difference is what the Install button does (silent apply-on-exit for Velopack, opens browser to Releases page for portable). New IUpdateService.CanAutoApply property captures the silent-apply-supported difference between adapters. UpdateNotificationViewModel inspects the flag in its AutoUpdateMode.Automatic branch: when CanAutoApply is false the VM silently demotes to NotifyOnly behavior (show banner with Install button), avoiding the hostile UX of surprise-launching a browser tab on every startup. BrowserNotifyUpdateService hits GET /repos/geevensingh/DiffViewer/releases?per_page=10 with the required User-Agent header, filters drafts (always) and prereleases (unless IncludePreReleases is set), picks the highest-Version non-draft release, compares to the running assembly version. The URL into the per-release page travels via UpdateCheckResult.OpaqueHandle so Install opens directly at the new release rather than a generic /releases/latest. Best-effort: network failures, rate-limits, malformed JSON all degrade to NoUpdateAvailable. Browser launch is injectable for tests. VelopackUpdateService.CanAutoApply = true; NullUpdateService.CanAutoApply = false; BrowserNotifyUpdateService.CanAutoApply = false. NullUpdateService stays in the dispatch as a defensive fallback when HttpClient is null (theoretically impossible today but harmless to guard). Test coverage: 17 BrowserNotifyUpdateService tests (Check happy path, version comparison, draft/prerelease filtering, highest-version-not-most-recent picking, HTTP error handling, network exception handling, User-Agent enforcement, Apply launches browser, Apply with no-update no-ops, Download is no-op, TryParseVersion strips v prefix and SemVer suffixes, garbage versions return null). 1 NullUpdateService test (CanAutoApply false). 2 UpdateNotificationViewModel tests (Automatic + !CanAutoApply demotes to NotifyOnly; Install always fires Download/Apply regardless of CanAutoApply). Tests: 1428 passing (+25 net), 1 skipped. 0 warnings, 0 errors in dotnet build -c Release. CHANGELOG [Unreleased] documents the new portable-user notification path. 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 7d3f5f6 commit 07ca84b

10 files changed

Lines changed: 677 additions & 22 deletions

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,22 @@ body. Keep section headings exact and write notes in Markdown.
1212

1313
## [Unreleased]
1414

15+
### Added
16+
17+
- **Portable users now get update notifications too.** Previously the
18+
auto-update banner only fired for installed copies; portable users
19+
had to manually check the Releases page. A new `BrowserNotifyUpdateService`
20+
queries the GitHub Releases REST API directly and surfaces
21+
available updates via the same banner. Clicking **Install** opens
22+
the Releases page in the default browser so the user can download
23+
the latest `DiffViewer-Setup.exe` (recommended) or
24+
`DiffViewer-portable.exe`. Honors the same Settings → Updates
25+
controls (auto-update behavior, check frequency, include
26+
pre-releases, skip-this-version). In `Automatic` mode the banner
27+
silently demotes to `NotifyOnly` behavior — opening a browser tab
28+
unprompted on every startup would be hostile UX, so portable
29+
users always click Install themselves.
30+
1531
## [1.5.0] - 2026-05-30
1632

1733
### Added
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Net;
5+
using System.Net.Http;
6+
using System.Net.Http.Headers;
7+
using System.Text;
8+
using System.Text.Json;
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
using DiffViewer.Models;
12+
using DiffViewer.Services;
13+
using FluentAssertions;
14+
using Xunit;
15+
16+
namespace DiffViewer.Tests.Services;
17+
18+
public sealed class BrowserNotifyUpdateServiceTests
19+
{
20+
[Fact]
21+
public void CanAutoApply_IsFalse()
22+
{
23+
// The whole point of the service is that "apply" launches a
24+
// browser; that can never happen silently in Automatic mode.
25+
// The VM uses this flag to demote Automatic -> NotifyOnly for
26+
// the browser-notify case.
27+
var sut = NewService(currentVersion: new Version(1, 0, 0), responses: Array.Empty<FakeResponse>());
28+
29+
sut.CanAutoApply.Should().BeFalse();
30+
}
31+
32+
[Fact]
33+
public async Task CheckAsync_WhenNewerStableReleaseAvailable_ReturnsAvailable()
34+
{
35+
var sut = NewService(
36+
currentVersion: new Version(1, 4, 0),
37+
responses: new[]
38+
{
39+
ReleasesJson(new GhRelease("v1.5.0", "https://github.com/owner/repo/releases/tag/v1.5.0", Draft: false, Prerelease: false)),
40+
});
41+
42+
var result = await sut.CheckAsync(CancellationToken.None);
43+
44+
result.IsAvailable.Should().BeTrue();
45+
result.Version.Should().Be("1.5.0");
46+
result.OpaqueHandle.Should().Be("https://github.com/owner/repo/releases/tag/v1.5.0");
47+
}
48+
49+
[Fact]
50+
public async Task CheckAsync_WhenLatestEqualsCurrent_ReturnsNoUpdate()
51+
{
52+
var sut = NewService(
53+
currentVersion: new Version(1, 5, 0),
54+
responses: new[]
55+
{
56+
ReleasesJson(new GhRelease("v1.5.0", "https://example/r", Draft: false, Prerelease: false)),
57+
});
58+
59+
var result = await sut.CheckAsync(CancellationToken.None);
60+
61+
result.Should().BeSameAs(UpdateCheckResult.NoUpdateAvailable);
62+
}
63+
64+
[Fact]
65+
public async Task CheckAsync_DraftReleases_Ignored()
66+
{
67+
// A draft release should never surface to users even if its
68+
// version is newer.
69+
var sut = NewService(
70+
currentVersion: new Version(1, 4, 0),
71+
responses: new[]
72+
{
73+
ReleasesJson(new GhRelease("v1.5.0", "https://example/r1", Draft: true, Prerelease: false),
74+
new GhRelease("v1.4.0", "https://example/r2", Draft: false, Prerelease: false)),
75+
});
76+
77+
var result = await sut.CheckAsync(CancellationToken.None);
78+
79+
result.Should().BeSameAs(UpdateCheckResult.NoUpdateAvailable);
80+
}
81+
82+
[Fact]
83+
public async Task CheckAsync_PrereleaseIgnoredWhenIncludePreReleasesFalse()
84+
{
85+
var sut = NewService(
86+
currentVersion: new Version(1, 4, 0),
87+
includePreReleases: false,
88+
responses: new[]
89+
{
90+
ReleasesJson(new GhRelease("v1.5.0-rc1", "https://example/rc", Draft: false, Prerelease: true),
91+
new GhRelease("v1.4.0", "https://example/r2", Draft: false, Prerelease: false)),
92+
});
93+
94+
var result = await sut.CheckAsync(CancellationToken.None);
95+
96+
result.Should().BeSameAs(UpdateCheckResult.NoUpdateAvailable);
97+
}
98+
99+
[Fact]
100+
public async Task CheckAsync_PrereleaseConsideredWhenIncludePreReleasesTrue()
101+
{
102+
var sut = NewService(
103+
currentVersion: new Version(1, 4, 0),
104+
includePreReleases: true,
105+
responses: new[]
106+
{
107+
ReleasesJson(new GhRelease("v1.5.0-rc1", "https://example/rc", Draft: false, Prerelease: true)),
108+
});
109+
110+
var result = await sut.CheckAsync(CancellationToken.None);
111+
112+
result.IsAvailable.Should().BeTrue();
113+
result.Version.Should().Be("1.5.0");
114+
}
115+
116+
[Fact]
117+
public async Task CheckAsync_PicksHighestVersion_NotMostRecent()
118+
{
119+
// GitHub returns releases in created-at order; the API
120+
// /releases endpoint is reverse-chronological. The highest
121+
// VERSION should win, not the most recent. (Releases can be
122+
// backported / cut out-of-order.)
123+
var sut = NewService(
124+
currentVersion: new Version(1, 4, 0),
125+
responses: new[]
126+
{
127+
ReleasesJson(new GhRelease("v1.5.0", "https://example/v150", Draft: false, Prerelease: false),
128+
new GhRelease("v1.6.0", "https://example/v160", Draft: false, Prerelease: false),
129+
new GhRelease("v1.5.1", "https://example/v151", Draft: false, Prerelease: false)),
130+
});
131+
132+
var result = await sut.CheckAsync(CancellationToken.None);
133+
134+
result.IsAvailable.Should().BeTrue();
135+
result.Version.Should().Be("1.6.0");
136+
result.OpaqueHandle.Should().Be("https://example/v160");
137+
}
138+
139+
[Fact]
140+
public async Task CheckAsync_HttpErrorResponse_ReturnsNoUpdate()
141+
{
142+
var sut = NewService(
143+
currentVersion: new Version(1, 0, 0),
144+
responses: new[]
145+
{
146+
new FakeResponse(HttpStatusCode.ServiceUnavailable, "{}"),
147+
});
148+
149+
var result = await sut.CheckAsync(CancellationToken.None);
150+
151+
result.Should().BeSameAs(UpdateCheckResult.NoUpdateAvailable);
152+
}
153+
154+
[Fact]
155+
public async Task CheckAsync_NetworkException_ReturnsNoUpdate()
156+
{
157+
var sut = NewService(
158+
currentVersion: new Version(1, 0, 0),
159+
responses: Array.Empty<FakeResponse>()); // handler throws
160+
161+
var result = await sut.CheckAsync(CancellationToken.None);
162+
163+
result.Should().BeSameAs(UpdateCheckResult.NoUpdateAvailable);
164+
}
165+
166+
[Fact]
167+
public async Task CheckAsync_SendsRequiredUserAgent()
168+
{
169+
var capturedHeaders = new List<HttpRequestHeaders?>();
170+
using var http = new HttpClient(new CapturingHandler(capturedHeaders, ReleasesJson()));
171+
var sut = new BrowserNotifyUpdateService(http, new Version(1, 4, 0), includePreReleases: false);
172+
173+
await sut.CheckAsync(CancellationToken.None);
174+
175+
capturedHeaders.Should().ContainSingle();
176+
var ua = capturedHeaders[0]!.UserAgent.ToString();
177+
ua.Should().Contain("DiffViewer").And.Contain("1.4.0");
178+
}
179+
180+
[Fact]
181+
public async Task ApplyOnNextLaunchAsync_LaunchesUrlFromOpaqueHandle()
182+
{
183+
var launched = new List<string>();
184+
using var http = new HttpClient(new FakeHttpHandler(Array.Empty<FakeResponse>()));
185+
var sut = new BrowserNotifyUpdateService(
186+
http, new Version(1, 0, 0), includePreReleases: false,
187+
openUrl: url => launched.Add(url));
188+
189+
await sut.ApplyOnNextLaunchAsync(
190+
new UpdateCheckResult { IsAvailable = true, Version = "1.5.0", OpaqueHandle = "https://example/v150" },
191+
CancellationToken.None);
192+
193+
launched.Should().ContainSingle().Which.Should().Be("https://example/v150");
194+
}
195+
196+
[Fact]
197+
public async Task ApplyOnNextLaunchAsync_WithNoUpdate_NoOps()
198+
{
199+
var launched = new List<string>();
200+
using var http = new HttpClient(new FakeHttpHandler(Array.Empty<FakeResponse>()));
201+
var sut = new BrowserNotifyUpdateService(
202+
http, new Version(1, 0, 0), includePreReleases: false,
203+
openUrl: url => launched.Add(url));
204+
205+
await sut.ApplyOnNextLaunchAsync(
206+
UpdateCheckResult.NoUpdateAvailable,
207+
CancellationToken.None);
208+
209+
launched.Should().BeEmpty();
210+
}
211+
212+
[Fact]
213+
public async Task DownloadAsync_IsAlwaysANoOp()
214+
{
215+
var sut = NewService(new Version(1, 0, 0), responses: Array.Empty<FakeResponse>());
216+
217+
var act = async () => await sut.DownloadAsync(
218+
new UpdateCheckResult { IsAvailable = true, Version = "1.5.0", OpaqueHandle = "x" },
219+
CancellationToken.None);
220+
221+
await act.Should().NotThrowAsync();
222+
}
223+
224+
[Theory]
225+
[InlineData("v1.5.0", "1.5.0")]
226+
[InlineData("V1.5.0", "1.5.0")]
227+
[InlineData("1.5.0", "1.5.0")]
228+
[InlineData("v1.5.0-rc1", "1.5.0")]
229+
[InlineData("v1.5.0+build.42", "1.5.0")]
230+
[InlineData("v1.5.0-rc1+build.42", "1.5.0")]
231+
public void TryParseVersion_StripsTagPrefixAndSemVerSuffixes(string input, string expected)
232+
{
233+
BrowserNotifyUpdateService.TryParseVersion(input)!.ToString().Should().Be(expected);
234+
}
235+
236+
[Theory]
237+
[InlineData("notaversion")]
238+
[InlineData("")]
239+
[InlineData(null)]
240+
public void TryParseVersion_Garbage_ReturnsNull(string? input)
241+
{
242+
BrowserNotifyUpdateService.TryParseVersion(input).Should().BeNull();
243+
}
244+
245+
// ----- helpers -----
246+
247+
private static BrowserNotifyUpdateService NewService(
248+
Version currentVersion,
249+
IEnumerable<FakeResponse> responses,
250+
bool includePreReleases = false)
251+
{
252+
var handler = new FakeHttpHandler(responses);
253+
// HttpClient owns the handler — we don't dispose it here so
254+
// the test can re-use the handler for assertions if needed;
255+
// GC handles cleanup at test exit.
256+
var http = new HttpClient(handler);
257+
return new BrowserNotifyUpdateService(http, currentVersion, includePreReleases, openUrl: _ => { });
258+
}
259+
260+
private static FakeResponse ReleasesJson(params GhRelease[] releases)
261+
{
262+
// Build the JSON shape GitHub returns (snake_case fields)
263+
// manually rather than via record serialization — avoids
264+
// a wrestling match with JsonSerializer over property naming.
265+
var arr = new System.Text.Json.Nodes.JsonArray();
266+
foreach (var r in releases)
267+
{
268+
arr.Add(new System.Text.Json.Nodes.JsonObject
269+
{
270+
["tag_name"] = r.TagName,
271+
["html_url"] = r.HtmlUrl,
272+
["draft"] = r.Draft,
273+
["prerelease"] = r.Prerelease,
274+
});
275+
}
276+
return new FakeResponse(HttpStatusCode.OK, arr.ToJsonString());
277+
}
278+
279+
private sealed record GhRelease(
280+
string TagName,
281+
string HtmlUrl,
282+
bool Draft,
283+
bool Prerelease);
284+
285+
private sealed record FakeResponse(HttpStatusCode Status, string BodyJson);
286+
287+
private sealed class FakeHttpHandler : HttpMessageHandler
288+
{
289+
private readonly Queue<FakeResponse> _responses;
290+
291+
public FakeHttpHandler(IEnumerable<FakeResponse> responses)
292+
{
293+
_responses = new Queue<FakeResponse>(responses);
294+
}
295+
296+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
297+
{
298+
if (_responses.Count == 0)
299+
{
300+
// Simulate a network outage for tests that want it.
301+
throw new HttpRequestException("simulated network failure");
302+
}
303+
var canned = _responses.Dequeue();
304+
var resp = new HttpResponseMessage(canned.Status)
305+
{
306+
Content = new StringContent(canned.BodyJson, Encoding.UTF8, "application/json"),
307+
};
308+
return Task.FromResult(resp);
309+
}
310+
}
311+
312+
private sealed class CapturingHandler : HttpMessageHandler
313+
{
314+
private readonly List<HttpRequestHeaders?> _headers;
315+
private readonly FakeResponse _response;
316+
317+
public CapturingHandler(List<HttpRequestHeaders?> headers, FakeResponse response)
318+
{
319+
_headers = headers;
320+
_response = response;
321+
}
322+
323+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
324+
{
325+
_headers.Add(request.Headers);
326+
var resp = new HttpResponseMessage(_response.Status)
327+
{
328+
Content = new StringContent(_response.BodyJson, Encoding.UTF8, "application/json"),
329+
};
330+
return Task.FromResult(resp);
331+
}
332+
}
333+
}
334+
335+
336+

DiffViewer.Tests/Services/NullUpdateServiceTests.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ namespace DiffViewer.Tests.Services;
99

1010
public sealed class NullUpdateServiceTests
1111
{
12+
[Fact]
13+
public void CanAutoApply_IsFalse()
14+
{
15+
// Portable launches can't apply silently — the VM uses this
16+
// flag to demote Automatic to NotifyOnly behavior so the
17+
// browser-notify path doesn't surprise users.
18+
var sut = new NullUpdateService();
19+
20+
sut.CanAutoApply.Should().BeFalse();
21+
}
22+
1223
[Fact]
1324
public async Task CheckAsync_ReturnsNoUpdateAvailableSingleton()
1425
{

0 commit comments

Comments
 (0)