Skip to content

Commit a9696b8

Browse files
committed
refactor(installer): replace raw FFI with winreg crate for registry access
The zero-dependency pattern made sense for vite_trampoline (copied 5-10 times as shim files) but not for a single downloadable installer where readability matters more. Switch from 225 lines of unsafe raw Win32 FFI to ~80 lines of safe Rust using the winreg crate (~50-100 KB after LTO). WM_SETTINGCHANGE broadcast still uses a single raw FFI call since winreg doesn't wrap SendMessageTimeoutW.
1 parent 6f02073 commit a9696b8

5 files changed

Lines changed: 73 additions & 196 deletions

File tree

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ vite_workspace = { git = "https://github.com/voidzero-dev/vite-task.git", rev =
207207
walkdir = "2.5.0"
208208
wax = "0.6.0"
209209
which = "8.0.0"
210+
winreg = "0.56.0"
210211
xxhash-rust = "0.8.15"
211212
zip = "7.2"
212213

crates/vite_installer/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,8 @@ vite_path = { workspace = true }
2222
vite_setup = { workspace = true }
2323
vite_shared = { workspace = true }
2424

25+
[target.'cfg(windows)'.dependencies]
26+
winreg = { workspace = true }
27+
2528
[lints]
2629
workspace = true

crates/vite_installer/src/windows_path.rs

Lines changed: 44 additions & 185 deletions
Original file line numberDiff line numberDiff line change
@@ -5,192 +5,40 @@
55
66
use std::io;
77

8-
/// Raw Win32 FFI declarations for registry and environment broadcast.
9-
///
10-
/// We declare these inline to avoid pulling in the `windows-sys` crate,
11-
/// following the same zero-dependency pattern as `vite_trampoline`.
12-
mod ffi {
13-
#![allow(non_snake_case, clippy::upper_case_acronyms)]
14-
15-
pub type HKEY = isize;
16-
pub type DWORD = u32;
17-
pub type LONG = i32;
18-
pub type LPCWSTR = *const u16;
19-
pub type HWND = isize;
20-
pub type WPARAM = usize;
21-
pub type LPARAM = isize;
22-
pub type UINT = u32;
8+
use winreg::{
9+
RegKey,
10+
enums::{HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_EXPAND_SZ},
11+
};
2312

24-
pub const HKEY_CURRENT_USER: HKEY = -2_147_483_647;
25-
pub const KEY_READ: DWORD = 0x0002_0019;
26-
pub const KEY_WRITE: DWORD = 0x0002_0006;
27-
pub const REG_EXPAND_SZ: DWORD = 2;
28-
pub const ERROR_SUCCESS: LONG = 0;
29-
pub const ERROR_FILE_NOT_FOUND: LONG = 2;
30-
pub const HWND_BROADCAST: HWND = 0xFFFF;
31-
pub const WM_SETTINGCHANGE: UINT = 0x001A;
32-
pub const SMTO_ABORTIFHUNG: UINT = 0x0002;
13+
/// Broadcast `WM_SETTINGCHANGE` so other processes pick up the PATH change.
14+
fn broadcast_settings_change() {
15+
const HWND_BROADCAST: isize = 0xFFFF;
16+
const WM_SETTINGCHANGE: u32 = 0x001A;
17+
const SMTO_ABORTIFHUNG: u32 = 0x0002;
3318

3419
unsafe extern "system" {
35-
pub fn RegOpenKeyExW(
36-
hKey: HKEY,
37-
lpSubKey: LPCWSTR,
38-
ulOptions: DWORD,
39-
samDesired: DWORD,
40-
phkResult: *mut HKEY,
41-
) -> LONG;
42-
43-
pub fn RegQueryValueExW(
44-
hKey: HKEY,
45-
lpValueName: LPCWSTR,
46-
lpReserved: *mut DWORD,
47-
lpType: *mut DWORD,
48-
lpData: *mut u8,
49-
lpcbData: *mut DWORD,
50-
) -> LONG;
51-
52-
pub fn RegSetValueExW(
53-
hKey: HKEY,
54-
lpValueName: LPCWSTR,
55-
Reserved: DWORD,
56-
dwType: DWORD,
57-
lpData: *const u8,
58-
cbData: DWORD,
59-
) -> LONG;
60-
61-
pub fn RegCloseKey(hKey: HKEY) -> LONG;
62-
63-
pub fn SendMessageTimeoutW(
64-
hWnd: HWND,
65-
Msg: UINT,
66-
wParam: WPARAM,
67-
lParam: LPARAM,
68-
fuFlags: UINT,
69-
uTimeout: UINT,
20+
fn SendMessageTimeoutW(
21+
hWnd: isize,
22+
Msg: u32,
23+
wParam: usize,
24+
lParam: isize,
25+
fuFlags: u32,
26+
uTimeout: u32,
7027
lpdwResult: *mut usize,
7128
) -> isize;
7229
}
73-
}
74-
75-
/// Encode a Rust string as a null-terminated wide (UTF-16) string.
76-
fn to_wide(s: &str) -> Vec<u16> {
77-
s.encode_utf16().chain(std::iter::once(0)).collect()
78-
}
79-
80-
/// Read the current User PATH from the registry.
81-
fn read_user_path() -> io::Result<String> {
82-
let sub_key = to_wide("Environment");
83-
let value_name = to_wide("Path");
84-
85-
let mut hkey: ffi::HKEY = 0;
86-
let result = unsafe {
87-
ffi::RegOpenKeyExW(ffi::HKEY_CURRENT_USER, sub_key.as_ptr(), 0, ffi::KEY_READ, &mut hkey)
88-
};
89-
90-
if result == ffi::ERROR_FILE_NOT_FOUND {
91-
return Ok(String::new());
92-
}
93-
if result != ffi::ERROR_SUCCESS {
94-
return Err(io::Error::from_raw_os_error(result));
95-
}
96-
97-
// Query the size first
98-
let mut data_type: ffi::DWORD = 0;
99-
let mut data_size: ffi::DWORD = 0;
100-
let result = unsafe {
101-
ffi::RegQueryValueExW(
102-
hkey,
103-
value_name.as_ptr(),
104-
std::ptr::null_mut(),
105-
&mut data_type,
106-
std::ptr::null_mut(),
107-
&mut data_size,
108-
)
109-
};
110-
111-
if result == ffi::ERROR_FILE_NOT_FOUND {
112-
unsafe { ffi::RegCloseKey(hkey) };
113-
return Ok(String::new());
114-
}
115-
if result != ffi::ERROR_SUCCESS {
116-
unsafe { ffi::RegCloseKey(hkey) };
117-
return Err(io::Error::from_raw_os_error(result));
118-
}
119-
120-
// Read the data
121-
let mut buf = vec![0u8; data_size as usize];
122-
let result = unsafe {
123-
ffi::RegQueryValueExW(
124-
hkey,
125-
value_name.as_ptr(),
126-
std::ptr::null_mut(),
127-
&mut data_type,
128-
buf.as_mut_ptr(),
129-
&mut data_size,
130-
)
131-
};
132-
133-
unsafe { ffi::RegCloseKey(hkey) };
13430

135-
if result != ffi::ERROR_SUCCESS {
136-
return Err(io::Error::from_raw_os_error(result));
137-
}
138-
139-
// Convert UTF-16 to String (strip trailing null)
140-
let wide: Vec<u16> = buf.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
141-
let s = String::from_utf16_lossy(&wide);
142-
Ok(s.trim_end_matches('\0').to_string())
143-
}
144-
145-
/// Write the User PATH to the registry.
146-
fn write_user_path(path: &str) -> io::Result<()> {
147-
let sub_key = to_wide("Environment");
148-
let value_name = to_wide("Path");
149-
let wide_path = to_wide(path);
150-
151-
let mut hkey: ffi::HKEY = 0;
152-
let result = unsafe {
153-
ffi::RegOpenKeyExW(ffi::HKEY_CURRENT_USER, sub_key.as_ptr(), 0, ffi::KEY_WRITE, &mut hkey)
154-
};
155-
156-
if result != ffi::ERROR_SUCCESS {
157-
return Err(io::Error::from_raw_os_error(result));
158-
}
159-
160-
let byte_len = (wide_path.len() * 2) as ffi::DWORD;
161-
let result = unsafe {
162-
ffi::RegSetValueExW(
163-
hkey,
164-
value_name.as_ptr(),
165-
0,
166-
ffi::REG_EXPAND_SZ,
167-
wide_path.as_ptr().cast::<u8>(),
168-
byte_len,
169-
)
170-
};
171-
172-
unsafe { ffi::RegCloseKey(hkey) };
173-
174-
if result != ffi::ERROR_SUCCESS {
175-
return Err(io::Error::from_raw_os_error(result));
176-
}
177-
178-
Ok(())
179-
}
180-
181-
/// Broadcast `WM_SETTINGCHANGE` so other processes pick up the PATH change.
182-
fn broadcast_settings_change() {
183-
let env_wide = to_wide("Environment");
184-
let mut _result: usize = 0;
31+
let env_wide: Vec<u16> = "Environment".encode_utf16().chain(std::iter::once(0)).collect();
32+
let mut result: usize = 0;
18533
unsafe {
186-
ffi::SendMessageTimeoutW(
187-
ffi::HWND_BROADCAST,
188-
ffi::WM_SETTINGCHANGE,
34+
SendMessageTimeoutW(
35+
HWND_BROADCAST,
36+
WM_SETTINGCHANGE,
18937
0,
190-
env_wide.as_ptr() as ffi::LPARAM,
191-
ffi::SMTO_ABORTIFHUNG,
38+
env_wide.as_ptr() as isize,
39+
SMTO_ABORTIFHUNG,
19240
5000,
193-
&mut _result,
41+
&mut result,
19442
);
19543
}
19644
}
@@ -201,25 +49,36 @@ fn broadcast_settings_change() {
20149
/// (case-insensitive, with/without trailing backslash), and prepends if not.
20250
/// Broadcasts `WM_SETTINGCHANGE` so new terminal sessions see the change.
20351
pub fn add_to_user_path(bin_dir: &str) -> io::Result<()> {
204-
let current = read_user_path()?;
52+
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
53+
let env = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)?;
54+
55+
let current: String = env.get_value("Path").unwrap_or_default();
20556
let bin_dir_normalized = bin_dir.trim_end_matches('\\');
20657

207-
// Check if already in PATH (case-insensitive, handle trailing backslash)
208-
let already_present = current.split(';').any(|entry| {
209-
let entry_normalized = entry.trim_end_matches('\\');
210-
entry_normalized.eq_ignore_ascii_case(bin_dir_normalized)
211-
});
58+
let already_present = current
59+
.split(';')
60+
.any(|entry| entry.trim_end_matches('\\').eq_ignore_ascii_case(bin_dir_normalized));
21261

21362
if already_present {
21463
return Ok(());
21564
}
21665

217-
// Prepend to PATH
21866
let new_path =
21967
if current.is_empty() { bin_dir.to_string() } else { format!("{bin_dir};{current}") };
22068

221-
write_user_path(&new_path)?;
222-
broadcast_settings_change();
69+
// Write as REG_EXPAND_SZ to support %VARIABLE% expansion in PATH entries
70+
env.set_raw_value(
71+
"Path",
72+
&winreg::RegValue {
73+
vtype: REG_EXPAND_SZ,
74+
bytes: new_path
75+
.encode_utf16()
76+
.chain(std::iter::once(0))
77+
.flat_map(|c| c.to_le_bytes())
78+
.collect(),
79+
},
80+
)?;
22381

82+
broadcast_settings_change();
22483
Ok(())
22584
}

rfcs/windows-installer.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -303,12 +303,16 @@ On failure before the **Activate** phase, the version directory is cleaned up an
303303

304304
### PATH Modification via Registry
305305

306-
Same approach as rustup and `install.ps1`, using raw Win32 FFI (no external crate) following the same zero-dependency pattern as `vite_trampoline`:
306+
Same approach as rustup and `install.ps1`, using the `winreg` crate for registry access:
307307

308-
1. Read current `HKCU\Environment\Path` via `RegQueryValueExW`
309-
2. Check if bin dir is already present (case-insensitive, handles trailing backslash)
310-
3. Prepend `%VP_HOME%\bin` if not present, write back via `RegSetValueExW` as `REG_EXPAND_SZ`
311-
4. Broadcast `WM_SETTINGCHANGE` via `SendMessageTimeoutW` so other processes pick up the change
308+
```rust
309+
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
310+
let env = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)?;
311+
let current: String = env.get_value("Path").unwrap_or_default();
312+
// ... check if already present (case-insensitive, handles trailing backslash)
313+
// ... prepend bin_dir, write back as REG_EXPAND_SZ
314+
// ... broadcast WM_SETTINGCHANGE via SendMessageTimeoutW (raw FFI, single call)
315+
```
312316

313317
See `crates/vite_installer/src/windows_path.rs` for the full implementation.
314318

@@ -461,10 +465,9 @@ Key dependencies and their approximate contribution:
461465
| `indicatif` | Progress bars | ~100 KB |
462466
| `sha2` | Integrity verification | ~50 KB |
463467
| `serde_json` | Registry JSON parsing | ~200 KB |
468+
| `winreg` + `windows-sys` | Windows registry | ~50-100 KB |
464469
| Rust std + overhead | | ~500 KB |
465470

466-
Note: Windows registry access uses raw FFI (~0 KB overhead) instead of the `winreg` crate, following the same zero-dependency pattern as `vite_trampoline`.
467-
468471
Use `opt-level = "z"` (optimize for size) in package profile override, matching the trampoline approach.
469472

470473
## Alternatives Considered
@@ -490,12 +493,12 @@ Like rustup, make `vp.exe` detect when called as `vp-setup.exe` and switch to in
490493

491494
Embed the PowerShell script in a self-extracting exe. Fragile, still requires PowerShell runtime.
492495

493-
### 4. Use `winreg` Crate vs Raw FFI for PATH (Decision: Raw FFI)
496+
### 4. Use `winreg` Crate vs Raw FFI for PATH (Decision: `winreg`)
494497

495-
- `winreg` crate: Higher-level API, adds ~50 KB dependency
496-
- Raw Win32 FFI: Zero external dependencies, matches `vite_trampoline` pattern, slightly more code
498+
- `winreg` crate: Higher-level safe API, ~50-100 KB after LTO, significantly less code (~80 lines vs ~225 lines)
499+
- Raw Win32 FFI: Zero dependencies but 225 lines of unsafe code with manual UTF-16 encoding and registry choreography
497500
- PowerShell subprocess: Proven in `install.ps1` but adds process spawn overhead and PowerShell dependency
498-
- Decision: Use raw FFI for direct registry access — keeps the installer dependency-free for Win32 operations, consistent with the trampoline's approach
501+
- Decision: Use `winreg` for registry access — the zero-dependency pattern makes sense for `vite_trampoline` (copied 5-10 times as shims) but not for a single downloadable installer where readability matters more. `WM_SETTINGCHANGE` broadcast still uses a single raw FFI call since `winreg` doesn't wrap it.
499502

500503
## Implementation Phases
501504

0 commit comments

Comments
 (0)