Skip to content

Commit 1ac2fc9

Browse files
Geeven SinghCopilot
andcommitted
Phase 2.1: Velopack auto-update foundations (silent, no UI yet)
Lands the Velopack 1.1.1 integration on master, gated so the existing single-file Portable build path and the Velopack-friendly Setup folder layout coexist behind a new <DiffViewerBuildMode> msbuild property. Both modes are exercised by dotnet publish: default (or -p:DiffViewerBuildMode=Portable) produces today's 150 MB single-file exe; -p:DiffViewerBuildMode=Setup produces the 256-file folder layout that �pk pack wraps into an installer (verified end-to-end by the Phase 1 spike on branch spike/velopack). Behavior change for already-installed users: none. Velopack-installed copies don't exist in the wild yet — Phase 3 will add �pk pack + �pk upload github steps to release.yml. Until then, VelopackUpdateService.CheckAndQueueUpdateAsync sees UpdateManager.IsInstalled=false on every existing install (single-file portable extracted to %TEMP%) and short-circuits to a no-op. The dispatch in App.xaml.cs falls through to NullUpdateService for portable / dev launches. Behavior change for from-source builders: the default dotnet publish -c Release -o publish command in the README still produces a single-file 150 MB exe (Portable is the default mode). Build pipelines wanting the Velopack-installable folder layout pass -p:DiffViewerBuildMode=Setup. Architectural pieces in place for Phase 2.2 (settings) and Phase 2.3 (banner UI): - IUpdateService interface in DiffViewer.Services. Single method CheckAndQueueUpdateAsync — deliberately minimal; Phase 2.3 will split it into Check/Download/Apply/Skip when the banner UX needs those seams. - VelopackUpdateService: thin pass-through to Velopack 1.1.1's UpdateManager + GithubSource pointed at https://github.com/geevensingh/DiffViewer (prerelease=false; toggle comes in Phase 2.2). Uses WaitExitThenApplyUpdates for silent apply-on-clean-exit UX — much gentler than the Phase 1 spike's ApplyUpdatesAndRestart. Untested per AGENTS.md §10 thin-wrapper carve-out; mechanics validated by the Phase 1 spike. - NullUpdateService: tested no-op used in portable / dev launches. Phase 5 will replace with a real BrowserNotifyUpdateService once the Phase 2.3 banner UI exists to surface the notification. - App.xaml.cs gains a static [STAThread] Main that runs VelopackApp.Build().Run() before any WPF code (Velopack's install/uninstall/restart hooks must intercept their arg before the dispatcher spins up). Required pairing in DiffViewer.csproj: <StartupObject>DiffViewer.App</StartupObject> and re-declaring App.xaml from <ApplicationDefinition> to <Page> so WPF stops auto-generating its own Main. Per the Velopack WPF sample csproj. - .config/dotnet-tools.json pins vpk 1.1.1 so dotnet tool restore + the future release.yml pipeline are reproducible. Test impact: 1369 passing (+2 from NullUpdateServiceTests), 1 skipped (the existing live GitHub PR test). 0 warnings, 0 errors in dotnet build -c Release. Both publish modes (Portable default, Setup opt-in) verified to produce the expected layout. 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 bff5f67 commit 1ac2fc9

7 files changed

Lines changed: 295 additions & 4 deletions

File tree

.config/dotnet-tools.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"version": 1,
3+
"isRoot": true,
4+
"tools": {
5+
"vpk": {
6+
"version": "1.1.1",
7+
"commands": [
8+
"vpk"
9+
],
10+
"rollForward": false
11+
}
12+
}
13+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System.Threading;
2+
using System.Threading.Tasks;
3+
using DiffViewer.Services;
4+
using FluentAssertions;
5+
using Xunit;
6+
7+
namespace DiffViewer.Tests.Services;
8+
9+
public sealed class NullUpdateServiceTests
10+
{
11+
[Fact]
12+
public async Task CheckAndQueueUpdateAsync_Completes_WithoutThrowing()
13+
{
14+
var sut = new NullUpdateService();
15+
16+
var act = async () => await sut.CheckAndQueueUpdateAsync(CancellationToken.None);
17+
18+
await act.Should().NotThrowAsync();
19+
}
20+
21+
[Fact]
22+
public async Task CheckAndQueueUpdateAsync_HonorsAlreadyCancelledToken_AsNoOp()
23+
{
24+
// The no-op is deliberately tolerant of an already-cancelled
25+
// token — there's no work to abandon and surfacing an OCE
26+
// would be noise. Phase 2.3 may revisit this when real
27+
// implementations start accepting and obeying the token.
28+
var sut = new NullUpdateService();
29+
using var cts = new CancellationTokenSource();
30+
await cts.CancelAsync();
31+
32+
var act = async () => await sut.CheckAndQueueUpdateAsync(cts.Token);
33+
34+
await act.Should().NotThrowAsync();
35+
}
36+
}

DiffViewer/App.xaml.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
using System;
22
using System.Net.Http;
33
using System.Threading;
4+
using System.Threading.Tasks;
45
using System.Windows;
56
using DiffViewer.Rendering;
67
using DiffViewer.Services;
78
using DiffViewer.Utility;
89
using ICSharpCode.AvalonEdit.Highlighting;
10+
using Velopack;
911

1012
namespace DiffViewer;
1113

@@ -15,6 +17,37 @@ public partial class App : Application
1517
private MainWindowCoordinator? _coordinator;
1618
private HttpClient? _httpClient;
1719

20+
// WPF's auto-generated Program.Main doesn't give Velopack a seam
21+
// to intercept its install/uninstall/restart args before WPF spins
22+
// up. The companion change in DiffViewer.csproj swaps App.xaml
23+
// from <ApplicationDefinition> to <Page> and sets
24+
// <StartupObject>DiffViewer.App</StartupObject> so this method
25+
// becomes the process entry point. Per the Velopack WPF sample
26+
// (velopack/velopack/samples/CSharpWpf/App.xaml.cs).
27+
[STAThread]
28+
private static void Main(string[] args)
29+
{
30+
try
31+
{
32+
// Must run before any WPF code. When the parent process is
33+
// an in-progress Velopack install/update, Velopack handles
34+
// the hook arg and Environment.Exit's before we ever reach
35+
// the WPF code below. Outside of installer hooks this is a
36+
// fast no-op.
37+
VelopackApp.Build().Run();
38+
}
39+
catch (Exception)
40+
{
41+
// Velopack init failures must not block launching the app.
42+
// Worst case: this launch runs without an update channel
43+
// and the next launch retries.
44+
}
45+
46+
var app = new App();
47+
app.InitializeComponent();
48+
app.Run();
49+
}
50+
1851
protected override async void OnStartup(StartupEventArgs e)
1952
{
2053
base.OnStartup(e);
@@ -134,6 +167,20 @@ protected override async void OnStartup(StartupEventArgs e)
134167
}
135168

136169
window.Show();
170+
171+
// Auto-update lifecycle (Phase 2.1). Fires a single background
172+
// check at startup; if an update is available it downloads
173+
// and queues to apply silently on the next clean exit.
174+
// Velopack-installed copies get the real service; portable /
175+
// dev launches get a no-op. Phase 2.2 will add periodic
176+
// re-checks driven by a configurable interval; Phase 2.3 will
177+
// add the in-app notification banner that turns this silent
178+
// path into a user-visible one.
179+
IUpdateService updateService =
180+
VelopackUpdateService.TryCreateForInstalled()
181+
?? (IUpdateService)new NullUpdateService();
182+
var updateCt = _shutdownCts.Token;
183+
_ = Task.Run(() => updateService.CheckAndQueueUpdateAsync(updateCt));
137184
}
138185

139186
protected override void OnExit(ExitEventArgs e)

DiffViewer/DiffViewer.csproj

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,33 @@
1010
<RootNamespace>DiffViewer</RootNamespace>
1111
<AssemblyName>DiffViewer</AssemblyName>
1212
<ApplicationIcon>Assets\diffviewer.ico</ApplicationIcon>
13+
14+
<!-- Velopack requires a hook (VelopackApp.Build().Run()) that
15+
runs before WPF spins up. WPF's auto-generated Program.Main
16+
doesn't give us that seam, so we provide our own static Main
17+
in App.xaml.cs and point StartupObject at it. The Page swap
18+
below (App.xaml from ApplicationDefinition to Page) is the
19+
companion change that stops WPF from generating its own
20+
Main and creating a duplicate-entry-point error. Both knobs
21+
apply to every build configuration — the Velopack hook is
22+
a no-op when running outside an installed location, so
23+
portable / dev builds aren't affected. -->
24+
<StartupObject>DiffViewer.App</StartupObject>
25+
26+
<!-- Selects the publish layout produced by `dotnet publish`.
27+
Portable (default) — single-file self-extracting exe (today's
28+
shape); matches the README's documented
29+
"Build from source" workflow and the
30+
shape the v1.x releases ship.
31+
Setup — non-single-file folder layout that
32+
`vpk pack` wraps into a Velopack installer.
33+
Built with
34+
`dotnet publish -p:DiffViewerBuildMode=Setup`.
35+
Used by the Phase 3 release workflow's
36+
auto-update lane.
37+
Both modes are self-contained win-x64. -->
38+
<DiffViewerBuildMode Condition="'$(DiffViewerBuildMode)' == ''">Portable</DiffViewerBuildMode>
39+
1340
<!-- DiffViewer's own strings are hard-coded English; no .resx, no
1441
x:Uid. The 11 framework locale folders shipped by the .NET
1542
desktop runtime (cs/de/es/fr/it/ja/ko/pl/pt-BR/ru/tr) only
@@ -26,6 +53,16 @@
2653
<Resource Include="Assets\diffviewer.ico" />
2754
</ItemGroup>
2855

56+
<ItemGroup>
57+
<!-- Re-declare App.xaml from ApplicationDefinition to Page so WPF
58+
stops auto-generating Program.Main. Our own static Main lives
59+
in App.xaml.cs and runs VelopackApp.Build().Run() before WPF
60+
spins up. Required pairing with <StartupObject> above. Per the
61+
Velopack WPF sample csproj. -->
62+
<ApplicationDefinition Remove="App.xaml" />
63+
<Page Include="App.xaml" />
64+
</ItemGroup>
65+
2966
<ItemGroup>
3067
<!-- Hand-authored AvalonEdit syntax-highlighting definitions (TypeScript,
3168
YAML, Go, Rust, Ruby, Bash, TOML). Registered at app startup via
@@ -35,15 +72,27 @@
3572
</ItemGroup>
3673

3774
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
38-
<PublishSingleFile>true</PublishSingleFile>
3975
<SelfContained>true</SelfContained>
4076
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
41-
<!-- LOAD-BEARING for LibGit2Sharp: ships native git2-*.dll that
42-
must be extracted from the single-file bundle at runtime.
43-
Do NOT remove without testing on a clean machine. -->
77+
</PropertyGroup>
78+
79+
<!-- Portable build: single-file self-extracting exe (today's shape).
80+
Built with `dotnet publish -p:DiffViewerBuildMode=Portable -c Release`.
81+
IncludeNativeLibrariesForSelfExtract is LOAD-BEARING here: it ships
82+
LibGit2Sharp's native git2-*.dll inside the bundle so it can be
83+
extracted to %TEMP% on first launch. Do NOT remove without testing
84+
on a clean machine. -->
85+
<PropertyGroup Condition="'$(Configuration)' == 'Release' AND '$(DiffViewerBuildMode)' == 'Portable'">
86+
<PublishSingleFile>true</PublishSingleFile>
4487
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
4588
</PropertyGroup>
4689

90+
<!-- Setup build (default): non-single-file folder layout that
91+
`vpk pack` wraps into a Velopack installer. Native git2-*.dll
92+
ships loose alongside DiffViewer.exe and is picked up by the OS
93+
loader directly — no self-extract needed. Verified end-to-end
94+
by the Phase 1 spike on branch spike/velopack. -->
95+
4796
<ItemGroup>
4897
<InternalsVisibleTo Include="DiffViewer.Tests" />
4998
</ItemGroup>
@@ -55,6 +104,7 @@
55104
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
56105
<PackageReference Include="SharpVectors.Wpf" Version="1.8.5" />
57106
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
107+
<PackageReference Include="Velopack" Version="1.1.1" />
58108
</ItemGroup>
59109

60110
</Project>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using System.Threading;
2+
using System.Threading.Tasks;
3+
4+
namespace DiffViewer.Services;
5+
6+
/// <summary>
7+
/// Auto-update lifecycle for DiffViewer. Today there is one production
8+
/// implementation, <see cref="VelopackUpdateService"/> (used when the
9+
/// app is running from a Velopack-installed location), and a no-op
10+
/// fallback <see cref="NullUpdateService"/> (used when the app is
11+
/// running portable or from <c>dotnet run</c>).
12+
///
13+
/// <para>The Phase 2.1 surface is deliberately minimal: a single
14+
/// "check, download, queue for next clean exit" method. Phase 2.3 will
15+
/// expand it (split <c>Check</c> from <c>Download</c> from
16+
/// <c>Apply</c>, expose a "skip this version" gesture, surface
17+
/// progress) once the in-app notification banner needs those seams.
18+
/// Until then, anything finer-grained would be speculative API design.
19+
/// </para>
20+
/// </summary>
21+
public interface IUpdateService
22+
{
23+
/// <summary>
24+
/// Check the configured update source for a newer release and, if
25+
/// one is available, queue it to apply silently when the app next
26+
/// exits cleanly. Best-effort: network failures, missing release
27+
/// feed, and "app is not running in an installed location" are all
28+
/// swallowed (logged via Velopack's logger). The next launch
29+
/// retries.
30+
/// </summary>
31+
Task CheckAndQueueUpdateAsync(CancellationToken ct);
32+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using System.Threading;
2+
using System.Threading.Tasks;
3+
4+
namespace DiffViewer.Services;
5+
6+
/// <summary>
7+
/// No-op <see cref="IUpdateService"/> used when the app is not running
8+
/// from a Velopack-installed location (portable build, or
9+
/// <c>dotnet run</c> during development).
10+
///
11+
/// <para>Phase 2.1 deliberately ships this as a true no-op rather than
12+
/// implementing the browser-notify behavior described in the master
13+
/// plan. Reason: the browser-notify path needs UI surface (a
14+
/// dismissable banner) that Phase 2.3 introduces — until then,
15+
/// triggering a browser launch from a silent background check would
16+
/// be hostile UX. Phase 5 will replace this with a real
17+
/// <c>BrowserNotifyUpdateService</c> once the banner UI exists.
18+
/// </para>
19+
/// </summary>
20+
public sealed class NullUpdateService : IUpdateService
21+
{
22+
public Task CheckAndQueueUpdateAsync(CancellationToken ct) => Task.CompletedTask;
23+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using System;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using Velopack;
5+
using Velopack.Sources;
6+
7+
namespace DiffViewer.Services;
8+
9+
/// <summary>
10+
/// Production <see cref="IUpdateService"/> wired to Velopack 1.x.
11+
/// Checks the configured GitHub Releases feed, downloads any newer
12+
/// release, and queues it to apply silently on the next clean exit
13+
/// via <see cref="UpdateManager.WaitExitThenApplyUpdates"/>.
14+
///
15+
/// <para>This is a thin pass-through adapter — by design there is no
16+
/// branching logic to test. The interesting behavior (check API,
17+
/// download, apply-on-exit semantics) lives inside Velopack itself
18+
/// and was verified end-to-end by the Phase 1 spike on branch
19+
/// <c>spike/velopack</c>. Per AGENTS.md §10 thin-wrapper carve-out,
20+
/// this class is intentionally untested; <see cref="NullUpdateService"/>
21+
/// has a smoke test that covers the dispatch decision in
22+
/// <see cref="App"/> startup.</para>
23+
///
24+
/// <para>Constructor takes nothing today; the feed URL is hardcoded
25+
/// because Phase 2.1 deliberately predates the
26+
/// <c>AppSettings.IncludePreReleases</c> toggle that Phase 2.2 adds.
27+
/// Once that setting lands, this service will read it from
28+
/// <see cref="ISettingsService"/> and pass through to the
29+
/// <see cref="GithubSource"/> ctor.</para>
30+
/// </summary>
31+
public sealed class VelopackUpdateService : IUpdateService
32+
{
33+
private const string ReleasesUrl = "https://github.com/geevensingh/DiffViewer";
34+
35+
/// <summary>
36+
/// Returns a configured <see cref="VelopackUpdateService"/> when
37+
/// the app is running from a Velopack-installed location, else
38+
/// <c>null</c>. Callers (today: <see cref="App"/> startup) should
39+
/// substitute a <see cref="NullUpdateService"/> on null so the
40+
/// rest of the app can program against <see cref="IUpdateService"/>
41+
/// unconditionally. Tolerant of Velopack-side exceptions
42+
/// (corrupt locator state, missing files in the install folder,
43+
/// etc.) — those degrade to "treat as portable" rather than
44+
/// crashing app startup.
45+
/// </summary>
46+
public static VelopackUpdateService? TryCreateForInstalled()
47+
{
48+
try
49+
{
50+
var source = new GithubSource(ReleasesUrl, accessToken: null, prerelease: false);
51+
var probe = new UpdateManager(source);
52+
return probe.IsInstalled ? new VelopackUpdateService() : null;
53+
}
54+
catch (Exception)
55+
{
56+
return null;
57+
}
58+
}
59+
60+
public async Task CheckAndQueueUpdateAsync(CancellationToken ct)
61+
{
62+
try
63+
{
64+
var source = new GithubSource(ReleasesUrl, accessToken: null, prerelease: false);
65+
var mgr = new UpdateManager(source);
66+
if (!mgr.IsInstalled)
67+
{
68+
return;
69+
}
70+
71+
var info = await mgr.CheckForUpdatesAsync().ConfigureAwait(false);
72+
if (info is null)
73+
{
74+
return;
75+
}
76+
77+
await mgr.DownloadUpdatesAsync(info).ConfigureAwait(false);
78+
mgr.WaitExitThenApplyUpdates(info);
79+
}
80+
catch (Exception)
81+
{
82+
// Best-effort: network failures, missing release feed,
83+
// GitHub rate-limiting, etc. Worst case the user stays on
84+
// the current version and the next launch retries. Phase
85+
// 2.3 will wire a Velopack ILogger to a rolling file log
86+
// under %LocalAppData%\DiffViewer\logs\ so diagnostics are
87+
// recoverable.
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)