Skip to content

Commit 2368541

Browse files
vupleaclaude
andcommitted
Add the install directory to the system PATH on install
The install command appends its Program Files directory to the machine PATH, so new terminals can run the command by name. The registry is edited directly (preserving REG_EXPAND_SZ and the existing text) and the environment change is broadcast; a refused PATH edit degrades to a warning, since the copy and shortcuts work without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zb28545waFBq9SSv5YBMH
1 parent 6c3442d commit 2368541

5 files changed

Lines changed: 197 additions & 14 deletions

File tree

‎PROFILES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ and large owner discussion threads, cited inline below.
66
## Setup
77

88
1. Extract `simple-nvidia-undervolt-profiles.zip` from [releases](https://github.com/vuplea/simple-nvidia-undervolt/releases).
9-
2. Run `~install-simple-nvidia-undervolt.exe` to install the app to Program Files, where the shortcuts are targeting.
9+
2. Run `~install-simple-nvidia-undervolt.exe` to install the app to Program Files, where the shortcuts are targeting, and add it to the system PATH.
1010
3. Double-click shortcuts from the folder that lists your card to apply them.
1111

1212
## The matrix: three families × four risk tiers

‎README.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,16 @@ simple-nvidia-undervolt clear
3737
three families (perf boost / power cut at the same performance / deep power cut) in four risk
3838
tiers each, built from community-converged values per generation, memory type and power-limit
3939
class. Extract the zip, double-click `~install-simple-nvidia-undervolt.exe` once (it copies the
40-
app to Program Files, which the shortcuts target), then double-click a profile from the folder
41-
that lists your card. [PROFILES.md](PROFILES.md) explains the matrix and the values.
40+
app to Program Files, which the shortcuts target, and adds it to the system PATH), then
41+
double-click a profile from the folder that lists your card. [PROFILES.md](PROFILES.md) explains the matrix and the values.
4242

4343
## Usage
4444

4545
```
4646
[options] By not specifying a verb, it is implicitly a 'tune' command: you can cap voltage
4747
(flatten the curve), set the clock at the cap, and offset the memory clock.
48-
install Copy the app to Program Files, so saved or profile shortcuts work.
48+
install Copy the app to Program Files and add it to the system PATH, so saved or
49+
profile shortcuts work and new terminals can run the command.
4950
status Show curve offset, memory clock, voltage boost, and logon re-apply.
5051
watch Poll live core voltage/clock/temp/power, tracking the max.
5152
clear Reset all tuning to stock and remove logon re-apply.
@@ -125,8 +126,8 @@ simple-nvidia-undervolt --mv 925 --dry-run --save-shortcut Quiet
125126
### clear
126127

127128
The `clear` command resets all tuning to stock: the V/F curve offsets, memory clock and core voltage boost.
128-
The command also stops the re-apply at logon by removing the registered task. The Program Files copy stays, so
129-
saved shortcuts keep working.
129+
The command also stops the re-apply at logon by removing the registered task. The Program Files copy and its
130+
PATH entry stay, so saved shortcuts and the terminal command keep working.
130131

131132
## Disclaimer
132133

‎src/Program.cs‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -166,16 +166,30 @@ private static int RunTuneCommand(string[] args, bool isRelayChild, string? laun
166166
}, needsElevation: !request.DryRun);
167167
}
168168

169-
/// <summary>'install' copies the app into Program Files without touching the GPU or registering
170-
/// anything — what pre-made profile shortcuts (which target the installed copy) need once. A
171-
/// persisting tune does the same as a side effect; this is the standalone form.</summary>
169+
/// <summary>'install' copies the app into Program Files and puts that directory on the system
170+
/// PATH, without touching the GPU or registering anything — what pre-made profile shortcuts
171+
/// (which target the installed copy) need once, and what lets any new terminal run the command
172+
/// by name. A persisting tune installs the copy as a side effect; this is the standalone form.</summary>
172173
private static int RunInstallCommand(string[] args, bool isRelayChild)
173174
=> RunWriteCommand(args, isRelayChild, () =>
174175
{
175176
WarnIfNotElevated();
176-
Console.WriteLine(Persistence.InstallApp()
177-
? $"Installed to {Persistence.InstallDir()}."
178-
: $"Already installed at {Persistence.InstallDir()}.");
177+
string dir = Persistence.InstallDir();
178+
Console.WriteLine(Persistence.InstallApp() ? $"Installed to {dir}." : $"Already installed at {dir}.");
179+
try
180+
{
181+
Console.WriteLine(SystemPath.Add(dir)
182+
? $"Added the install directory to the system PATH; new terminals can run '{Product.Name}'."
183+
: "The install directory is already on the system PATH.");
184+
}
185+
catch (CliError ex)
186+
{
187+
// The copy is in and shortcuts work regardless, so a refused PATH edit (a policy or
188+
// security product guarding the Environment key) degrades to a warning instead of
189+
// reading as a failed install - especially in the double-clicked installer's box.
190+
Console.WriteLine($"Warning: {ex.Message}");
191+
}
192+
179193
Console.WriteLine("Shortcuts targeting the installed copy (saved or profile .lnks) now work.");
180194
});
181195

@@ -855,8 +869,9 @@ background process - and caps voltage by flattening the V/F curve.
855869
[options] By not specifying a verb, it is implicitly a 'tune' command: you can
856870
cap voltage (flatten the curve), set the clock at the cap, and offset
857871
the memory clock.
858-
install Copy the app to Program Files, so saved or pre-made profile shortcuts
859-
work. (A persisting tune installs too; this is the standalone form.)
872+
install Copy the app to Program Files and add it to the system PATH, so
873+
shortcuts work and new terminals can run the command. (A persisting
874+
tune installs the copy too; this is the standalone form.)
860875
status Show curve offset, memory clock, voltage boost, and logon re-apply.
861876
--out-tuning-file <f> exports the applied tuning as JSON - the
862877
tuned curve anchors, keyed by voltage, plus the memory offset -

‎src/SystemPath.cs‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using System.Runtime.InteropServices;
2+
using System.Security;
3+
using Microsoft.Win32;
4+
5+
namespace SimpleNvidiaUndervolt;
6+
7+
/// <summary>Puts the install directory on the system PATH, so any new terminal can run
8+
/// <c>simple-nvidia-undervolt</c> by name. Machine-wide (HKLM) like the install itself, which is
9+
/// also why the elevation the install already has suffices. The registry is edited directly instead
10+
/// of through <see cref="Environment.SetEnvironmentVariable(string,string?,EnvironmentVariableTarget)"/>,
11+
/// which rewrites the value as plain REG_SZ: the machine PATH is REG_EXPAND_SZ full of
12+
/// <c>%SystemRoot%</c>-style entries that would then stop expanding.</summary>
13+
internal static class SystemPath
14+
{
15+
private const string EnvironmentKey = @"SYSTEM\CurrentControlSet\Control\Session Manager\Environment";
16+
private const string PathValueName = "Path";
17+
18+
/// <summary>Adds the directory to the machine PATH when it isn't already listed, returning
19+
/// whether it added. Membership is decided on a read-only open — the already-listed case then
20+
/// succeeds without write access, like <see cref="Persistence.InstallApp"/>'s read-only
21+
/// already-installed check on a <c>--no-elevate</c> re-run. Running processes keep the PATH they
22+
/// started with; the settings-change broadcast makes Explorer re-read it, so terminals launched
23+
/// after the install see the entry.</summary>
24+
public static bool Add(string dir)
25+
{
26+
try
27+
{
28+
string current = ReadPathValue();
29+
if (ContainsDir(current, dir))
30+
{
31+
return false;
32+
}
33+
34+
using RegistryKey key = OpenEnvironmentKey(writable: true);
35+
key.SetValue(PathValueName, Append(current, dir), RegistryValueKind.ExpandString);
36+
}
37+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException)
38+
{
39+
// Typically the environment refusing the write - a non-elevated run (--no-elevate), or a
40+
// policy/security product guarding this key - so report the message, not a stack (see
41+
// ErrorReporter.Describe).
42+
throw new CliError($"Adding {dir} to the system PATH failed: {ex.Message}");
43+
}
44+
45+
BroadcastSettingsChange();
46+
return true;
47+
}
48+
49+
/// <summary>The machine PATH's raw (unexpanded) text. A missing or non-string value is refused
50+
/// rather than read as empty: appending to a misread would replace the whole machine PATH with
51+
/// just the install directory, and on a real machine the value is never actually empty.</summary>
52+
private static string ReadPathValue()
53+
{
54+
using RegistryKey key = OpenEnvironmentKey(writable: false);
55+
return key.GetValue(PathValueName, null, RegistryValueOptions.DoNotExpandEnvironmentNames)
56+
is string { Length: > 0 } value
57+
? value
58+
: throw new CliError(
59+
$@"The machine PATH value (HKLM\{EnvironmentKey}\{PathValueName}) is missing or not text.");
60+
}
61+
62+
private static RegistryKey OpenEnvironmentKey(bool writable)
63+
=> Registry.LocalMachine.OpenSubKey(EnvironmentKey, writable)
64+
?? throw new CliError($@"The registry key HKLM\{EnvironmentKey} is missing.");
65+
66+
/// <summary>Whether a PATH value already lists the directory: entries compare expanded (an
67+
/// entry written as <c>%ProgramFiles%\...</c> still counts), case-insensitively, and ignoring
68+
/// surrounding whitespace and a trailing backslash.</summary>
69+
internal static bool ContainsDir(string pathValue, string dir)
70+
=> pathValue.Split(';').Any(entry =>
71+
Normalize(Environment.ExpandEnvironmentVariables(entry))
72+
.Equals(Normalize(dir), StringComparison.OrdinalIgnoreCase));
73+
74+
/// <summary>The PATH value with the directory appended: the existing text stays byte-identical
75+
/// (its entries are other software's configuration) - a separator is added only when the value
76+
/// doesn't already end with one.</summary>
77+
internal static string Append(string pathValue, string dir)
78+
=> pathValue.Length == 0 ? dir
79+
: pathValue.EndsWith(';') ? pathValue + dir
80+
: pathValue + ';' + dir;
81+
82+
private static string Normalize(string path) => path.Trim().TrimEnd('\\');
83+
84+
/// <summary>Tells running windows the environment changed — the same broadcast the system
85+
/// environment-variables dialog sends. Fire-and-forget with a short per-window timeout, so one
86+
/// hung window can't stall the install; a missed broadcast only means the new PATH waits for
87+
/// the next logon.</summary>
88+
private static void BroadcastSettingsChange()
89+
=> SendMessageTimeoutW(HWND_BROADCAST, WM_SETTINGCHANGE, IntPtr.Zero, "Environment",
90+
SMTO_ABORTIFHUNG, 5000, out _);
91+
92+
private static readonly IntPtr HWND_BROADCAST = new(0xFFFF);
93+
private const uint WM_SETTINGCHANGE = 0x001A;
94+
private const uint SMTO_ABORTIFHUNG = 0x0002;
95+
96+
/// <summary>For WM_SETTINGCHANGE, lParam names the changed setting as a wide string.</summary>
97+
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
98+
private static extern IntPtr SendMessageTimeoutW(IntPtr hWnd, uint msg, IntPtr wParam,
99+
string lParam, uint flags, uint timeoutMs, out IntPtr result);
100+
}

‎tests/SystemPathTests.cs‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
namespace SimpleNvidiaUndervolt.Tests;
2+
3+
/// <summary>Tests for the PATH edit the install command makes: how an existing PATH value is
4+
/// searched for the install directory, and how the directory is appended to it.</summary>
5+
public class SystemPathTests
6+
{
7+
private const string Dir = @"C:\Program Files\simple-nvidia-undervolt";
8+
9+
[Theory]
10+
[InlineData(@"C:\Windows;C:\Program Files\simple-nvidia-undervolt")] // exact entry
11+
[InlineData(@"C:\PROGRAM FILES\SIMPLE-NVIDIA-UNDERVOLT;C:\Windows")] // case-insensitive
12+
[InlineData(@"C:\Windows;C:\Program Files\simple-nvidia-undervolt\")] // trailing backslash
13+
[InlineData(@"C:\Windows; C:\Program Files\simple-nvidia-undervolt ;C:\x")] // padded entry
14+
public void ContainsDir_FindsTheListedDirectory(string pathValue)
15+
{
16+
Assert.True(SystemPath.ContainsDir(pathValue, Dir));
17+
}
18+
19+
[Fact]
20+
public void ContainsDir_ExpandsEnvironmentReferencesInEntries()
21+
{
22+
// The machine PATH is REG_EXPAND_SZ, so an entry can spell the directory as
23+
// %ProgramFiles%\... - it still counts as listed.
24+
string dir = Path.Combine(
25+
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "simple-nvidia-undervolt");
26+
Assert.True(SystemPath.ContainsDir(@"%ProgramFiles%\simple-nvidia-undervolt", dir));
27+
}
28+
29+
[Theory]
30+
[InlineData("")] // empty PATH
31+
[InlineData(@"C:\Windows;C:\Program Files")] // a parent directory
32+
[InlineData(@"C:\Program Files\simple-nvidia-undervolt-old")] // a longer name is not ours
33+
[InlineData(@"C:\Program Files\simple-nvidia-undervolt\data")] // a subfolder
34+
public void ContainsDir_DoesNotMatchOtherEntries(string pathValue)
35+
{
36+
Assert.False(SystemPath.ContainsDir(pathValue, Dir));
37+
}
38+
39+
[Fact]
40+
public void Append_KeepsTheExistingValueAndAddsOneEntry()
41+
{
42+
// The existing text stays byte-identical - unexpanded %refs included - with the new entry
43+
// after one separator.
44+
Assert.Equal(@"%SystemRoot%\system32;C:\Tools;" + Dir,
45+
SystemPath.Append(@"%SystemRoot%\system32;C:\Tools", Dir));
46+
}
47+
48+
[Fact]
49+
public void Append_EmptyPath_IsJustTheDirectory()
50+
{
51+
Assert.Equal(Dir, SystemPath.Append(string.Empty, Dir));
52+
}
53+
54+
[Fact]
55+
public void Append_DoesNotDoubleATrailingSeparator()
56+
{
57+
Assert.Equal(@"C:\Windows;" + Dir, SystemPath.Append(@"C:\Windows;", Dir));
58+
}
59+
60+
[Fact]
61+
public void Append_LeavesRepeatedTrailingSeparatorsInPlace()
62+
{
63+
// Even odd existing text (a ";;" tail) is someone's configuration: it stays byte-identical,
64+
// with the new entry after it.
65+
Assert.Equal(@"C:\Windows;;" + Dir, SystemPath.Append(@"C:\Windows;;", Dir));
66+
}
67+
}

0 commit comments

Comments
 (0)