Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions app/utils/broadcast-env-change.ts
Original file line number Diff line number Diff line change
@@ -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');
38 changes: 37 additions & 1 deletion app/utils/cli-install.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {execFile} from 'child_process';
import {existsSync, readlink, symlink} from 'fs';
import path from 'path';
import {promisify} from 'util';
Expand All @@ -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)
Expand Down Expand Up @@ -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<boolean> => {
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<void>((resolve, reject) => {
try {
Expand Down Expand Up @@ -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);
Expand All @@ -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}`);
Expand Down
39 changes: 39 additions & 0 deletions test/unit/broadcast-env-change.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});