Skip to content

Commit 9dc3104

Browse files
committed
Add PATH registry update
1 parent c827c1f commit 9dc3104

File tree

4 files changed

+149
-18
lines changed

4 files changed

+149
-18
lines changed

_msbuild.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ class ResourceFile(CSourceFile):
8686
CFunction('reg_rename_key'),
8787
CFunction('get_current_package'),
8888
CFunction('read_alias_package'),
89+
CFunction('broadcast_settings_change'),
8990
source='src/_native',
9091
RootNamespace='_native',
9192
)

_msbuild_test.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
CFunction('reg_rename_key'),
5353
CFunction('get_current_package'),
5454
CFunction('read_alias_package'),
55+
CFunction('broadcast_settings_change'),
5556
source='src/_native',
5657
),
5758
DllPackage('_shellext_test',

src/_native/misc.cpp

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,4 +181,56 @@ PyObject *read_alias_package(PyObject *, PyObject *args, PyObject *kwargs) {
181181
return PyUnicode_FromWideChar(buffer.package_name, -1);
182182
}
183183

184+
185+
typedef LRESULT (*PSendMessageTimeoutW)(
186+
HWND hWnd,
187+
UINT Msg,
188+
WPARAM wParam,
189+
LPARAM lParam,
190+
UINT fuFlags,
191+
UINT uTimeout,
192+
PDWORD_PTR lpdwResult
193+
);
194+
195+
PyObject *broadcast_settings_change(PyObject *, PyObject *, PyObject *) {
196+
// Avoid depending on user32 because it's so slow
197+
HMODULE user32 = LoadLibraryExW(L"user32.dll", NULL, LOAD_LIBRARY_SEARCH_SYSTEM32);
198+
if (!user32) {
199+
PyErr_SetFromWindowsErr(0);
200+
return NULL;
201+
}
202+
PSendMessageTimeoutW sm = (PSendMessageTimeoutW)GetProcAddress(user32, "SendMessageTimeoutW");
203+
if (!sm) {
204+
PyErr_SetFromWindowsErr(0);
205+
FreeLibrary(user32);
206+
return NULL;
207+
}
208+
209+
// SendMessageTimeout needs special error handling
210+
SetLastError(0);
211+
LPARAM lParam = (LPARAM)L"Environment";
212+
213+
if (!(*sm)(
214+
HWND_BROADCAST,
215+
WM_SETTINGCHANGE,
216+
NULL,
217+
lParam,
218+
SMTO_ABORTIFHUNG,
219+
50,
220+
NULL
221+
)) {
222+
int err = GetLastError();
223+
if (!err) {
224+
PyErr_SetString(PyExc_OSError, "Unspecified error");
225+
} else {
226+
PyErr_SetFromWindowsErr(err);
227+
}
228+
FreeLibrary(user32);
229+
return NULL;
230+
}
231+
232+
FreeLibrary(user32);
233+
return Py_GetConstant(Py_CONSTANT_NONE);
234+
}
235+
184236
}

src/manage/firstrun.py

Lines changed: 95 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,8 @@ def check_app_alias(cmd):
3535
LOGGER.debug("Failed to get current package name.", exc_info=True)
3636
pkg = None
3737
if not pkg:
38-
pkg = ""
39-
#LOGGER.debug("Check skipped: MSI install can't do this check")
40-
#return True
38+
LOGGER.debug("Check skipped: MSI install can't do this check")
39+
return "skip"
4140
LOGGER.debug("Checking for %s", pkg)
4241
root = Path(os.environ["LocalAppData"]) / "Microsoft/WindowsApps"
4342
for name in ["py.exe", "pyw.exe", "python.exe", "pythonw.exe", "python3.exe", "pymanager.exe"]:
@@ -75,7 +74,7 @@ def check_py_on_path(cmd):
7574
from _native import get_current_package, read_alias_package
7675
if not get_current_package():
7776
LOGGER.debug("Check skipped: MSI install can't do this check")
78-
return True
77+
return "skip"
7978
for p in os.environ["PATH"].split(";"):
8079
if not p:
8180
continue
@@ -96,23 +95,89 @@ def check_py_on_path(cmd):
9695
def check_global_dir(cmd):
9796
LOGGER.debug("Checking for global dir on PATH")
9897
if not cmd.global_dir:
99-
LOGGER.debug("Check passed: global dir is not configured")
100-
return True
98+
LOGGER.debug("Check skipped: global dir is not configured")
99+
return "skip"
101100
for p in os.environ["PATH"].split(";"):
102101
if not p:
103102
continue
104103
if Path(p).absolute().match(cmd.global_dir):
105104
LOGGER.debug("Check passed: %s is on PATH", p)
106105
return True
106+
# In case user has updated their registry but not the terminal
107+
import winreg
108+
try:
109+
with winreg.OpenKeyEx(winreg.HKEY_CURRENT_USER, "Environment") as key:
110+
path, kind = winreg.QueryValueEx(key, "Path")
111+
LOGGER.debug("Current registry path: %s", path)
112+
if kind == winreg.REG_EXPAND_SZ:
113+
path = os.path.expandvars(path)
114+
elif kind != winreg.REG_SZ:
115+
LOGGER.debug("Check skipped: PATH registry key is not a string.")
116+
return "skip"
117+
for p in path.split(";"):
118+
if not p:
119+
continue
120+
if Path(p).absolute().match(cmd.global_dir):
121+
LOGGER.debug("Check skipped: %s will be on PATH after restart", p)
122+
return True
123+
except Exception:
124+
LOGGER.debug("Failed to read PATH setting from registry", exc_info=True)
107125
LOGGER.debug("Check failed: %s not found in PATH", cmd.global_dir)
108126
return False
109127

110128

111129
def do_global_dir_on_path(cmd):
112130
import winreg
113-
LOGGER.debug("Adding %s to PATH", cmd.global_dir)
114-
# TODO: Add to PATH (correctly!)
115-
# TODO: Send notification
131+
added = notified = False
132+
try:
133+
LOGGER.debug("Adding %s to PATH", cmd.global_dir)
134+
with winreg.OpenKeyEx(winreg.HKEY_CURRENT_USER, "Environment") as key:
135+
initial, kind = winreg.QueryValueEx(key, "Path")
136+
LOGGER.debug("Initial path: %s", initial)
137+
if kind not in (winreg.REG_SZ, winreg.REG_EXPAND_SZ) or not isinstance(initial, str):
138+
LOGGER.debug("Value kind is %s and not REG_[EXPAND_]SZ. Aborting.")
139+
return
140+
for p in initial.split(";"):
141+
if not p:
142+
continue
143+
if p.casefold() == str(cmd.global_dir).casefold():
144+
LOGGER.debug("Path is already found.")
145+
return
146+
newpath = initial + (";" if initial else "") + str(Path(cmd.global_dir).absolute())
147+
LOGGER.debug("New path: %s", newpath)
148+
# Expand the value and ensure we are found
149+
for p in os.path.expandvars(newpath).split(";"):
150+
if not p:
151+
continue
152+
if p.casefold() == str(cmd.global_dir).casefold():
153+
LOGGER.debug("Path is added successfully")
154+
break
155+
else:
156+
return
157+
158+
with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, "Environment",
159+
access=winreg.KEY_READ|winreg.KEY_WRITE) as key:
160+
initial2, kind2 = winreg.QueryValueEx(key, "Path")
161+
if initial2 != initial or kind2 != kind:
162+
LOGGER.debug("PATH has changed while we were working. Aborting.")
163+
return
164+
winreg.SetValueEx(key, "Path", 0, kind, newpath)
165+
added = True
166+
167+
from _native import broadcast_settings_change
168+
broadcast_settings_change()
169+
notified = True
170+
except Exception:
171+
LOGGER.debug("Failed to update PATH environment variable", exc_info=True)
172+
finally:
173+
if added and not notified:
174+
LOGGER.warn("Failed to notify of PATH environment variable change.")
175+
LOGGER.info("You may need to sign out or restart to see the changes.")
176+
elif not added:
177+
LOGGER.warn("Failed to update PATH environment variable successfully.")
178+
LOGGER.info("You may add it yourself by opening 'Edit environment "
179+
"variables' and adding this directory to 'PATH': !B!%s!W!",
180+
cmd.global_dir)
116181

117182

118183
def check_any_install(cmd):
@@ -160,7 +225,8 @@ def first_run(cmd):
160225
welcome()
161226

162227
if cmd.check_app_alias:
163-
if not check_app_alias(cmd):
228+
r = check_app_alias(cmd)
229+
if not r:
164230
welcome()
165231
LOGGER.warn("Your app execution alias settings are configured to launch "
166232
"other commands besides 'py' and 'python'.")
@@ -173,7 +239,10 @@ def first_run(cmd):
173239
):
174240
os.startfile("ms-settings:advanced-apps")
175241
elif cmd.explicit:
176-
LOGGER.info("Checked app execution aliases")
242+
if r == "skip":
243+
LOGGER.info("Skipped app execution aliases check")
244+
else:
245+
LOGGER.info("Checked app execution aliases")
177246

178247
if cmd.check_long_paths:
179248
if not check_long_paths(cmd):
@@ -194,7 +263,8 @@ def first_run(cmd):
194263
LOGGER.info("Checked system long paths setting")
195264

196265
if cmd.check_py_on_path:
197-
if not check_py_on_path(cmd):
266+
r = check_py_on_path(cmd)
267+
if not r:
198268
welcome()
199269
LOGGER.warn("The legacy 'py' command is still installed.")
200270
LOGGER.info("This may interfere with launching the new 'py' command, "
@@ -205,26 +275,33 @@ def first_run(cmd):
205275
):
206276
os.startfile("ms-settings:appsfeatures")
207277
elif cmd.explicit:
208-
LOGGER.info("Checked PATH for legacy 'py' command")
278+
if r == "skip":
279+
LOGGER.info("Skipped check for legacy 'py' command")
280+
else:
281+
LOGGER.info("Checked PATH for legacy 'py' command")
209282

210283
if cmd.check_global_dir:
211-
if not check_global_dir(cmd):
284+
r = check_global_dir(cmd)
285+
if not r:
212286
welcome()
213287
LOGGER.warn("The directory for versioned Python commands is not configured.")
214288
LOGGER.info("This will prevent commands like !B!python3.14.exe!W! "
215289
"working, but will not affect the !B!python!W! or "
216290
"!B!py!W! commands (for example, !B!py -V:3.14!W!).")
217291
LOGGER.info("We can add the directory to PATH now, but you will need "
218-
"to restart your terminal to see the change, and may need "
219-
"to manually edit your environment variables if you later "
220-
"decide to remove the entry.")
292+
"to restart your terminal to see the change, and must "
293+
"manually edit environment variables to later remove the "
294+
"entry.")
221295
if (
222296
cmd.confirm and
223297
not cmd.ask_ny("Add commands directory to your PATH now?")
224298
):
225299
do_global_dir_on_path(cmd)
226300
elif cmd.explicit:
227-
LOGGER.info("Checked PATH for versioned commands directory")
301+
if r == "skip":
302+
LOGGER.info("Skipped check for commands directory on PATH")
303+
else:
304+
LOGGER.info("Checked PATH for versioned commands directory")
228305

229306
# This check must be last, because 'do_install' will exit the program.
230307
if cmd.check_any_install:

0 commit comments

Comments
 (0)