Skip to content

Commit 7bade35

Browse files
Geeven SinghCopilot
andcommitted
Register installed app on per-user PATH so diffviewer works from a terminal
After a Setup (Velopack) install the app lived only at %LocalAppData%\DiffViewer\current, so launching from a terminal meant typing the full deep path. The Velopack install/update/uninstall fast callbacks now add/remove that stable `current` directory on the per-user PATH, making `diffviewer` resolvable in any new terminal. Editing the user PATH safely is the crux: .NET's Environment.Get/SetEnvironmentVariable(User) expands %VAR% on read and rewrites the value as REG_SZ, which would corrupt %USERPROFILE%-style entries (proven empirically). So PATH edits go through the raw registry (HKCU\Environment), reading unexpanded and preserving REG_EXPAND_SZ, then broadcast WM_SETTINGCHANGE so new terminals observe the change. New pieces: - Utility/PathListEditor: pure add/remove on a PATH string (whole- segment, case-insensitive, separator- and %VAR%-equivalence aware). - Services/RegistryPathIo: raw read/write preserving value kind. - Services/IEnvironmentPathStore + WindowsUserPathStore: registry + WM_SETTINGCHANGE broadcast behind a testable seam. - Services/UserPathRegistrar: idempotent register/unregister. - App.Main wires the three Velopack fast callbacks (best-effort). Hooks only fire for the Velopack-installed copy; portable/dev launches are unaffected. Adds unit tests for the pure logic, the registrar (fake store), and RegistryPathIo (throwaway HKCU key). README + CHANGELOG updated. AI-Local-Session: 8ea248ad-b4d5-4860-a197-594feab7eada AI-Cloud-Session: c3856a40-a18f-4d6d-bc1d-df484df61f2b Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3db2a93 commit 7bade35

11 files changed

Lines changed: 795 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ body. Keep section headings exact and write notes in Markdown.
1414

1515
### Added
1616

17+
- **`diffviewer` works from the command line after Setup install.**
18+
Installing via `DiffViewer-Setup.exe` now adds the app to your
19+
per-user `PATH`, so you can launch it by typing `diffviewer`
20+
(optionally with a repo path or commit-ish, e.g.
21+
`diffviewer C:\path\to\repo`) in any **new** terminal — no need to
22+
dig into `%LocalAppData%\DiffViewer\current`. Uninstalling removes
23+
the entry. The PATH edit preserves existing `%VAR%`-style entries
24+
(`REG_EXPAND_SZ`) and only touches your user PATH, never the
25+
machine PATH. Portable builds are unaffected.
1726
- **Settings → Updates section now shows the running version.** A
1827
read-only "Current version" row at the top of the section
1928
displays the build's version string (e.g. `1.6.0` or `1.6.0-rc1`
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using System;
2+
using DiffViewer.Services;
3+
using FluentAssertions;
4+
using Microsoft.Win32;
5+
using Xunit;
6+
7+
namespace DiffViewer.Tests.Services;
8+
9+
/// <summary>
10+
/// Verifies the load-bearing registry behavior that the whole CLI-PATH
11+
/// feature depends on: reading the raw (unexpanded) value and preserving
12+
/// <c>REG_EXPAND_SZ</c> vs <c>REG_SZ</c> on write. Runs against a throwaway
13+
/// per-user key, never the real <c>HKCU\Environment</c>.
14+
/// </summary>
15+
public sealed class RegistryPathIoTests : IDisposable
16+
{
17+
private const string ParentPath = @"Software\DiffViewerTests";
18+
private readonly string _subKeyPath;
19+
private readonly RegistryKey _key;
20+
21+
public RegistryPathIoTests()
22+
{
23+
_subKeyPath = $@"{ParentPath}\{Guid.NewGuid():N}";
24+
_key = Registry.CurrentUser.CreateSubKey(_subKeyPath, writable: true)!;
25+
}
26+
27+
public void Dispose()
28+
{
29+
_key.Dispose();
30+
try { Registry.CurrentUser.DeleteSubKeyTree(_subKeyPath, throwOnMissingSubKey: false); }
31+
catch { /* best-effort cleanup */ }
32+
}
33+
34+
[Fact]
35+
public void Read_AbsentValue_ReturnsNullAndExpandableTrue()
36+
{
37+
var (value, isExpandable) = RegistryPathIo.Read(_key, "Path");
38+
value.Should().BeNull();
39+
isExpandable.Should().BeTrue();
40+
}
41+
42+
[Fact]
43+
public void Read_ExpandString_ReturnsRawUnexpandedValueAndExpandableTrue()
44+
{
45+
_key.SetValue("Path", @"%USERPROFILE%\bin", RegistryValueKind.ExpandString);
46+
47+
var (value, isExpandable) = RegistryPathIo.Read(_key, "Path");
48+
49+
value.Should().Be(@"%USERPROFILE%\bin", "the raw value must not be expanded on read");
50+
value.Should().Contain("%");
51+
isExpandable.Should().BeTrue();
52+
}
53+
54+
[Fact]
55+
public void Read_String_ReturnsValueAndExpandableFalse()
56+
{
57+
_key.SetValue("Path", @"C:\tools", RegistryValueKind.String);
58+
59+
var (value, isExpandable) = RegistryPathIo.Read(_key, "Path");
60+
61+
value.Should().Be(@"C:\tools");
62+
isExpandable.Should().BeFalse();
63+
}
64+
65+
[Fact]
66+
public void Read_NonStringKind_Throws()
67+
{
68+
_key.SetValue("Path", 42, RegistryValueKind.DWord);
69+
70+
var act = () => RegistryPathIo.Read(_key, "Path");
71+
72+
act.Should().Throw<NotSupportedException>();
73+
}
74+
75+
[Fact]
76+
public void Write_Expandable_PersistsAsRegExpandSz()
77+
{
78+
RegistryPathIo.Write(_key, "Path", @"%USERPROFILE%\bin;C:\tools", isExpandable: true);
79+
80+
_key.GetValueKind("Path").Should().Be(RegistryValueKind.ExpandString);
81+
_key.GetValue("Path", null, RegistryValueOptions.DoNotExpandEnvironmentNames)
82+
.Should().Be(@"%USERPROFILE%\bin;C:\tools");
83+
}
84+
85+
[Fact]
86+
public void Write_NonExpandable_PersistsAsRegSz()
87+
{
88+
RegistryPathIo.Write(_key, "Path", @"C:\tools", isExpandable: false);
89+
90+
_key.GetValueKind("Path").Should().Be(RegistryValueKind.String);
91+
}
92+
93+
[Fact]
94+
public void RoundTrip_PreservesExpandSzAndRawValue()
95+
{
96+
_key.SetValue("Path", @"%USERPROFILE%\bin", RegistryValueKind.ExpandString);
97+
98+
var (value, isExpandable) = RegistryPathIo.Read(_key, "Path");
99+
RegistryPathIo.Write(_key, "Path", value + @";C:\tools", isExpandable);
100+
101+
_key.GetValueKind("Path").Should().Be(RegistryValueKind.ExpandString);
102+
_key.GetValue("Path", null, RegistryValueOptions.DoNotExpandEnvironmentNames)
103+
.Should().Be(@"%USERPROFILE%\bin;C:\tools");
104+
}
105+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
using DiffViewer.Services;
2+
using FluentAssertions;
3+
using Xunit;
4+
5+
namespace DiffViewer.Tests.Services;
6+
7+
public class UserPathRegistrarTests
8+
{
9+
private const string Dir = @"C:\Users\me\AppData\Local\DiffViewer\current";
10+
11+
private sealed class FakeStore : IEnvironmentPathStore
12+
{
13+
public string? Value;
14+
public bool IsExpandable;
15+
public int WriteCount;
16+
public string? LastWritten;
17+
public bool? LastWrittenExpandable;
18+
19+
public (string? Value, bool IsExpandable) Read() => (Value, IsExpandable);
20+
21+
public void Write(string value, bool isExpandable)
22+
{
23+
WriteCount++;
24+
LastWritten = value;
25+
LastWrittenExpandable = isExpandable;
26+
Value = value;
27+
IsExpandable = isExpandable;
28+
}
29+
}
30+
31+
[Fact]
32+
public void Register_AppendsAndWrites()
33+
{
34+
var store = new FakeStore { Value = @"C:\Windows", IsExpandable = true };
35+
var registrar = new UserPathRegistrar(store);
36+
37+
registrar.Register(Dir).Should().BeTrue();
38+
store.WriteCount.Should().Be(1);
39+
store.LastWritten.Should().Be($@"C:\Windows;{Dir}");
40+
}
41+
42+
[Fact]
43+
public void Register_AlreadyPresent_DoesNotWrite()
44+
{
45+
var store = new FakeStore { Value = $@"C:\Windows;{Dir}", IsExpandable = true };
46+
var registrar = new UserPathRegistrar(store);
47+
48+
registrar.Register(Dir).Should().BeFalse();
49+
store.WriteCount.Should().Be(0);
50+
}
51+
52+
[Fact]
53+
public void Register_PreservesIsExpandableFlag()
54+
{
55+
var expandable = new FakeStore { Value = @"%USERPROFILE%\bin", IsExpandable = true };
56+
new UserPathRegistrar(expandable).Register(Dir);
57+
expandable.LastWrittenExpandable.Should().BeTrue();
58+
59+
var plain = new FakeStore { Value = @"C:\bin", IsExpandable = false };
60+
new UserPathRegistrar(plain).Register(Dir);
61+
plain.LastWrittenExpandable.Should().BeFalse();
62+
}
63+
64+
[Fact]
65+
public void Register_NoExistingPath_CreatesIt()
66+
{
67+
var store = new FakeStore { Value = null, IsExpandable = true };
68+
var registrar = new UserPathRegistrar(store);
69+
70+
registrar.Register(Dir).Should().BeTrue();
71+
store.LastWritten.Should().Be(Dir);
72+
}
73+
74+
[Fact]
75+
public void Register_BlankDirectory_DoesNothing()
76+
{
77+
var store = new FakeStore { Value = @"C:\Windows", IsExpandable = true };
78+
var registrar = new UserPathRegistrar(store);
79+
80+
registrar.Register(" ").Should().BeFalse();
81+
store.WriteCount.Should().Be(0);
82+
}
83+
84+
[Fact]
85+
public void Unregister_RemovesAndWrites()
86+
{
87+
var store = new FakeStore { Value = $@"C:\Windows;{Dir}", IsExpandable = true };
88+
var registrar = new UserPathRegistrar(store);
89+
90+
registrar.Unregister(Dir).Should().BeTrue();
91+
store.WriteCount.Should().Be(1);
92+
store.LastWritten.Should().Be(@"C:\Windows");
93+
}
94+
95+
[Fact]
96+
public void Unregister_NotPresent_DoesNotWrite()
97+
{
98+
var store = new FakeStore { Value = @"C:\Windows", IsExpandable = true };
99+
var registrar = new UserPathRegistrar(store);
100+
101+
registrar.Unregister(Dir).Should().BeFalse();
102+
store.WriteCount.Should().Be(0);
103+
}
104+
105+
[Fact]
106+
public void Unregister_PreservesIsExpandableFlag()
107+
{
108+
var store = new FakeStore { Value = $@"%USERPROFILE%\bin;{Dir}", IsExpandable = true };
109+
new UserPathRegistrar(store).Unregister(Dir);
110+
store.LastWrittenExpandable.Should().BeTrue();
111+
}
112+
113+
[Fact]
114+
public void Register_IsIdempotentAcrossRepeatedCalls()
115+
{
116+
var store = new FakeStore { Value = @"C:\Windows", IsExpandable = true };
117+
var registrar = new UserPathRegistrar(store);
118+
119+
registrar.Register(Dir).Should().BeTrue();
120+
registrar.Register(Dir).Should().BeFalse();
121+
registrar.Register(Dir).Should().BeFalse();
122+
store.WriteCount.Should().Be(1);
123+
}
124+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
using System;
2+
using DiffViewer.Utility;
3+
using FluentAssertions;
4+
using Xunit;
5+
6+
namespace DiffViewer.Tests.Utility;
7+
8+
public class PathListEditorTests
9+
{
10+
private const string Dir = @"C:\Users\me\AppData\Local\DiffViewer\current";
11+
12+
// ---- Contains --------------------------------------------------------
13+
14+
[Fact]
15+
public void Contains_NullOrEmptyRaw_IsFalse()
16+
{
17+
PathListEditor.Contains(null, Dir).Should().BeFalse();
18+
PathListEditor.Contains("", Dir).Should().BeFalse();
19+
}
20+
21+
[Fact]
22+
public void Contains_ExactSegment_IsTrue()
23+
=> PathListEditor.Contains($@"C:\Windows;{Dir};C:\Tools", Dir).Should().BeTrue();
24+
25+
[Fact]
26+
public void Contains_IsCaseInsensitive()
27+
=> PathListEditor.Contains(Dir.ToUpperInvariant(), Dir).Should().BeTrue();
28+
29+
[Fact]
30+
public void Contains_TrailingSeparatorMismatch_StillMatches()
31+
{
32+
PathListEditor.Contains($@"{Dir}\", Dir).Should().BeTrue();
33+
PathListEditor.Contains(Dir, $@"{Dir}\").Should().BeTrue();
34+
}
35+
36+
[Fact]
37+
public void Contains_SubstringOfAnotherSegment_DoesNotMatch()
38+
{
39+
// "...\current" must not match "...\current2".
40+
PathListEditor.Contains($@"{Dir}2", Dir).Should().BeFalse();
41+
PathListEditor.Contains($@"C:\a\currentX;C:\b", @"C:\a\current").Should().BeFalse();
42+
}
43+
44+
[Fact]
45+
public void Contains_EnvironmentVariableForm_MatchesExpandedTarget()
46+
{
47+
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
48+
var expanded = System.IO.Path.Combine(localAppData, "DiffViewer", "current");
49+
var rawWithVar = @"%LOCALAPPDATA%\DiffViewer\current";
50+
51+
PathListEditor.Contains(rawWithVar, expanded).Should().BeTrue();
52+
}
53+
54+
// ---- Add -------------------------------------------------------------
55+
56+
[Fact]
57+
public void Add_NullRaw_ReturnsJustDirectory()
58+
=> PathListEditor.Add(null, Dir).Should().Be(Dir);
59+
60+
[Fact]
61+
public void Add_EmptyRaw_ReturnsJustDirectory()
62+
=> PathListEditor.Add("", Dir).Should().Be(Dir);
63+
64+
[Fact]
65+
public void Add_WhitespaceRaw_ReturnsJustDirectory()
66+
=> PathListEditor.Add(" ", Dir).Should().Be(Dir);
67+
68+
[Fact]
69+
public void Add_AppendsWithSingleSeparator()
70+
=> PathListEditor.Add(@"C:\Windows;C:\Tools", Dir).Should().Be($@"C:\Windows;C:\Tools;{Dir}");
71+
72+
[Fact]
73+
public void Add_RawEndingWithSeparator_DoesNotDoubleUp()
74+
=> PathListEditor.Add(@"C:\Windows;", Dir).Should().Be($@"C:\Windows;{Dir}");
75+
76+
[Fact]
77+
public void Add_AlreadyPresent_ReturnsNull()
78+
=> PathListEditor.Add($@"C:\Windows;{Dir}", Dir).Should().BeNull();
79+
80+
[Fact]
81+
public void Add_AlreadyPresentWithTrailingSlash_ReturnsNull()
82+
=> PathListEditor.Add($@"C:\Windows;{Dir}\", Dir).Should().BeNull();
83+
84+
[Fact]
85+
public void Add_BlankDirectory_ReturnsNull()
86+
{
87+
PathListEditor.Add(@"C:\Windows", "").Should().BeNull();
88+
PathListEditor.Add(@"C:\Windows", " ").Should().BeNull();
89+
}
90+
91+
[Fact]
92+
public void Add_TrimsTrailingSeparatorOnStoredEntry()
93+
=> PathListEditor.Add(@"C:\Windows", $@"{Dir}\").Should().Be($@"C:\Windows;{Dir}");
94+
95+
// ---- Remove ----------------------------------------------------------
96+
97+
[Fact]
98+
public void Remove_NullOrEmptyRaw_ReturnsNull()
99+
{
100+
PathListEditor.Remove(null, Dir).Should().BeNull();
101+
PathListEditor.Remove("", Dir).Should().BeNull();
102+
}
103+
104+
[Fact]
105+
public void Remove_NotPresent_ReturnsNull()
106+
=> PathListEditor.Remove(@"C:\Windows;C:\Tools", Dir).Should().BeNull();
107+
108+
[Fact]
109+
public void Remove_FromMiddle_PreservesOtherSegments()
110+
=> PathListEditor.Remove($@"C:\Windows;{Dir};C:\Tools", Dir).Should().Be(@"C:\Windows;C:\Tools");
111+
112+
[Fact]
113+
public void Remove_FromEnd()
114+
=> PathListEditor.Remove($@"C:\Windows;{Dir}", Dir).Should().Be(@"C:\Windows");
115+
116+
[Fact]
117+
public void Remove_FromStart()
118+
=> PathListEditor.Remove($@"{Dir};C:\Windows", Dir).Should().Be(@"C:\Windows");
119+
120+
[Fact]
121+
public void Remove_OnlyEntry_ReturnsEmptyStringNotNull()
122+
=> PathListEditor.Remove(Dir, Dir).Should().Be(string.Empty);
123+
124+
[Fact]
125+
public void Remove_DuplicateEntries_RemovesAll()
126+
=> PathListEditor.Remove($@"{Dir};C:\Windows;{Dir}\", Dir).Should().Be(@"C:\Windows");
127+
128+
[Fact]
129+
public void Remove_PreservesInteriorEmptySegmentsVerbatim()
130+
=> PathListEditor.Remove($@"C:\Windows;;{Dir};C:\Tools", Dir).Should().Be(@"C:\Windows;;C:\Tools");
131+
132+
[Fact]
133+
public void Remove_EnvironmentVariableForm_IsRemoved()
134+
{
135+
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
136+
var expanded = System.IO.Path.Combine(localAppData, "DiffViewer", "current");
137+
138+
PathListEditor.Remove($@"C:\Windows;%LOCALAPPDATA%\DiffViewer\current", expanded)
139+
.Should().Be(@"C:\Windows");
140+
}
141+
142+
[Fact]
143+
public void Remove_IsCaseInsensitive()
144+
=> PathListEditor.Remove($@"C:\Windows;{Dir.ToUpperInvariant()}", Dir).Should().Be(@"C:\Windows");
145+
}

0 commit comments

Comments
 (0)