diff --git a/constructor/main.py b/constructor/main.py index ee8104ad3..abf03ad2b 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -19,6 +19,7 @@ import sys from os.path import abspath, expanduser, isdir, join from pathlib import Path +from tempfile import TemporaryDirectory from textwrap import dedent from . import __version__ @@ -77,7 +78,22 @@ def get_output_filename(info): ) -def _win_install_needs_python_exe(conda_exe: str) -> bool: +def _conda_exe_supports_logging(conda_exe: str, conda_exe_type: StandaloneExe | None) -> bool: + """Test if the standalone binary supports the the --log-file argument. + + Only available for conda-standalone. + """ + if not conda_exe_type: + return False + with TemporaryDirectory() as tmpdir: + logfile = Path(tmpdir, "conda.log") + subprocess.run([conda_exe, "--version", f"--log-file={logfile}"]) + return logfile.exists() + + +def _win_install_needs_python_exe(conda_exe: str, conda_exe_type: StandaloneExe | None) -> bool: + if not conda_exe_type: + return True results = subprocess.run( [conda_exe, "constructor", "windows", "--help"], capture_output=True, @@ -270,6 +286,11 @@ def is_conda_meta_frozen(path_str: str) -> bool: else: info["_ignore_condarcs_arg"] = "" + info["_conda_exe_supports_logging"] = _conda_exe_supports_logging( + info["_conda_exe"], + info["_conda_exe_type"], + ) + if "pkg" in itypes: if (domains := info.get("pkg_domains")) is not None: domains = {key: str(val).lower() for key, val in domains.items()} @@ -289,7 +310,10 @@ def is_conda_meta_frozen(path_str: str) -> bool: } if osname == "win": - info["_win_install_needs_python_exe"] = _win_install_needs_python_exe(info["_conda_exe"]) + info["_win_install_needs_python_exe"] = _win_install_needs_python_exe( + info["_conda_exe"], + info["_conda_exe_type"], + ) info["installer_type"] = itypes[0] fcp_main(info, verbose=verbose, dry_run=dry_run, conda_exe=conda_exe) diff --git a/constructor/nsis/Utils.nsh b/constructor/nsis/Utils.nsh index 986bf4344..d7ecd1ea0 100644 --- a/constructor/nsis/Utils.nsh +++ b/constructor/nsis/Utils.nsh @@ -1,128 +1,5 @@ # Miscellaneous helpers. -# We're not using RIndexOf at the moment, so ifdef it out for now (which -# prevents the compiler warnings about an unused function). -!ifdef INDEXOF -Function IndexOf - Exch $R0 - Exch - Exch $R1 - Push $R2 - Push $R3 - - StrCpy $R3 $R0 - StrCpy $R0 -1 - IntOp $R0 $R0 + 1 - - StrCpy $R2 $R3 1 $R0 - StrCmp $R2 "" +2 - StrCmp $R2 $R1 +2 -3 - - StrCpy $R0 -1 - - Pop $R3 - Pop $R2 - Pop $R1 - Exch $R0 -FunctionEnd - -!macro IndexOf Var Str Char - Push "${Char}" - Push "${Str}" - Call IndexOf - Pop "${Var}" - !macroend -!define IndexOf "!insertmacro IndexOf" - -Function RIndexOf - Exch $R0 - Exch - Exch $R1 - Push $R2 - Push $R3 - - StrCpy $R3 $R0 - StrCpy $R0 0 - IntOp $R0 $R0 + 1 - StrCpy $R2 $R3 1 -$R0 - StrCmp $R2 "" +2 - StrCmp $R2 $R1 +2 -3 - - StrCpy $R0 -1 - - Pop $R3 - Pop $R2 - Pop $R1 - Exch $R0 -FunctionEnd - -!macro RIndexOf Var Str Char - Push "${Char}" - Push "${Str}" - Call RIndexOf - Pop "${Var}" -!macroend - -!define RIndexOf "!insertmacro RIndexOf" -!endif - -!macro StrStr - Exch $R1 ; st=haystack,old$R1, $R1=needle - Exch ; st=old$R1,haystack - Exch $R2 ; st=old$R1,old$R2, $R2=haystack - Push $R3 - Push $R4 - Push $R5 - StrLen $R3 $R1 - StrCpy $R4 0 - ; $R1=needle - ; $R2=haystack - ; $R3=len(needle) - ; $R4=cnt - ; $R5=tmp - loop: - StrCpy $R5 $R2 $R3 $R4 - StrCmp $R5 $R1 done - StrCmp $R5 "" done - IntOp $R4 $R4 + 1 - Goto loop - done: - StrCpy $R1 $R2 "" $R4 - Pop $R5 - Pop $R4 - Pop $R3 - Pop $R2 - Exch $R1 -!macroend - -!macro GetShortPathName - Pop $0 - # Return the 8.3 short path name for $0. We ensure $0 exists by calling - # SetOutPath first (kernel32::GetShortPathName() fails otherwise). - SetOutPath $0 - Push $0 - Push ' ' - Call StrStr - Pop $1 - ${If} $1 != "" - # Our installation directory has a space, so use the short name from - # here in. (This ensures no directories with spaces are written to - # registry values or configuration files.) After GetShortPathName(), - # $0 will have the new name and $1 will have the length (if it's 0, - # assume an error occurred and leave $INSTDIR as it is). - System::Call "kernel32::GetShortPathName(\ - t'$RootDir', \ - t.R0, \ - i${NSIS_MAX_STRLEN}) i.R1" - - ${If} $R1 > 0 - Push $R0 - ${EndIf} - ${Else} - Push $0 - ${EndIf} -!macroend - ; Slightly modified version of http://nsis.sourceforge.net/IsWritable Function IsWritable !define IsWritable `!insertmacro IsWritableCall` diff --git a/constructor/nsis/_nsis.py b/constructor/nsis/_nsis.py index d83008854..383f3cd3e 100644 --- a/constructor/nsis/_nsis.py +++ b/constructor/nsis/_nsis.py @@ -35,24 +35,7 @@ def gui_excepthook(exctype, value, tb): sys.excepthook = gui_excepthook -# If pythonw is being run, there may be no write function -if sys.stdout and sys.stdout.write: - out = sys.stdout.write - err = sys.stderr.write -else: - import ctypes - OutputDebugString = ctypes.windll.kernel32.OutputDebugStringW - OutputDebugString.argtypes = [ctypes.c_wchar_p] - - def out(x): - OutputDebugString('_nsis.py: ' + x) - - def err(x): - OutputDebugString('_nsis.py: Error: ' + x) - - allusers = (not exists(join(ROOT_PREFIX, '.nonadmin'))) -# out('allusers is %s\n' % allusers) # This must be the same as conda's binpath_from_arg() in conda/cli/activate.py PATH_SUFFIXES = ('', @@ -96,7 +79,7 @@ def add_to_path(pyversion, arch): except IOError: old_prefixes = [] for prefix in old_prefixes: - out('Removing old installation at %s from PATH (if any entries get found)\n' % (prefix)) + print('Removing old installation at %s from PATH (if any entries get found)\n' % (prefix)) remove_from_path(prefix) # add Anaconda to the path diff --git a/constructor/nsis/_system_path.py b/constructor/nsis/_system_path.py index ad391be86..ff35fe20e 100644 --- a/constructor/nsis/_system_path.py +++ b/constructor/nsis/_system_path.py @@ -11,28 +11,10 @@ import ctypes import os import re -import sys from ctypes import wintypes from os import path -if sys.version_info[0] >= 3: - import winreg as reg -else: - import _winreg as reg - -# If pythonw is being run, there may be no write function -if sys.stdout and sys.stdout.write: - out = sys.stdout.write - err = sys.stderr.write -else: - OutputDebugString = ctypes.windll.kernel32.OutputDebugStringW - OutputDebugString.argtypes = [ctypes.c_wchar_p] - - def out(x): - OutputDebugString('_nsis.py: ' + x) - - def err(x): - OutputDebugString('_nsis.py: Error: ' + x) +import winreg as reg HWND_BROADCAST = 0xffff WM_SETTINGCHANGE = 0x001A @@ -159,9 +141,6 @@ def add_to_system_path(paths, allusers=True, path_env_var='PATH'): final_value = final_value.replace('"', '') # Warn about directories that do not exist. directories = final_value.split(';') - for directory in directories: - if '%' not in directory and not os.path.exists(directory): - out("WARNING: Old PATH entry '%s' does not exist\n" % (directory)) reg.SetValueEx(key, path_env_var, 0, reg_type, final_value) finally: diff --git a/constructor/nsis/main.nsi.tmpl b/constructor/nsis/main.nsi.tmpl index fb93badb6..8b25654c6 100644 --- a/constructor/nsis/main.nsi.tmpl +++ b/constructor/nsis/main.nsi.tmpl @@ -17,6 +17,16 @@ Unicode true !define LogSet "!insertmacro LogSetMacro" !macro LogSetMacro SETTING !ifdef ENABLE_LOGGING + ${If} ${SETTING} == "on" + ${IfNot} ${FileExists} "$INSTDIR\install.log" + # Enforce UTF-16 encoding in the log file + # NSIS doesn't write the correct BOM to log files, + # so each character will be followed by NUL. + FileOpen $R0 "$INSTDIR\install.log" w + FileWriteUTF16LE $R0 "" + FileClose $R0 + ${EndIf} + ${EndIf} LogSet ${SETTING} !endif !macroend @@ -31,9 +41,12 @@ Unicode true var /global QuietMode # "0" = print normally, "1" = do not print var /global StdOutHandle var /global StdOutHandleSet -!define Print "!insertmacro PrintMacro" -!macro PrintMacro INPUT_TEXT - DetailPrint "${INPUT_TEXT}" +# Print and PrintToConsole are macros because it makes them easier to call. +# However, that also means that registers must be handled with caution because +# they will be overwritten in the macro. It is best to use $R* registers if +# temporary variables need to be used. +!define PrintToConsole "!insertmacro PrintToConsoleMacro" +!macro PrintToConsoleMacro INPUT_TEXT ${If} ${Silent} ${AndIf} $QuietMode != "1" ${IfNot} $StdOutHandleSet == "1" @@ -47,10 +60,24 @@ var /global StdOutHandleSet StrCpy $StdOutHandle $0 StrCpy $StdOutHandleSet "1" ${EndIf} - FileWrite $StdOutHandle "${INPUT_TEXT}$\n" + # Only add newline if input text doesn't have it already + StrLen $2 "${INPUT_TEXT}" + IntOp $2 $2 - 1 + StrCpy $2 "${INPUT_TEXT}" 1 $2 + ${If} $2 == "$\n" + FileWrite $StdOutHandle "${INPUT_TEXT}" + ${Else} + FileWrite $StdOutHandle "${INPUT_TEXT}$\n" + ${EndIf} ${EndIf} !macroend +!define Print "!insertmacro PrintMacro" +!macro PrintMacro INPUT_TEXT + DetailPrint "${INPUT_TEXT}" + ${PrintToConsole} "${INPUT_TEXT}" +!macroend + !include "WinMessages.nsh" !include "WordFunc.nsh" !include "LogicLib.nsh" @@ -98,8 +125,11 @@ ${Using:StrFunc} StrStr !define INIT_CONDA_DEFAULT_VALUE {{ '1' if initialize_by_default else '0' }} !define PRODUCT_NAME "${NAME} ${VERSION} (${ARCH})" !define UNINSTALL_NAME "{{ UNINSTALL_NAME }}" -!define UNINSTREG "SOFTWARE\Microsoft\Windows\CurrentVersion\ - \Uninstall\${UNINSTALL_NAME}" +!define UNINSTREG "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_NAME}" +# Silent installations do not output to the console and outputs to stdout +# are not written into install.log. STEP_LOG creates an intermittent file +# that is output into these streams after the commands finish. +!define STEP_LOG "$INSTDIR\.step.log" var /global INIT_CONDA var /global REG_PY @@ -254,7 +284,6 @@ UninstPage Custom un.UninstCustomOptions_Show !insertmacro MUI_LANGUAGE "English" Function SkipPageIfUACInnerInstance - ${LogSet} on ${If} ${UAC_IsInnerInstance} Abort ${EndIf} @@ -465,7 +494,6 @@ FunctionEnd !macroend Function InstModePage_RadioButton_OnClick - ${LogSet} on Exch $0 Push $1 Push $2 @@ -481,7 +509,6 @@ Function InstModePage_RadioButton_OnClick FunctionEnd Function InstModePage_Create - ${LogSet} on Push $0 Push $1 Push $2 @@ -528,7 +555,6 @@ Function InstModePage_Create FunctionEnd Function DisableBackButtonIfUACInnerInstance - ${LogSet} on Push $0 ${If} ${UAC_IsInnerInstance} GetDlgItem $0 $HWNDParent 3 @@ -538,7 +564,6 @@ Function DisableBackButtonIfUACInnerInstance FunctionEnd Function RemoveNextBtnShield - ${LogSet} on Push $0 GetDlgItem $0 $HWNDParent 1 SendMessage $0 ${BCM_SETSHIELD} 0 0 @@ -546,7 +571,6 @@ Function RemoveNextBtnShield FunctionEnd Function InstModeChanged - ${LogSet} on # When using the installer with /S (silent mode), the /D option sets $INSTDIR, # and it is therefore important not to overwrite $INSTDIR here, but it is also # important that we do call SetShellVarContext with the appropriate value. @@ -577,7 +601,6 @@ FunctionEnd !macroend Function InstModePage_Leave - ${LogSet} on Push $0 Push $1 Push $2 @@ -598,7 +621,6 @@ Function InstModePage_Leave FunctionEnd Function .onInit - ${LogSet} on Push $0 Push $1 Push $2 @@ -954,7 +976,6 @@ FunctionEnd # http://nsis.sourceforge.net/Check_for_spaces_in_a_directory_path Function CheckForSpaces - ${LogSet} on Exch $R0 Push $R1 Push $R2 @@ -978,7 +999,6 @@ FunctionEnd # http://nsis.sourceforge.net/StrCSpn,_StrCSpnReverse:_Scan_strings_for_characters Function StrCSpn - ${LogSet} on Exch $R0 ; string to check Exch Exch $R1 ; string of chars @@ -1038,7 +1058,6 @@ Pop $0 Function OnDirectoryLeave - ${LogSet} on ${If} ${IsNonEmptyDirectory} "$InstDir" ${Print} "::error:: Directory '$INSTDIR' is not empty, please choose a different location." MessageBox MB_OK|MB_ICONEXCLAMATION \ @@ -1193,7 +1212,6 @@ Function OnDirectoryLeave FunctionEnd Function .onVerifyInstDir - ${LogSet} on StrLen $0 $Desktop StrCpy $0 $INSTDIR $0 StrCmp $0 $Desktop 0 PathGood @@ -1212,37 +1230,83 @@ Function un.OnDirectoryLeave confirmed_yes: FunctionEnd -# Make function available for both installer and uninstaller +# Make functions available for both installer and uninstaller # Uninstaller functions need an `un.` prefix, so we use a macro to do both # see https://nsis.sourceforge.io/Sharing_functions_between_Installer_and_Uninstaller -!macro AbortRetryNSExecWaitMacro un +!macro FunctionTemplates un + Function ${un}PrintFromStepLog + Exch $R0 + Push $R1 + Push $R2 + ClearErrors + FileOpen $R1 "${STEP_LOG}" r + IfErrors close_file + read_line: + FileRead $R1 $R2 + IfErrors close_file + ${If} $R0 == "ToConsole" + ${OrIf} $R0 == "both" + ${PrintToConsole} "$R2" + ${EndIf} + ${If} $R0 == "ToLog" + ${OrIf} $R0 == "both" + ${LogText} "$R2" + ${EndIf} + goto read_line + close_file: + FileClose $R1 + Pop $R2 + Pop $R1 + Pop $R0 + FunctionEnd + Function ${un}AbortRetryNSExecWait # This function expects three arguments in the stack - # $1: 'WithLog' or 'NoLog': Use ExecToLog or just Exec, respectively - # $2: The message to show if an error occurred - # $3: The command to run, quoted + # $R1: 'WithLog' or 'NoLog': Use ExecToLog or just Exec, respectively + # $R2: The message to show if an error occurred + # $R3: The command to run, quoted # Note that the args need to be pushed to the stack in reverse order! # Search 'AbortRetryNSExecWait' in this script to see examples - ${LogSet} on - Pop $1 - Pop $2 - Pop $3 + Pop $R1 + Pop $R2 + Pop $R3 ${Do} - ${If} $1 == "WithLog" - nsExec::ExecToLog $3 - ${ElseIf} $1 == "NoLog" - nsExec::Exec $3 + # Execute command inside a subshell to catch issues with command execution. + # When binaries are executed with Exec or ExectToLog, only the output of these + # commands are logged. If they do not start successfully (e.g., if they don't exist), + # no error messages are returned. Using a subshell reveals these kinds of issues. + StrCpy $R3 '"$CMD_EXE" /D /C "$R3"' + ${If} $R1 == "WithLog" + nsExec::ExecToLog $R3 + ${ElseIf} $R1 == "NoLog" + nsExec::Exec $R3 ${Else} - ${Print} "::error:: AbortRetryNSExecWait: 1st argument must be 'WithLog' or 'NoLog'. You used: $1" + ${Print} "::error:: AbortRetryNSExecWait: 1st argument must be 'WithLog' or 'NoLog'. You used: $R1" Abort ${EndIf} - pop $0 - ${If} $0 != "0" - ${Print} "::error:: $2" - MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON3 \ - $2 /SD IDIGNORE IDABORT abort IDRETRY retry + pop $R0 + ${If} $R1 == "WithLog" + ${AndIf} ${FileExists} "${STEP_LOG}" + ${If} ${Silent} + push "both" + Call ${un}PrintFromStepLog + ${Else} + push "ToLog" + Call ${un}PrintFromStepLog + ${EndIf} + ${EndIf} + ${If} $R0 != "0" + # Always print on error + ${If} $R1 == "NoLog" + ${AndIf} ${FileExists} "${STEP_LOG}" + Push "both" + Call ${un}PrintFromStepLog + ${EndIf} + ${Print} "::error:: $R2" + MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + $R2 /SD IDABORT IDABORT abort IDRETRY retry ; IDIGNORE: Continue anyway - StrCpy $0 "0" + StrCpy $R0 "0" goto retry abort: ; Abort installation @@ -1250,11 +1314,14 @@ FunctionEnd retry: ; Retry the nsExec command ${EndIf} - ${LoopWhile} $0 != "0" + ${If} ${FileExists} "${STEP_LOG}" + Delete "${STEP_LOG}" + ${EndIf} + ${LoopWhile} $R0 != "0" FunctionEnd !macroend -!insertmacro AbortRetryNSExecWaitMacro "" -!insertmacro AbortRetryNSExecWaitMacro "un." +!insertmacro FunctionTemplates "" +!insertmacro FunctionTemplates "un." {%- set pathname = "$INSTDIR\\condabin" if initialize_conda == "condabin" else "$INSTDIR\\Scripts & Library\\bin" %} !macro AddRemovePath add_remove un @@ -1275,7 +1342,7 @@ FunctionEnd StrCpy $R1 "Failed to remove {{ NAME }} from PATH" ${EndIf} ${If} ${Silent} - push '"$INSTDIR\pythonw.exe" -E -s "$INSTDIR\Lib\_nsis.py" $R0' + push '"$INSTDIR\python.exe" -E -s "$INSTDIR\Lib\_nsis.py" $R0 > "${STEP_LOG}" 2>&1' ${Else} push '"$INSTDIR\python.exe" -E -s "$INSTDIR\Lib\_nsis.py" $R0' ${EndIf} @@ -1293,7 +1360,7 @@ FunctionEnd StrCpy $R0 "remove" StrCpy $R1 'Failed to remove {{ NAME }} from PATH' ${EndIf} - push '"$INSTDIR\_conda.exe" constructor windows path --$R0=user --prefix "$INSTDIR" {{ pathflag }}' + push '"$INSTDIR\_conda.exe" constructor windows path --$R0=user --prefix "$INSTDIR" {{ pathflag }} {{ CONDA_LOG_ARG }}' push $R1 push 'WithLog' call ${un}AbortRetryNSExecWait @@ -1321,6 +1388,7 @@ FunctionEnd "Unable to determine the defaults permissions of the installation directory. "\ "Ensure that you have read access to $INSTDIR and icacls.exe is in your PATH." ${Print} $R1 + MessageBox MB_ICONSTOP $R1 Abort ${EndIf} StrCpy $0 "" @@ -1374,7 +1442,6 @@ FunctionEnd # Installer sections Section "Install" - ${LogSet} on ${If} ${Silent} call OnDirectoryLeave ${EndIf} @@ -1382,6 +1449,7 @@ Section "Install" !insertmacro FindWindowsBinaries SetOutPath "$INSTDIR" + ${LogSet} on # Resolve INSTDIR so that paths and registry keys do not contain '..' or similar strings. # $0 is empty if the directory doesn't exist, but the File commands should have created it already. @@ -1493,7 +1561,7 @@ Section "Install" System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_SOLVER", "classic").r0' SetDetailsPrint TextOnly ${Print} "Checking virtual specs compatibility: {{ VIRTUAL_SPECS_DEBUG }}" - push '"$INSTDIR\_conda.exe" create --dry-run --prefix "$INSTDIR\envs\_virtual_specs_checks" --offline {{ VIRTUAL_SPECS }} {{ NO_RCS_ARG }}' + push '"$INSTDIR\_conda.exe" create --dry-run --prefix "$INSTDIR\envs\_virtual_specs_checks" --offline {{ VIRTUAL_SPECS }} {{ NO_RCS_ARG }} {{ CONDA_LOG_ARG }}' push 'Failed to check virtual specs: {{ VIRTUAL_SPECS_DEBUG }}' push 'WithLog' call AbortRetryNSExecWait @@ -1505,19 +1573,22 @@ Section "Install" File {{ dist }} {%- endfor %} - SetDetailsPrint TextOnly ${Print} "Setting up the package cache..." - push '"$INSTDIR\_conda.exe" constructor --prefix "$INSTDIR" --extract-conda-pkgs' + push '"$INSTDIR\_conda.exe" constructor --prefix "$INSTDIR" --extract-conda-pkgs {{ CONDA_LOG_ARG }}' push 'Failed to extract packages' - push 'NoLog' - # We use NoLog here because TQDM progress bars are parsed as a single line in NSIS 3.08 - # These can crash the installer if they get too long (a few packages is enough!) + push 'WithLog' call AbortRetryNSExecWait - SetDetailsPrint both IfFileExists "$INSTDIR\pkgs\pre_install.bat" 0 NoPreInstall ${Print} "Running pre_install scripts..." - push '"$CMD_EXE" /D /C "$INSTDIR\pkgs\pre_install.bat"' + # Output into step log if silent so that the output is visible in the console + # and the logs. For GUI installers, there is no good way to have live output + # and output into logs. + ${If} ${Silent} + push '"$INSTDIR\pkgs\pre_install.bat" > "${STEP_LOG}" 2>&1' + ${Else} + push '"$INSTDIR\pkgs\pre_install.bat"' + ${EndIf} push "Failed to run pre_install" push 'WithLog' call AbortRetryNSExecWait @@ -1545,10 +1616,10 @@ Section "Install" # Run conda install ${If} $Ana_CreateShortcuts_State = ${BST_CHECKED} ${Print} "Installing packages for {{ env.name }}, creating shortcuts if necessary..." - push '"$INSTDIR\_conda.exe" install --offline -yp "{{ env.prefix }}" --file "{{ env.lockfile_txt }}" {{ env.shortcuts }} {{ env.no_rcs_arg }}' + push '"$INSTDIR\_conda.exe" install --offline -yp "{{ env.prefix }}" --file "{{ env.lockfile_txt }}" {{ env.shortcuts }} {{ env.no_rcs_arg }} {{ CONDA_LOG_ARG }}' ${Else} ${Print} "Installing packages for {{ env.name }}..." - push '"$INSTDIR\_conda.exe" install --offline -yp "{{ env.prefix }}" --file "{{ env.lockfile_txt }}" --no-shortcuts {{ env.no_rcs_arg }}' + push '"$INSTDIR\_conda.exe" install --offline -yp "{{ env.prefix }}" --file "{{ env.lockfile_txt }}" --no-shortcuts {{ env.no_rcs_arg }} {{ CONDA_LOG_ARG }}' ${EndIf} push 'Failed to link extracted packages to {{ env.prefix }}!' push 'WithLog' @@ -1578,7 +1649,14 @@ Section "Install" ${If} ${FileExists} "$INSTDIR\pkgs\post_install.bat" ${If} $Ana_PostInstall_State = ${BST_CHECKED} ${Print} "Running post install..." - push '"$CMD_EXE" /D /C "$INSTDIR\pkgs\post_install.bat"' + # Output into step log if silent so that the output is visible in the console + # and the logs. For GUI installers, there is no good way to have live output + # and output into logs. + ${If} ${Silent} + push '"$INSTDIR\pkgs\post_install.bat" > "${STEP_LOG}" 2>&1"' + ${Else} + push '"$INSTDIR\pkgs\post_install.bat"' + ${EndIf} push "Failed to run post_install" push 'WithLog' call AbortRetryNSExecWait @@ -1587,7 +1665,7 @@ Section "Install" ${If} $Ana_ClearPkgCache_State = ${BST_CHECKED} ${Print} "Clearing package cache..." - push '"$INSTDIR\_conda.exe" clean --all --force-pkgs-dirs --yes {{ NO_RCS_ARG }}' + push '"$INSTDIR\_conda.exe" clean --all --force-pkgs-dirs --yes {{ NO_RCS_ARG }} {{ CONDA_LOG_ARG }}' push 'Failed to clear package cache' push 'WithLog' call AbortRetryNSExecWait @@ -1643,9 +1721,16 @@ Section "Install" # Enable inheritance on all files inside $INSTDIR. # Use icacls because it is much faster than custom NSIS solutions. # We continue on error because icacls fails on broken links. - push '"$ICACLS_EXE" "$INSTDIR\*" /inheritance:e /T /C /Q' + # Output into step log if silent so that the output is visible in the console + # and the logs. For GUI installers, there is no good way to have live output + # and output into logs. + ${If} ${Silent} + push '"$ICACLS_EXE" "$INSTDIR\*" /inheritance:e /T /C /Q > "${STEP_LOG}" 2>&1' + ${Else} + push '"$ICACLS_EXE" "$INSTDIR\*" /inheritance:e /T /C /Q' + ${EndIf} push 'Failed to enable inheritance for all files in the installation directory.' - push 'NoLog' + push 'WithLog' call AbortRetryNSExecWait ${Print} "Done!" SectionEnd @@ -1674,6 +1759,9 @@ Section "Uninstall" # For long installation times, this may cause a buffer overflow, crashing the installer. System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_QUIET", "1")".r0' + # Remove registry entries first because they are difficult to clean + # up manually if the uninstallation irrecoverably fails. + # Read variables the uninstaller needs from the registry StrCpy $R0 "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" StrLen $R1 "Uninstall-${NAME}.exe" @@ -1696,6 +1784,27 @@ Section "Uninstall" goto loop_path endloop_path: + ${If} $INSTALLER_NAME_FULL != "" + DeleteRegKey SHCTX "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$INSTALLER_NAME_FULL" + ${EndIf} + + # If Anaconda was registered as the official Python for this version, + # remove it from the registry + StrCpy $R0 "SOFTWARE\Python\PythonCore" + StrCpy $0 0 + loop_py: + EnumRegKey $1 SHCTX $R0 $0 + StrCmp $1 "" endloop_py + ReadRegStr $2 SHCTX "$R0\$1\InstallPath" "" + ${If} $2 == $INSTDIR + StrCpy $R1 $1 + DeleteRegKey SHCTX "$R0\$1" + goto endloop_py + ${EndIf} + IntOp $0 $0 + 1 + goto loop_py + endloop_py: + # Extra info for pre_uninstall scripts System::Call 'kernel32::SetEnvironmentVariable(t,t)i("PREFIX", "$INSTDIR").r0' System::Call 'kernel32::SetEnvironmentVariable(t,t)i("INSTALLER_NAME", "${NAME}").r0' @@ -1714,7 +1823,14 @@ Section "Uninstall" ${If} ${FileExists} "$INSTDIR\pkgs\pre_uninstall.bat" ${Print} "Running pre_uninstall scripts..." - push '"$CMD_EXE" /D /C "$INSTDIR\pkgs\pre_uninstall.bat"' + # Output into step log if silent so that the output is visible in the console + # and the logs. For GUI installers, there is no good way to have live output + # and output into logs. + ${If} ${Silent} + push '"$INSTDIR\pkgs\pre_uninstall.bat" > "${STEP_LOG}" 2>&1' + ${Else} + push '"$INSTDIR\pkgs\pre_uninstall.bat"' + ${EndIf} push "Failed to run pre_uninstall" push 'WithLog' call un.AbortRetryNSExecWait @@ -1746,7 +1862,7 @@ Section "Uninstall" ${EndIf} ${Print} "Removing files and folders..." - push '"$INSTDIR\_conda.exe" constructor uninstall $R0 --prefix "$INSTDIR"' + push '"$INSTDIR\_conda.exe" constructor uninstall $R0 --prefix "$INSTDIR" {{ CONDA_LOG_ARG }}' push 'Failed to remove files and folders. Please see the log for more information.' push 'WithLog' SetDetailsPrint listonly @@ -1756,6 +1872,9 @@ Section "Uninstall" # The uninstallation may leave the install.log, the uninstaller, # and .conda_trash files behind, so remove those manually. ${If} ${FileExists} "$INSTDIR" + # Stop logging or the uninstaller will not remove the install.log file + # without requiring a reboot + LogSet Off RMDir /r /REBOOTOK "$INSTDIR" ${EndIf} {%- else %} @@ -1764,7 +1883,7 @@ Section "Uninstall" SetDetailsPrint both ${Print} "Deleting ${NAME} menus in {{ env.name }}..." SetDetailsPrint listonly - push '"$INSTDIR\_conda.exe" constructor --prefix "$INSTDIR{{ subdir }}" --rm-menus' + push '"$INSTDIR\_conda.exe" constructor --prefix "$INSTDIR{{ subdir }}" --rm-menus {{ CONDA_LOG_ARG }}' push 'Failed to delete menus in {{ env.name }}' push 'WithLog' call un.AbortRetryNSExecWait @@ -1776,11 +1895,11 @@ Section "Uninstall" ${Else} StrCpy $R0 "system" ${EndIf} - # When running conda.bat directly, there is a non-fatal error - # that DOSKEY (called by conda_hook.bat) is not a valid command. - # While the operation still succeeds, this error is confusing. - # Calling via cmd.exe fixes that. - push '"$CMD_EXE" /D /C "$INSTDIR\condabin\conda.bat" init cmd.exe --reverse --$R0' + ${If} ${Silent} + push '"$INSTDIR\condabin\conda.bat" init cmd.exe --reverse --$R0 > "${STEP_LOG}" 2>&1' + ${Else} + push '"$INSTDIR\condabin\conda.bat" init cmd.exe --reverse --$R0' + ${EndIf} push 'Failed to clean AutoRun' push 'WithLog' call un.AbortRetryNSExecWait @@ -1789,32 +1908,14 @@ Section "Uninstall" ${Print} "Removing files and folders..." nsExec::Exec 'cmd.exe /D /C RMDIR /Q /S "$INSTDIR"' + # Stop logging or the uninstaller will not remove the install.log file + # without requiring a reboot + LogSet Off # In case the last command fails, run the slow method to remove leftover RMDir /r /REBOOTOK "$INSTDIR" {%- endif %} - ${If} $INSTALLER_NAME_FULL != "" - DeleteRegKey SHCTX "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$INSTALLER_NAME_FULL" - ${EndIf} - - # If Anaconda was registered as the official Python for this version, - # remove it from the registry - StrCpy $R0 "SOFTWARE\Python\PythonCore" - StrCpy $0 0 - loop_py: - EnumRegKey $1 SHCTX $R0 $0 - StrCmp $1 "" endloop_py - ReadRegStr $2 SHCTX "$R0\$1\InstallPath" "" - ${If} $2 == $INSTDIR - StrCpy $R1 $1 - DeleteRegKey SHCTX "$R0\$1" - goto endloop_py - ${EndIf} - IntOp $0 $0 + 1 - goto loop_py - endloop_py: - ${Print} "Done!" ${If} ${Silent} # give it some time so users can read the last lines diff --git a/constructor/winexe.py b/constructor/winexe.py index d225182af..cd1cf05aa 100644 --- a/constructor/winexe.py +++ b/constructor/winexe.py @@ -261,6 +261,9 @@ def make_nsi( approx_pkgs_size_kb = approx_size_kb(info, "pkgs") # UPPERCASE variables are unescaped (and unquoted) + variables["CONDA_LOG_ARG"] = ( + '--log-file "${STEP_LOG}"' if info.get("_conda_exe_supports_logging") else "" + ) variables["NAME"] = name variables["NSIS_DIR"] = NSIS_DIR variables["BITS"] = str(arch) diff --git a/news/1108-improve-logging b/news/1108-improve-logging new file mode 100644 index 000000000..265f2a1ce --- /dev/null +++ b/news/1108-improve-logging @@ -0,0 +1,23 @@ +### Enhancements + +* Improve logging experience for EXE installers: (#1108) + - Use `cmd.exe` to run commands so that outputs are captured. + - Output command output in CLI installations. + - Prevent log builds from writing to log before installation directory exists. + - Remove registry entries while installation directory still exists so that errors are logged. + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* + +### Other + +* diff --git a/recipe/meta.yaml b/recipe/meta.yaml index c5876d0cd..f9334ccb7 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -25,7 +25,7 @@ requirements: - conda >=4.6 - python # >=3.8 - ruamel.yaml >=0.11.14,<0.19 - - conda-standalone + - conda-standalone >=24.1.2 - jinja2 - jsonschema >=4 - pillow >=3.1 # [win or osx] diff --git a/tests/test_examples.py b/tests/test_examples.py index a2ae8ad85..e335108bd 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -210,7 +210,7 @@ def _run_uninstaller_exe( f"_?={install_dir}", ] process = _execute(cmd, timeout=timeout, check=check) - if check: + if check and Path(install_dir, "install.log").exists(): _check_installer_log(install_dir) remaining_files = list(install_dir.iterdir()) if len(remaining_files) > 3: