Skip to content

Commit 7e8c553

Browse files
authored
Add 'first run' configuration experience to prompt users to modify system settings (#98)
Fixes #93
1 parent 4c1976e commit 7e8c553

File tree

12 files changed

+969
-38
lines changed

12 files changed

+969
-38
lines changed

_msbuild.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ class ResourceFile(CSourceFile):
8484
CFunction('date_as_str'),
8585
CFunction('datetime_as_str'),
8686
CFunction('reg_rename_key'),
87+
CFunction('get_current_package'),
88+
CFunction('read_alias_package'),
89+
CFunction('broadcast_settings_change'),
8790
source='src/_native',
8891
RootNamespace='_native',
8992
)

_msbuild_test.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@
5050
CFunction('date_as_str'),
5151
CFunction('datetime_as_str'),
5252
CFunction('reg_rename_key'),
53+
CFunction('get_current_package'),
54+
CFunction('read_alias_package'),
55+
CFunction('broadcast_settings_change'),
5356
source='src/_native',
5457
),
5558
DllPackage('_shellext_test',

scripts/test-firstrun.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Simple script to allow running manage/firstrun.py without rebuilding.
2+
3+
You'll need to build the test module (_msbuild_test.py).
4+
"""
5+
6+
import os
7+
import pathlib
8+
import sys
9+
10+
11+
ROOT = pathlib.Path(__file__).absolute().parent.parent / "src"
12+
sys.path.append(str(ROOT))
13+
14+
15+
import _native
16+
if not hasattr(_native, "coinitialize"):
17+
import _native_test
18+
for k in dir(_native_test):
19+
if k[:1] not in ("", "_"):
20+
setattr(_native, k, getattr(_native_test, k))
21+
22+
23+
import manage.commands
24+
cmd = manage.commands.FirstRun([], ROOT)
25+
sys.exit(cmd.execute() or 0)

src/_native/misc.cpp

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#include <Python.h>
22
#include <windows.h>
3+
#include <appmodel.h>
34

45
#include "helpers.h"
56

@@ -119,4 +120,117 @@ PyObject *reg_rename_key(PyObject *, PyObject *args, PyObject *kwargs) {
119120
return r;
120121
}
121122

123+
124+
PyObject *get_current_package(PyObject *, PyObject *, PyObject *) {
125+
wchar_t package_name[256];
126+
UINT32 cch = sizeof(package_name) / sizeof(package_name[0]);
127+
int err = GetCurrentPackageFamilyName(&cch, package_name);
128+
switch (err) {
129+
case ERROR_SUCCESS:
130+
return PyUnicode_FromWideChar(package_name, cch ? cch - 1 : 0);
131+
case APPMODEL_ERROR_NO_PACKAGE:
132+
return Py_GetConstant(Py_CONSTANT_NONE);
133+
default:
134+
PyErr_SetFromWindowsErr(err);
135+
return NULL;
136+
}
137+
}
138+
139+
140+
PyObject *read_alias_package(PyObject *, PyObject *args, PyObject *kwargs) {
141+
static const char * keywords[] = {"path", NULL};
142+
wchar_t *path = NULL;
143+
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O&:read_alias_package", keywords,
144+
as_utf16, &path)) {
145+
return NULL;
146+
}
147+
148+
HANDLE h = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING,
149+
FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, NULL);
150+
PyMem_Free(path);
151+
if (h == INVALID_HANDLE_VALUE) {
152+
PyErr_SetFromWindowsErr(0);
153+
return NULL;
154+
}
155+
156+
struct {
157+
DWORD tag;
158+
DWORD _reserved1;
159+
DWORD _reserved2;
160+
wchar_t package_name[256];
161+
wchar_t nul;
162+
} buffer;
163+
DWORD nread;
164+
165+
if (!DeviceIoControl(h, FSCTL_GET_REPARSE_POINT, NULL, 0,
166+
&buffer, sizeof(buffer), &nread, NULL)
167+
// we expect our buffer to be too small, but we only want the package
168+
&& GetLastError() != ERROR_MORE_DATA) {
169+
PyErr_SetFromWindowsErr(0);
170+
CloseHandle(h);
171+
return NULL;
172+
}
173+
174+
CloseHandle(h);
175+
176+
if (buffer.tag != IO_REPARSE_TAG_APPEXECLINK) {
177+
return Py_GetConstant(Py_CONSTANT_NONE);
178+
}
179+
180+
buffer.nul = 0;
181+
return PyUnicode_FromWideChar(buffer.package_name, -1);
182+
}
183+
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+
122236
}

src/manage/commands.py

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,19 @@
2525
DEFAULT_TAG = "3.14"
2626

2727

28+
# TODO: Remove the /dev/ for stable release
29+
HELP_URL = "https://docs.python.org/dev/using/windows"
30+
31+
2832
COPYRIGHT = f"""Python installation manager {__version__}
2933
Copyright (c) Python Software Foundation. All Rights Reserved.
3034
"""
3135

3236

37+
if EXE_NAME.casefold() == "py-manager".casefold():
38+
EXE_NAME = "py"
39+
40+
3341
WELCOME = f"""!B!Python install manager was successfully updated to {__version__}.!W!
3442
"""
3543

@@ -188,6 +196,7 @@ def execute(self):
188196
"enable-shortcut-kinds": ("enable_shortcut_kinds", _NEXT, config_split),
189197
"disable-shortcut-kinds": ("disable_shortcut_kinds", _NEXT, config_split),
190198
"help": ("show_help", True), # nested to avoid conflict with command
199+
"configure": ("configure", True),
191200
# Set when the manager is doing an automatic install.
192201
# Generally won't be set by manual invocation
193202
"automatic": ("automatic", True),
@@ -202,6 +211,10 @@ def execute(self):
202211
"force": ("confirm", False),
203212
"help": ("show_help", True), # nested to avoid conflict with command
204213
},
214+
215+
"**first_run": {
216+
"explicit": ("explicit", True),
217+
},
205218
}
206219

207220

@@ -240,6 +253,16 @@ def execute(self):
240253
"disable_shortcut_kinds": (str, config_split_append),
241254
},
242255

256+
"first_run": {
257+
"enabled": (config_bool, None, "env"),
258+
"explicit": (config_bool, None),
259+
"check_app_alias": (config_bool, None, "env"),
260+
"check_long_paths": (config_bool, None, "env"),
261+
"check_py_on_path": (config_bool, None, "env"),
262+
"check_any_install": (config_bool, None, "env"),
263+
"check_global_dir": (config_bool, None, "env"),
264+
},
265+
243266
# These configuration settings are intended for administrative override only
244267
# For example, if you are managing deployments that will use your own index
245268
# and/or your own builds.
@@ -419,11 +442,11 @@ def __init__(self, args, root=None):
419442
# If our command has any config, load them to override anything that
420443
# wasn't set on the command line.
421444
try:
422-
cmd_config = config[self.CMD]
445+
cmd_config = config[self.CMD.lstrip("*")]
423446
except (AttributeError, LookupError):
424447
pass
425448
else:
426-
arg_names = frozenset(CONFIG_SCHEMA[self.CMD])
449+
arg_names = frozenset(CONFIG_SCHEMA[self.CMD.lstrip("*")])
427450
for k, v in cmd_config.items():
428451
if k in arg_names and k not in _set_args:
429452
LOGGER.debug("Overriding command option %s with %r", k, v)
@@ -511,7 +534,7 @@ def get_log_file(self):
511534
logs_dir = Path(os.getenv("TMP") or os.getenv("TEMP") or os.getcwd())
512535
from _native import datetime_as_str
513536
self._log_file = logs_dir / "python_{}_{}_{}.log".format(
514-
self.CMD, datetime_as_str(), os.getpid()
537+
self.CMD.strip("*"), datetime_as_str(), os.getpid()
515538
)
516539
return self._log_file
517540

@@ -545,28 +568,13 @@ def show_usage(cls):
545568
if usage_ljust % 4:
546569
usage_ljust += 4 - (usage_ljust % 4)
547570
usage_ljust = max(usage_ljust, 16) + 1
548-
sp = " " * usage_ljust
549571

550572
LOGGER.print("!G!Usage:!W!")
551573
for k, d in usage_docs:
552-
if k.endswith("\n") and len(logging.strip_colour(k)) >= usage_ljust:
553-
LOGGER.print(k.rstrip())
554-
r = sp
555-
else:
556-
k = k.rstrip()
557-
r = k.ljust(usage_ljust + len(k) - len(logging.strip_colour(k)))
558-
for b in d.split(" "):
559-
if len(r) >= logging.CONSOLE_MAX_WIDTH:
560-
LOGGER.print(r.rstrip())
561-
r = sp
562-
r += b + " "
563-
if r.rstrip():
564-
LOGGER.print(r)
565-
566-
LOGGER.print()
567-
# TODO: Remove the /dev/ for stable release
568-
LOGGER.print("Find additional information at !B!https://docs.python.org/dev/using/windows!W!.")
569-
LOGGER.print()
574+
for s in logging.wrap_and_indent(d, indent=usage_ljust, hang=k.rstrip()):
575+
LOGGER.print(s)
576+
577+
LOGGER.print("\nFind additional information at !B!%s!W!.\n", HELP_URL)
570578

571579
@classmethod
572580
def help_text(cls):
@@ -746,6 +754,7 @@ class InstallCommand(BaseCommand):
746754
-u, --update Overwrite existing install if a newer version is available.
747755
--dry-run Choose runtime but do not install
748756
--refresh Update shortcuts and aliases for all installed versions.
757+
--configure Re-run the system configuration helper.
749758
--by-id Require TAG to exactly match the install ID. (For advanced use.)
750759
!B!<TAG> <TAG>!W! ... One or more tags to install (Company\Tag format)
751760
@@ -775,6 +784,7 @@ class InstallCommand(BaseCommand):
775784
dry_run = False
776785
refresh = False
777786
by_id = False
787+
configure = False
778788
automatic = False
779789
from_script = None
780790
enable_shortcut_kinds = None
@@ -801,9 +811,13 @@ def __init__(self, args, root=None):
801811
self.download = Path(self.download).absolute()
802812

803813
def execute(self):
804-
from .install_command import execute
805814
self.show_welcome()
806-
execute(self)
815+
if self.configure:
816+
cmd = FirstRun(["**first_run", "--explicit"], self.root)
817+
cmd.execute()
818+
else:
819+
from .install_command import execute
820+
execute(self)
807821

808822

809823
class UninstallCommand(BaseCommand):
@@ -867,6 +881,7 @@ class HelpCommand(BaseCommand):
867881
"""
868882

869883
_create_log_file = False
884+
commands_only = False
870885

871886
def __init__(self, args, root=None):
872887
super().__init__([self.CMD], root)
@@ -891,7 +906,7 @@ def execute(self):
891906

892907

893908
class HelpWithErrorCommand(HelpCommand):
894-
CMD = "__help_with_error"
909+
CMD = "**help_with_error"
895910

896911
def __init__(self, args, root=None):
897912
# Essentially disable argument processing for this command
@@ -945,6 +960,28 @@ def __init__(self, root):
945960
super().__init__([], root)
946961

947962

963+
class FirstRun(BaseCommand):
964+
CMD = "**first_run"
965+
enabled = True
966+
explicit = False
967+
check_app_alias = True
968+
check_long_paths = True
969+
check_py_on_path = True
970+
check_any_install = True
971+
check_global_dir = True
972+
973+
def execute(self):
974+
if not self.enabled:
975+
return
976+
from .firstrun import first_run
977+
first_run(self)
978+
if not self.explicit:
979+
self.show_usage()
980+
if self.confirm and not self.ask_ny("View online help?"):
981+
import os
982+
os.startfile(HELP_URL)
983+
984+
948985
def load_default_config(root):
949986
return DefaultConfig(root)
950987

0 commit comments

Comments
 (0)