From e56f5c4634043d60101eabb3029c040fd5db890c Mon Sep 17 00:00:00 2001 From: Mukunda Katta Date: Sat, 25 Apr 2026 11:14:24 -0700 Subject: [PATCH] Windows: refresh PATH without reboot after Hyper CLI install The CLI installer writes to HKCU\Environment\PATH via native-reg, which does not broadcast WM_SETTINGCHANGE. As a result, already-running shells and Explorer keep the stale environment block until the user reboots or logs out, so `hyper` is missing from PATH in fresh terminals (#2823). Fix: * After writing the registry value, broadcast WM_SETTINGCHANGE via a short PowerShell P/Invoke into user32!SendMessageTimeoutW. The script is shipped through `-EncodedCommand` (UTF-16LE base64) so quoting is not a concern. * Also update process.env.Path/PATH so the currently running Hyper process and any shells it spawns see the bin dir immediately (Stanzilla's suggestion in the issue thread). * Adjust the installation notification: when the broadcast lands the reboot wording is no longer accurate; keep the old wording as the fallback if PowerShell is missing or the broadcast fails. Also adds a small unit test that pins the script payload (HWND_BROADCAST, WM_SETTINGCHANGE, the literal "Environment" lParam) and the UTF-16LE base64 round-trip used by `-EncodedCommand`. --- app/utils/broadcast-env-change.ts | 49 ++++++++++++++++++++++++++ app/utils/cli-install.ts | 38 +++++++++++++++++++- test/unit/broadcast-env-change.test.ts | 39 ++++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 app/utils/broadcast-env-change.ts create mode 100644 test/unit/broadcast-env-change.test.ts diff --git a/app/utils/broadcast-env-change.ts b/app/utils/broadcast-env-change.ts new file mode 100644 index 000000000000..7354240dd773 --- /dev/null +++ b/app/utils/broadcast-env-change.ts @@ -0,0 +1,49 @@ +// Helpers for telling the rest of Windows that the user's PATH changed. +// +// The Win32 environment block of an already-running process is a snapshot +// taken at process creation. Even if you write a new value to +// `HKCU\Environment\PATH`, processes like the existing Explorer / cmd / WSL / +// VSCode terminals will not see it until they exit, get a WM_SETTINGCHANGE +// broadcast, or the user logs out/reboots. +// +// `setx` and the .NET `[Environment]::SetEnvironmentVariable(...)` API both +// do this broadcast for you; `native-reg` does not. We write via `native-reg` +// (because it preserves REG_EXPAND_SZ correctly), so we have to fire the +// broadcast ourselves. +// +// SendMessageTimeoutW(HWND_BROADCAST, WM_SETTINGCHANGE, 0, "Environment", ...) +// is the documented contract: +// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendmessagetimeoutw + +// PowerShell snippet that P/Invokes user32!SendMessageTimeoutW. Multi-line +// here-string is fine because we ship this through `-EncodedCommand` (UTF-16 +// base64), which is quoting-safe. +// +// Constants used below: +// HWND_BROADCAST = 0xFFFF +// WM_SETTINGCHANGE = 0x001A +// SMTO_ABORTIFHUNG = 0x0002 +// timeout = 5000ms +export const BROADCAST_PS_SCRIPT = [ + 'Add-Type -Namespace Win32Funcs -Name NativeMethods -MemberDefinition @"', + '[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError=true, CharSet=System.Runtime.InteropServices.CharSet.Auto)]', + 'public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out System.IntPtr lpdwResult);', + '"@;', + '$out = [System.IntPtr]::Zero;', + '[void][Win32Funcs.NativeMethods]::SendMessageTimeout([System.IntPtr]0xFFFF, 0x1A, [System.UIntPtr]::Zero, "Environment", 0x0002, 5000, [ref]$out);' +].join('\n'); + +// Standard hardening flags for an unattended PowerShell child process. +// `-EncodedCommand` itself goes after these. +export const POWERSHELL_BROADCAST_ARGS = [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand' +] as const; + +// PowerShell expects `-EncodedCommand` as a UTF-16LE base64 string. Doing +// this ourselves is more robust than trying to escape the script through a +// shell; it also keeps the ChildProcess args pristine (no quoting needed). +export const encodePowerShellCommand = (script: string): string => Buffer.from(script, 'utf16le').toString('base64'); diff --git a/app/utils/cli-install.ts b/app/utils/cli-install.ts index 3a5b9930793d..c33ad3ca72ae 100644 --- a/app/utils/cli-install.ts +++ b/app/utils/cli-install.ts @@ -1,3 +1,4 @@ +import {execFile} from 'child_process'; import {existsSync, readlink, symlink} from 'fs'; import path from 'path'; import {promisify} from 'util'; @@ -12,9 +13,12 @@ import sudoPrompt from 'sudo-prompt'; import {cliScriptPath, cliLinkPath} from '../config/paths'; import notify from '../notify'; +import {BROADCAST_PS_SCRIPT, encodePowerShellCommand, POWERSHELL_BROADCAST_ARGS} from './broadcast-env-change'; + const readLink = promisify(readlink); const symLink = promisify(symlink); const sudoExec = promisify(sudoPrompt.exec); +const execFileP = promisify(execFile); const checkInstall = () => { return readLink(cliLinkPath) @@ -75,6 +79,24 @@ sudo ln -sf "${cliScriptPath}" "${cliLinkPath}"`, } }; +// Tells the rest of Windows that the user's PATH changed so already-running +// shells / Explorer pick up the new value without requiring a logout or +// reboot. See ./broadcast-env-change.ts for the why and the WinAPI details. +// Failure here is non-fatal: the registry value is already persisted, so a +// reboot/logout still works as the fallback. +const broadcastEnvChange = async (): Promise => { + try { + await execFileP('powershell.exe', [...POWERSHELL_BROADCAST_ARGS, encodePowerShellCommand(BROADCAST_PS_SCRIPT)], { + windowsHide: true, + timeout: 10000 + }); + return true; + } catch (err) { + console.warn('Failed to broadcast WM_SETTINGCHANGE for PATH update', err); + return false; + } +}; + const addBinToUserPath = () => { return new Promise((resolve, reject) => { try { @@ -117,6 +139,17 @@ const addBinToUserPath = () => { console.log('Adding HyperCLI path (registry)'); Registry.setValueRaw(envKey, pathItemName, type, Registry.formatString(newPathValue)); Registry.closeKey(envKey); + + // Update the running Hyper process's PATH so any shells spawned from + // within Hyper (or commands run via child_process) see the CLI right + // away, without waiting on the broadcast or a reboot. + const binPathDir = path.dirname(cliScriptPath); + const currentRuntimePath = process.env.Path || process.env.PATH || ''; + if (!currentRuntimePath.split(';').some((entry) => entry.toLowerCase() === binPathDir.toLowerCase())) { + const updated = currentRuntimePath ? `${currentRuntimePath};${binPathDir}` : binPathDir; + process.env.Path = updated; + process.env.PATH = updated; + } resolve(); } catch (error) { reject(error); @@ -133,10 +166,13 @@ export const installCLI = async (withNotification: boolean) => { if (process.platform === 'win32') { try { await addBinToUserPath(); + const broadcasted = await broadcastEnvChange(); logNotify( withNotification, 'Hyper CLI installed', - 'You may need to restart your computer to complete this installation process.' + broadcasted + ? 'Hyper CLI is now on your PATH. Open a new terminal window to use it.' + : 'You may need to open a new terminal session (or restart your computer) to complete this installation process.' ); } catch (err) { logNotify(withNotification, 'Hyper CLI installation failed', `Failed to add Hyper CLI path to user PATH ${err}`); diff --git a/test/unit/broadcast-env-change.test.ts b/test/unit/broadcast-env-change.test.ts new file mode 100644 index 000000000000..d1e2fa2a8c73 --- /dev/null +++ b/test/unit/broadcast-env-change.test.ts @@ -0,0 +1,39 @@ +import test from 'ava'; + +import { + BROADCAST_PS_SCRIPT, + encodePowerShellCommand, + POWERSHELL_BROADCAST_ARGS +} from '../../app/utils/broadcast-env-change'; + +test('BROADCAST_PS_SCRIPT calls user32!SendMessageTimeout with WM_SETTINGCHANGE for "Environment"', (t) => { + // Sanity-check the literal payload we ship to PowerShell. If any of these + // markers go missing, we are no longer broadcasting an environment-change + // event to the rest of the system, and Windows users will be back to + // needing a reboot for `hyper` to land on PATH. + t.true(BROADCAST_PS_SCRIPT.includes('user32.dll')); + t.true(BROADCAST_PS_SCRIPT.includes('SendMessageTimeout')); + // 0xFFFF == HWND_BROADCAST + t.true(BROADCAST_PS_SCRIPT.includes('0xFFFF')); + // 0x1A == WM_SETTINGCHANGE + t.true(BROADCAST_PS_SCRIPT.includes('0x1A')); + // The lParam value must literally be the string "Environment" so that + // listeners know which environment block to refresh. + t.true(BROADCAST_PS_SCRIPT.includes('"Environment"')); +}); + +test('encodePowerShellCommand produces UTF-16LE base64 (round-trip)', (t) => { + const encoded = encodePowerShellCommand(BROADCAST_PS_SCRIPT); + // base64 alphabet only + t.regex(encoded, /^[A-Za-z0-9+/=]+$/); + // Round-trip back to the original script via UTF-16LE decoding, which is + // exactly what `powershell.exe -EncodedCommand` does internally. + const decoded = Buffer.from(encoded, 'base64').toString('utf16le'); + t.is(decoded, BROADCAST_PS_SCRIPT); +}); + +test('POWERSHELL_BROADCAST_ARGS ends with -EncodedCommand and disables profile/interactivity', (t) => { + t.is(POWERSHELL_BROADCAST_ARGS[POWERSHELL_BROADCAST_ARGS.length - 1], '-EncodedCommand'); + t.true(POWERSHELL_BROADCAST_ARGS.includes('-NoProfile')); + t.true(POWERSHELL_BROADCAST_ARGS.includes('-NonInteractive')); +});