|
| 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 | +} |
0 commit comments