diff --git a/.github/workflows/build_all.yml b/.github/workflows/build_all.yml index bb204779e750..4c28ba0894cc 100644 --- a/.github/workflows/build_all.yml +++ b/.github/workflows/build_all.yml @@ -55,6 +55,7 @@ jobs: - /opt/ghc:/opt/ghc1 - /usr/local/share/boost:/usr/local/share/boost1 strategy: + fail-fast: false matrix: variant: - arch: x86_64 diff --git a/CMakeLists.txt b/CMakeLists.txt index fc74d22cbd0e..a6881ade1326 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -976,84 +976,36 @@ set (CPACK_PACKAGE_INSTALL_DIRECTORY ${CPACK_PACKAGE_NAME}) set (CPACK_PACKAGE_ICON "${CMAKE_SOURCE_DIR}/resources/images\\\\Snapmaker_Orca.ico") set (CPACK_NSIS_MUI_ICON "${CPACK_PACKAGE_ICON}") set (CPACK_NSIS_MUI_UNIICON "${CPACK_PACKAGE_ICON}") -set (CPACK_NSIS_INSTALLED_ICON_NAME "$INSTDIR\\\\snapmaker-orca.exe") +set (CPACK_NSIS_INSTALLED_ICON_NAME "snapmaker-orca.exe") set(CPACK_NSIS_UNINSTALL_NAME "Uninstall") -# Preinstall guard in one place: -# 1) Block while app is running. -# 2) If old version exists, uninstall it with clear failure reason. -# We keep this in EXTRA_PREINSTALL because CPack's default .onInit uninstall prompt runs before section logic. -set(CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS [=[ -SnapmakerOrcaPre_check_loop: - ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq snapmaker-orca.exe" 2>nul | find /i "snapmaker-orca.exe" >nul' $0 - IntCmp $0 0 SnapmakerOrcaPre_in_use SnapmakerOrcaPre_try_legacy SnapmakerOrcaPre_try_legacy -SnapmakerOrcaPre_try_legacy: - ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq Snapmaker_Orca.exe" 2>nul | find /i "Snapmaker_Orca.exe" >nul' $0 - IntCmp $0 0 SnapmakerOrcaPre_in_use SnapmakerOrcaPre_idle SnapmakerOrcaPre_idle -SnapmakerOrcaPre_idle: - Goto SnapmakerOrcaPre_done -SnapmakerOrcaPre_in_use: - IfSilent SnapmakerOrcaPre_silent SnapmakerOrcaPre_prompt -SnapmakerOrcaPre_silent: - SetErrorLevel 7 - Quit -SnapmakerOrcaPre_prompt: - MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Snapmaker Orca is still running.$\nClose the program, then click Retry to continue, or Cancel to exit the installer." IDRETRY SnapmakerOrcaPre_check_loop - Abort -SnapmakerOrcaPre_done: - ReadRegStr $0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "UninstallString" - StrCmp $0 "" SnapmakerOrcaPre_read_hkcu_uninst SnapmakerOrcaPre_has_uninst -SnapmakerOrcaPre_read_hkcu_uninst: - ReadRegStr $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "UninstallString" -SnapmakerOrcaPre_has_uninst: - StrCmp $0 "" SnapmakerOrcaPre_finish - - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "Snapmaker_Orca is already installed.$\n$\nDo you want to uninstall the old version before installing the new one?" /SD IDYES IDYES SnapmakerOrcaPre_uninst IDNO SnapmakerOrcaPre_finish - Abort - -SnapmakerOrcaPre_uninst: - ClearErrors - StrCpy $2 $0 1 0 - StrCmp $2 "$\"" SnapmakerOrcaPre_trim_first_quote SnapmakerOrcaPre_check_last_quote -SnapmakerOrcaPre_trim_first_quote: - StrCpy $0 $0 "" 1 -SnapmakerOrcaPre_check_last_quote: - StrCpy $2 $0 1 -1 - StrCmp $2 "$\"" SnapmakerOrcaPre_trim_last_quote SnapmakerOrcaPre_have_uninst_path -SnapmakerOrcaPre_trim_last_quote: - StrCpy $0 $0 -1 -SnapmakerOrcaPre_have_uninst_path: - - StrLen $2 "Uninstall.exe" - StrCpy $3 $0 -$2 - ; $3 ends with "\" (e.g. ...\Snapmaker_Orca\). _?="...\" breaks Windows cmdline parsing - ; (\" escapes the closing quote), truncating _?= at the first space — strip trailing backslashes. -SnapmakerOrcaPre_trim_inst_slash: - StrCpy $2 $3 1 -1 - StrCmp $2 "\" SnapmakerOrcaPre_do_trim_slash SnapmakerOrcaPre_trim_slash_done -SnapmakerOrcaPre_do_trim_slash: - StrCpy $3 $3 -1 - Goto SnapmakerOrcaPre_trim_inst_slash -SnapmakerOrcaPre_trim_slash_done: - IfFileExists "$0" 0 SnapmakerOrcaPre_uninst_launch_failed - ; Same as CMake CPack NSIS.template.in and NSIS maintainer guidance: use _?=$DIR with NO - ; extra quotes around the directory. _?="C:\Program Files\..." breaks parsing (log shows - ; _?="C:\Program Fil...) and causes uninstall failure. See NSIS forums t-212468. - ExecWait '"$0" /S _?=$3' $1 - IfErrors SnapmakerOrcaPre_uninst_launch_failed 0 - IntCmp $1 0 SnapmakerOrcaPre_finish SnapmakerOrcaPre_uninst_failed SnapmakerOrcaPre_uninst_failed - -SnapmakerOrcaPre_uninst_failed: - MessageBox MB_OK|MB_ICONSTOP "Uninstall failed.$\nUninstaller path: $0$\nWorking dir: $3$\nExit code: $1" - Abort - -SnapmakerOrcaPre_uninst_launch_failed: - MessageBox MB_OK|MB_ICONSTOP "Failed to start uninstaller process.$\nUninstaller path: $0$\nWorking dir: $3$\n$\nSystem error: the uninstall command could not be executed by Windows (IfErrors set).$\nPossible causes: missing/corrupted uninstaller, access denied, or invalid install directory." - Abort - -SnapmakerOrcaPre_finish: -]=]) +# Window caption. Keep install dir / Start Menu / registry without version. +# CPack-generated .nsi stays ASCII. Chinese uninstall confirm is a UTF-8 BOM +# .nsh included after "Unicode true" so language switch (SimpChinese/English) works. +set (CPACK_NSIS_DISPLAY_NAME "${CPACK_PACKAGE_NAME}") +set (CPACK_NSIS_PACKAGE_NAME "${CPACK_PACKAGE_NAME} V${Snapmaker_VERSION}") +# Pre-fill the directory page with the existing install path on overwrite +# (logic lives in cmake/nsis/SnapmakerDirPre.nsh, pulled in via MUI hook). +file(TO_NATIVE_PATH "${CMAKE_SOURCE_DIR}/cmake/nsis/SnapmakerDirPre.nsh" _snapmaker_dirpre_nsh) +set (CPACK_NSIS_DEFINES "!define MUI_STARTMENUPAGE_DEFAULTFOLDER \"${CPACK_PACKAGE_NAME}\"\n!include \"${_snapmaker_dirpre_nsh}\"") +# Preinstall guard (block-while-running + old-version uninstall prompt) lives in +# cmake/nsis/SnapmakerPreinstall.nsh.in and is included from the install section +# via CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS. That variable is set in the WIN32 +# block below (where the UTF-8 BOM .nsh is written), because the CPack-generated +# .nsi template must stay ASCII -- the Chinese/English prompt texts therefore +# live in the separate UTF-8 BOM .nsh instead of this inline block. file(READ "${CMAKE_SOURCE_DIR}/cmake/nsis/SnapmakerURLProtocols_install.nsh" CPACK_NSIS_EXTRA_INSTALL_COMMANDS) -file(READ "${CMAKE_SOURCE_DIR}/cmake/nsis/SnapmakerURLProtocols_uninstall.nsh" CPACK_NSIS_EXTRA_UNINSTALL_COMMANDS) +file(READ "${CMAKE_SOURCE_DIR}/cmake/nsis/SnapmakerURLProtocols_uninstall.nsh" _snapmaker_nsis_uninst_url) +set(_snapmaker_nsis_uninst_kill [=[ + nsExec::Exec 'taskkill /F /IM snapmaker-orca.exe /T' + Pop $0 + nsExec::Exec 'taskkill /F /IM Snapmaker_Orca.exe /T' + Pop $0 +]=]) +string(CONCAT CPACK_NSIS_EXTRA_UNINSTALL_COMMANDS + "${_snapmaker_nsis_uninst_kill}" "${_snapmaker_nsis_uninst_url}") +# Auto-close the uninstall details page so the wizard advances to the custom +# finish page as soon as the uninstall section finishes (no extra click). +string(APPEND CPACK_NSIS_EXTRA_UNINSTALL_COMMANDS " SetAutoClose true\n") # Desktop shortcut inside MUI_STARTMENU_WRITE_BEGIN: respects "do not create shortcuts" on the start menu page. set(CPACK_NSIS_CREATE_ICONS_EXTRA [=[ SetShellVarContext current @@ -1112,7 +1064,18 @@ if (WIN32) ]=]) string(REPLACE "${_snapmaker_nsis_old_registry}" "${_snapmaker_nsis_new_registry}" _snapmaker_nsis_tpl "${_snapmaker_nsis_tpl}") + string(REPLACE + [=[;Include Modern UI + + !include "MUI.nsh"]=] + [=[;Include Modern UI + + Unicode true + !include "MUI.nsh"]=] + _snapmaker_nsis_tpl "${_snapmaker_nsis_tpl}") foreach(_snapmaker_nsis_erase IN ITEMS + [=[ !insertmacro MUI_UNPAGE_CONFIRM +]=] [=[ Var DO_NOT_ADD_TO_PATH Var ADD_TO_PATH_ALL_USERS Var ADD_TO_PATH_CURRENT_USER @@ -1169,8 +1132,97 @@ FunctionEnd ) string(REPLACE "${_snapmaker_nsis_erase}" "" _snapmaker_nsis_tpl "${_snapmaker_nsis_tpl}") endforeach() + # CPack only deletes Software\\, not the parent key, so + # an empty "Software\Snapmaker" key is left behind after uninstall. Delete it + # too (with /ifempty so it stays if other Snapmaker products also use it). + string(REPLACE + [=[ DeleteRegKey /ifempty SHCTX "Software\@CPACK_PACKAGE_VENDOR@\@CPACK_PACKAGE_INSTALL_REGISTRY_KEY@"]=] + [=[ DeleteRegKey /ifempty SHCTX "Software\@CPACK_PACKAGE_VENDOR@\@CPACK_PACKAGE_INSTALL_REGISTRY_KEY@" + DeleteRegKey /ifempty SHCTX "Software\@CPACK_PACKAGE_VENDOR@"]=] + _snapmaker_nsis_tpl "${_snapmaker_nsis_tpl}") + string(REPLACE + [=[ ; Remove the registry entries. + DeleteRegKey SHCTX "Software\@CPACK_PACKAGE_VENDOR@\@CPACK_PACKAGE_INSTALL_REGISTRY_KEY@" +]=] + "" _snapmaker_nsis_tpl "${_snapmaker_nsis_tpl}") + string(REPLACE + [=[ StrCpy $MUI_TEMP "$START_MENU" + Delete "$SMPROGRAMS\$MUI_TEMP\Uninstall.lnk" +@CPACK_NSIS_DELETE_ICONS_EXTRA@]=] + [=[ StrCpy $MUI_TEMP "$START_MENU" + StrCmp $MUI_TEMP "" secondStartMenuDeleteLoopDone + Delete "$SMPROGRAMS\$MUI_TEMP\Uninstall.lnk" +@CPACK_NSIS_DELETE_ICONS@ +@CPACK_NSIS_DELETE_ICONS_EXTRA@]=] + _snapmaker_nsis_tpl "${_snapmaker_nsis_tpl}") + string(REPLACE + [=[ secondStartMenuDeleteLoopDone: + + DeleteRegKey /ifempty SHCTX "Software\@CPACK_PACKAGE_VENDOR@\@CPACK_PACKAGE_INSTALL_REGISTRY_KEY@"]=] + [=[ secondStartMenuDeleteLoopDone: + + DeleteRegKey SHCTX "Software\@CPACK_PACKAGE_VENDOR@\@CPACK_PACKAGE_INSTALL_REGISTRY_KEY@" + DeleteRegKey /ifempty SHCTX "Software\@CPACK_PACKAGE_VENDOR@\@CPACK_PACKAGE_INSTALL_REGISTRY_KEY@"]=] + _snapmaker_nsis_tpl "${_snapmaker_nsis_tpl}") set(_snapmaker_nsis_tpl_dir "${CMAKE_BINARY_DIR}/cmake/cpack") file(MAKE_DIRECTORY "${_snapmaker_nsis_tpl_dir}") + set(_snapmaker_uninst_confirm_in "${CMAKE_SOURCE_DIR}/cmake/nsis/SnapmakerUninstallConfirm.nsh.in") + if(NOT EXISTS "${_snapmaker_uninst_confirm_in}") + message(FATAL_ERROR "Missing ${_snapmaker_uninst_confirm_in} (required for CPack NSIS uninstall confirm).") + endif() + file(READ "${_snapmaker_uninst_confirm_in}" _snapmaker_uninst_confirm ENCODING UTF-8) + string(REPLACE "@Snapmaker_VERSION@" "${Snapmaker_VERSION}" + _snapmaker_uninst_confirm "${_snapmaker_uninst_confirm}") + string(ASCII 239 187 191 _snapmaker_utf8_bom) + file(WRITE "${_snapmaker_nsis_tpl_dir}/SnapmakerUninstallConfirm.nsh" + "${_snapmaker_utf8_bom}${_snapmaker_uninst_confirm}") + file(TO_NATIVE_PATH "${_snapmaker_nsis_tpl_dir}/SnapmakerUninstallConfirm.nsh" _snapmaker_uninst_confirm_nsh) + string(PREPEND CPACK_NSIS_EXTRA_UNINSTALL_COMMANDS + " !include \"${_snapmaker_uninst_confirm_nsh}\"\n") + # Welcome-style uninstall finish page, included at top level of the patched + # template so Function/Var live outside Section "Uninstall". + set(_snapmaker_uninst_finish_in "${CMAKE_SOURCE_DIR}/cmake/nsis/SnapmakerUninstallFinish.nsh.in") + if(NOT EXISTS "${_snapmaker_uninst_finish_in}") + message(FATAL_ERROR "Missing ${_snapmaker_uninst_finish_in} (required for CPack NSIS uninstall finish page).") + endif() + file(READ "${_snapmaker_uninst_finish_in}" _snapmaker_uninst_finish ENCODING UTF-8) + string(REPLACE "@Snapmaker_VERSION@" "${Snapmaker_VERSION}" + _snapmaker_uninst_finish "${_snapmaker_uninst_finish}") + file(WRITE "${_snapmaker_nsis_tpl_dir}/SnapmakerUninstallFinish.nsh" + "${_snapmaker_utf8_bom}${_snapmaker_uninst_finish}") + file(TO_NATIVE_PATH "${_snapmaker_nsis_tpl_dir}/SnapmakerUninstallFinish.nsh" _snapmaker_uninst_finish_nsh) + # Upgrade cleanup: remove version-specific user data (system/ota) before the + # new files are installed. Included at top level (Function/Var) and invoked + # from CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS when an existing install is found. + set(_snapmaker_upgrade_cleanup_in "${CMAKE_SOURCE_DIR}/cmake/nsis/SnapmakerUpgradeCleanup.nsh.in") + if(NOT EXISTS "${_snapmaker_upgrade_cleanup_in}") + message(FATAL_ERROR "Missing ${_snapmaker_upgrade_cleanup_in} (required for CPack NSIS upgrade cleanup).") + endif() + file(READ "${_snapmaker_upgrade_cleanup_in}" _snapmaker_upgrade_cleanup ENCODING UTF-8) + file(WRITE "${_snapmaker_nsis_tpl_dir}/SnapmakerUpgradeCleanup.nsh" + "${_snapmaker_utf8_bom}${_snapmaker_upgrade_cleanup}") + file(TO_NATIVE_PATH "${_snapmaker_nsis_tpl_dir}/SnapmakerUpgradeCleanup.nsh" _snapmaker_upgrade_cleanup_nsh) + # Preinstall prompts (block-while-running + old-version uninstall confirm) + # moved out of the inline ASCII template so the Chinese/English texts can + # live in a UTF-8 BOM .nsh. Included from the install section via + # CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS. + set(_snapmaker_preinstall_in "${CMAKE_SOURCE_DIR}/cmake/nsis/SnapmakerPreinstall.nsh.in") + if(NOT EXISTS "${_snapmaker_preinstall_in}") + message(FATAL_ERROR "Missing ${_snapmaker_preinstall_in} (required for CPack NSIS preinstall prompts).") + endif() + file(READ "${_snapmaker_preinstall_in}" _snapmaker_preinstall ENCODING UTF-8) + file(WRITE "${_snapmaker_nsis_tpl_dir}/SnapmakerPreinstall.nsh" + "${_snapmaker_utf8_bom}${_snapmaker_preinstall}") + file(TO_NATIVE_PATH "${_snapmaker_nsis_tpl_dir}/SnapmakerPreinstall.nsh" _snapmaker_preinstall_nsh) + set(CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS " !include \"${_snapmaker_preinstall_nsh}\"\n") + # Register the finish page AFTER MUI_UNPAGE_INSTFILES: shown automatically + # once file deletion in Section "Uninstall" has finished. + # BrandingText " " hides the default "Nullsoft Install System vX.XX" label + # in the bottom-left corner (empty string would reset to the default). + string(REPLACE + " !insertmacro MUI_UNPAGE_INSTFILES\n" + " !insertmacro MUI_UNPAGE_INSTFILES\n !include \"${_snapmaker_upgrade_cleanup_nsh}\"\n !include \"${_snapmaker_uninst_finish_nsh}\"\n UninstPage custom un.SnapmakerUninstallFinishCreate un.SnapmakerUninstallFinishLeave\n BrandingText \" \"\n" + _snapmaker_nsis_tpl "${_snapmaker_nsis_tpl}") file(WRITE "${_snapmaker_nsis_tpl_dir}/NSIS.template.in" "${_snapmaker_nsis_tpl}") list(PREPEND CMAKE_MODULE_PATH "${_snapmaker_nsis_tpl_dir}") endif () diff --git a/cmake/modules/MacOSXBundleInfo.plist.in b/cmake/modules/MacOSXBundleInfo.plist.in index 7b6a97776e56..b039c3a84331 100644 --- a/cmake/modules/MacOSXBundleInfo.plist.in +++ b/cmake/modules/MacOSXBundleInfo.plist.in @@ -10,6 +10,8 @@ ${MACOSX_BUNDLE_INFO_STRING} CFBundleIconFile ${MACOSX_BUNDLE_ICON_FILE} + CFBundleIconName + AppIcon CFBundleIdentifier com.snapmaker.snapmaker-orca CFBundleInfoDictionaryVersion diff --git a/cmake/nsis/SnapmakerDirPre.nsh b/cmake/nsis/SnapmakerDirPre.nsh new file mode 100644 index 000000000000..73e121b37606 --- /dev/null +++ b/cmake/nsis/SnapmakerDirPre.nsh @@ -0,0 +1,38 @@ +; Pre-fill the installer directory page with the existing install path so an +; overwrite/upgrade reinstalls into the same folder. +; Hooked through MUI_PAGE_CUSTOMFUNCTION_PRE: the first MUI page macro (Welcome) +; consumes and !undef's the hook, so this runs exactly once, before the directory +; page. $IS_DEFAULT_INSTALLDIR is set by .onInit (1 = the user did NOT pass /D=), +; so a /D= override is preserved. Fresh installs read no UninstallString and are +; left untouched. +!define MUI_PAGE_CUSTOMFUNCTION_PRE SnapmakerDirPre + +Function SnapmakerDirPre + StrCmp "$IS_DEFAULT_INSTALLDIR" "1" 0 SnapmakerDirPre_done + ReadRegStr $0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "UninstallString" + StrCmp $0 "" SnapmakerDirPre_hkcu SnapmakerDirPre_have + SnapmakerDirPre_hkcu: + ReadRegStr $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "UninstallString" + StrCmp $0 "" SnapmakerDirPre_hklm_space SnapmakerDirPre_have + SnapmakerDirPre_hklm_space: + ReadRegStr $0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker Orca" "UninstallString" + StrCmp $0 "" SnapmakerDirPre_hkcu_space SnapmakerDirPre_have + SnapmakerDirPre_hkcu_space: + ReadRegStr $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker Orca" "UninstallString" + StrCmp $0 "" SnapmakerDirPre_done SnapmakerDirPre_have + SnapmakerDirPre_have: + ; UninstallString is quoted; strip the surrounding quotes. + StrCpy $1 $0 1 0 + StrCmp $1 "$\"" 0 SnapmakerDirPre_check_last + StrCpy $0 $0 "" 1 + SnapmakerDirPre_check_last: + StrCpy $1 $0 1 -1 + StrCmp $1 "$\"" 0 SnapmakerDirPre_strip + StrCpy $0 $0 -1 + SnapmakerDirPre_strip: + StrLen $1 "\Uninstall.exe" + StrCpy $0 $0 -$1 + StrCmp $0 "" SnapmakerDirPre_done + StrCpy $INSTDIR $0 + SnapmakerDirPre_done: +FunctionEnd diff --git a/cmake/nsis/SnapmakerPreinstall.nsh.in b/cmake/nsis/SnapmakerPreinstall.nsh.in new file mode 100644 index 000000000000..9390794196a5 --- /dev/null +++ b/cmake/nsis/SnapmakerPreinstall.nsh.in @@ -0,0 +1,171 @@ +; Pre-install guard, run inside Section "-Core installation". CMakeLists.txt sets +; CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS to " !include .../SnapmakerPreinstall.nsh" +; so this whole block lands in the install section at the template's +; @CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@ placeholder. +; 1) Block while the app is running. +; 2) If an old version exists, prompt to uninstall it first, with a clear failure +; reason when the uninstall cannot run or fails. +; Kept out of the inline CPack template because the CPack-generated .nsi must +; stay ASCII; the Chinese/English prompt texts below live in this UTF-8 BOM .nsh +; (included after "Unicode true") and switch on $LANGUAGE, matching +; SnapmakerUninstallConfirm.nsh / SnapmakerUpgradeCleanup.nsh. +; $SnapmakerIsUpgrade and Function SnapmakerCleanupUpgradeData are declared in +; SnapmakerUpgradeCleanup.nsh (included at the top level of the patched template). + +!ifndef LANG_SIMPCHINESE +!define LANG_SIMPCHINESE 2052 +!endif + +SnapmakerOrcaPre_check_loop: + nsExec::Exec 'cmd.exe /c tasklist /FI "IMAGENAME eq snapmaker-orca.exe" 2>nul | find /i "snapmaker-orca.exe" >nul' + Pop $0 + IntCmp $0 0 SnapmakerOrcaPre_in_use SnapmakerOrcaPre_try_legacy SnapmakerOrcaPre_try_legacy +SnapmakerOrcaPre_try_legacy: + nsExec::Exec 'cmd.exe /c tasklist /FI "IMAGENAME eq Snapmaker_Orca.exe" 2>nul | find /i "Snapmaker_Orca.exe" >nul' + Pop $0 + IntCmp $0 0 SnapmakerOrcaPre_in_use SnapmakerOrcaPre_idle SnapmakerOrcaPre_idle +SnapmakerOrcaPre_idle: + Goto SnapmakerOrcaPre_done +SnapmakerOrcaPre_in_use: + IfSilent SnapmakerOrcaPre_silent SnapmakerOrcaPre_prompt +SnapmakerOrcaPre_silent: + SetErrorLevel 7 + Quit +SnapmakerOrcaPre_prompt: + StrCmp $LANGUAGE ${LANG_SIMPCHINESE} 0 SnapmakerOrcaPre_prompt_en + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Snapmaker Orca 仍在运行。$\n请关闭程序后点击重试继续,或点击取消退出安装程序。" IDRETRY SnapmakerOrcaPre_check_loop + Abort +SnapmakerOrcaPre_prompt_en: + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Snapmaker Orca is still running.$\nClose the program, then click Retry to continue, or Cancel to exit the installer." IDRETRY SnapmakerOrcaPre_check_loop + Abort +SnapmakerOrcaPre_done: + ReadRegStr $0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "UninstallString" + StrCmp $0 "" SnapmakerOrcaPre_read_hkcu_uninst SnapmakerOrcaPre_have_uninst_str +SnapmakerOrcaPre_read_hkcu_uninst: + ReadRegStr $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "UninstallString" + StrCmp $0 "" SnapmakerOrcaPre_read_hkml_space SnapmakerOrcaPre_have_uninst_str +SnapmakerOrcaPre_read_hkml_space: + ReadRegStr $0 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker Orca" "UninstallString" + StrCmp $0 "" SnapmakerOrcaPre_read_hkcu_space SnapmakerOrcaPre_have_uninst_str +SnapmakerOrcaPre_read_hkcu_space: + ReadRegStr $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker Orca" "UninstallString" + StrCmp $0 "" SnapmakerOrcaPre_try_instdir SnapmakerOrcaPre_have_uninst_str +SnapmakerOrcaPre_try_instdir: + IfFileExists "$INSTDIR\Uninstall.exe" 0 SnapmakerOrcaPre_check_app + StrCpy $0 "$INSTDIR\Uninstall.exe" + Goto SnapmakerOrcaPre_have_uninst_str +SnapmakerOrcaPre_check_app: + IfFileExists "$INSTDIR\snapmaker-orca.exe" SnapmakerOrcaPre_have_existing_install SnapmakerOrcaPre_check_legacy_app +SnapmakerOrcaPre_check_legacy_app: + IfFileExists "$INSTDIR\Snapmaker_Orca.exe" SnapmakerOrcaPre_have_existing_install SnapmakerOrcaPre_finish + +SnapmakerOrcaPre_have_existing_install: + ; App exe found but no uninstaller path in registry ($0 stays empty). Fall through + ; to the common prompt; the quote-strip below is a no-op on an empty string, and + ; the uninstall step will skip removal because $0 is empty. +SnapmakerOrcaPre_have_uninst_str: + StrCpy $2 $0 1 0 + StrCmp $2 "$\"" SnapmakerOrcaPre_trim_first_quote SnapmakerOrcaPre_check_last_quote +SnapmakerOrcaPre_trim_first_quote: + StrCpy $0 $0 "" 1 +SnapmakerOrcaPre_check_last_quote: + StrCpy $2 $0 1 -1 + StrCmp $2 "$\"" SnapmakerOrcaPre_trim_last_quote SnapmakerOrcaPre_path_ready +SnapmakerOrcaPre_trim_last_quote: + StrCpy $0 $0 -1 +SnapmakerOrcaPre_path_ready: + ; Existing install confirmed -> this is an upgrade (overwrite or uninstall-first). + StrCpy $SnapmakerIsUpgrade "1" + + StrCpy $4 "" + ; Uninstall key is written by CPack in the default (32-bit) registry view; read it + ; in the same view. Do NOT SetRegView 64 here -- the 32-bit installer writes to + ; the WOW6432Node, so a 64-bit read would always come back empty. + ReadRegStr $4 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "DisplayVersion" + StrCmp $4 "" 0 SnapmakerOrcaPre_have_ver + ReadRegStr $4 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "DisplayVersion" + StrCmp $4 "" 0 SnapmakerOrcaPre_have_ver + ReadRegStr $4 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker Orca" "DisplayVersion" + StrCmp $4 "" 0 SnapmakerOrcaPre_have_ver + ReadRegStr $4 HKLM "Software\Snapmaker\Snapmaker_Orca" "Version" + StrCmp $4 "" 0 SnapmakerOrcaPre_have_ver + ReadRegStr $4 HKLM "Software\Snapmaker\Snapmaker Orca" "Version" +SnapmakerOrcaPre_have_ver: + StrCpy $5 $4 1 + StrCmp $5 "V" 0 SnapmakerOrcaPre_ver_ready + StrCpy $4 $4 "" 1 +SnapmakerOrcaPre_ver_ready: + StrCmp $4 "" SnapmakerOrcaPre_already_no_ver SnapmakerOrcaPre_already_ver +SnapmakerOrcaPre_already_ver: + StrCmp $LANGUAGE ${LANG_SIMPCHINESE} 0 SnapmakerOrcaPre_already_ver_en + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "已安装 Snapmaker Orca V$4。$\n$\n是否先卸载旧版本,再安装新版本?" /SD IDYES IDYES SnapmakerOrcaPre_uninst IDNO SnapmakerOrcaPre_finish + Abort +SnapmakerOrcaPre_already_ver_en: + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "Snapmaker Orca V$4 is already installed.$\n$\nDo you want to uninstall the old version before installing the new one?" /SD IDYES IDYES SnapmakerOrcaPre_uninst IDNO SnapmakerOrcaPre_finish + Abort +SnapmakerOrcaPre_already_no_ver: + StrCmp $LANGUAGE ${LANG_SIMPCHINESE} 0 SnapmakerOrcaPre_already_no_ver_en + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "已安装 Snapmaker Orca。$\n$\n是否先卸载旧版本,再安装新版本?" /SD IDYES IDYES SnapmakerOrcaPre_uninst IDNO SnapmakerOrcaPre_finish + Abort +SnapmakerOrcaPre_already_no_ver_en: + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION "Snapmaker Orca is already installed.$\n$\nDo you want to uninstall the old version before installing the new one?" /SD IDYES IDYES SnapmakerOrcaPre_uninst IDNO SnapmakerOrcaPre_finish + Abort + +SnapmakerOrcaPre_skip_uninst: + ; User chose to uninstall but the uninstaller is missing: skip removal and + ; proceed with the overwrite install (no blocking dialog). + StrCmp $LANGUAGE ${LANG_SIMPCHINESE} 0 SnapmakerOrcaPre_skip_uninst_en + DetailPrint "未找到卸载程序,跳过旧版本移除并直接覆盖安装。" + Goto SnapmakerOrcaPre_finish +SnapmakerOrcaPre_skip_uninst_en: + DetailPrint "Uninstaller not found, skipping old-version removal and overwriting." + Goto SnapmakerOrcaPre_finish + +SnapmakerOrcaPre_uninst: + IfFileExists "$0" 0 SnapmakerOrcaPre_skip_uninst + ClearErrors + StrLen $2 "Uninstall.exe" + StrCpy $3 $0 -$2 + ; $3 ends with "\" (e.g. ...\Snapmaker_Orca\). _?="...\" breaks Windows cmdline parsing + ; (\" escapes the closing quote), truncating _?= at the first space -- strip trailing backslashes. +SnapmakerOrcaPre_trim_inst_slash: + StrCpy $2 $3 1 -1 + StrCmp $2 "\" SnapmakerOrcaPre_do_trim_slash SnapmakerOrcaPre_trim_slash_done +SnapmakerOrcaPre_do_trim_slash: + StrCpy $3 $3 -1 + Goto SnapmakerOrcaPre_trim_inst_slash +SnapmakerOrcaPre_trim_slash_done: + IfFileExists "$0" 0 SnapmakerOrcaPre_uninst_launch_failed + ; Same as CMake CPack NSIS.template.in and NSIS maintainer guidance: use _?=$DIR with NO + ; extra quotes around the directory. _?="C:\Program Files\..." breaks parsing (log shows + ; _?="C:\Program Fil...) and causes uninstall failure. See NSIS forums t-212468. + ExecWait '"$0" /S _?=$3' $1 + IfErrors SnapmakerOrcaPre_uninst_launch_failed 0 + IntCmp $1 0 SnapmakerOrcaPre_uninst_ok SnapmakerOrcaPre_uninst_failed SnapmakerOrcaPre_uninst_failed +SnapmakerOrcaPre_uninst_ok: + ; The old uninstaller's self-delete may be deferred or fail; remove it again so + ; Uninstall.exe never survives an upgrade into the new install. + Delete "$0" + ClearErrors + Goto SnapmakerOrcaPre_finish + +SnapmakerOrcaPre_uninst_failed: + StrCmp $LANGUAGE ${LANG_SIMPCHINESE} 0 SnapmakerOrcaPre_uninst_failed_en + MessageBox MB_OK|MB_ICONSTOP "卸载失败。$\n卸载程序路径:$0$\n工作目录:$3$\n退出码:$1" + Abort +SnapmakerOrcaPre_uninst_failed_en: + MessageBox MB_OK|MB_ICONSTOP "Uninstall failed.$\nUninstaller path: $0$\nWorking dir: $3$\nExit code: $1" + Abort + +SnapmakerOrcaPre_uninst_launch_failed: + StrCmp $LANGUAGE ${LANG_SIMPCHINESE} 0 SnapmakerOrcaPre_uninst_launch_failed_en + MessageBox MB_OK|MB_ICONSTOP "无法启动卸载程序。$\n卸载程序路径:$0$\n工作目录:$3$\n$\n系统错误:Windows 无法执行卸载命令(已设置 IfErrors)。$\n可能原因:卸载程序缺失或损坏、访问被拒绝,或安装目录无效。" + Abort +SnapmakerOrcaPre_uninst_launch_failed_en: + MessageBox MB_OK|MB_ICONSTOP "Failed to start uninstaller process.$\nUninstaller path: $0$\nWorking dir: $3$\n$\nSystem error: the uninstall command could not be executed by Windows (IfErrors set).$\nPossible causes: missing/corrupted uninstaller, access denied, or invalid install directory." + Abort + +SnapmakerOrcaPre_finish: + StrCmp $SnapmakerIsUpgrade "1" 0 SnapmakerOrcaPre_cleanup_done + Call SnapmakerCleanupUpgradeData +SnapmakerOrcaPre_cleanup_done: diff --git a/cmake/nsis/SnapmakerURLProtocols_uninstall.nsh b/cmake/nsis/SnapmakerURLProtocols_uninstall.nsh index 9b6eafcf17eb..97d454c81b00 100644 --- a/cmake/nsis/SnapmakerURLProtocols_uninstall.nsh +++ b/cmake/nsis/SnapmakerURLProtocols_uninstall.nsh @@ -2,4 +2,6 @@ SetRegView 64 DeleteRegKey HKLM "Software\Classes\snapmaker-orca" DeleteRegKey HKLM "Software\Classes\Snapmaker_Orca" +DeleteRegKey HKCU "Software\Classes\snapmaker-orca" +DeleteRegKey HKCU "Software\Classes\Snapmaker_Orca" SetRegView 32 diff --git a/cmake/nsis/SnapmakerUninstallConfirm.nsh.in b/cmake/nsis/SnapmakerUninstallConfirm.nsh.in new file mode 100644 index 000000000000..a85b610eb339 --- /dev/null +++ b/cmake/nsis/SnapmakerUninstallConfirm.nsh.in @@ -0,0 +1,14 @@ +; Uninstall confirmation (skipped for silent /S upgrades). +; Version is substituted from Snapmaker_VERSION at CMake configure time. +; Language follows the NSIS installer language ($LANGUAGE). +IfSilent SnapmakerOrcaUn_confirm_done +StrCmp $LANGUAGE ${LANG_SIMPCHINESE} 0 SnapmakerOrcaUn_confirm_en +MessageBox MB_ICONQUESTION|MB_YESNO|MB_DEFBUTTON2 "确定要彻底卸载 Snapmaker Orca V@Snapmaker_VERSION@ 及其全部组件吗?" IDYES SnapmakerOrcaUn_confirm_done +; "No" chosen: close the uninstaller immediately. Use Quit, not Abort -- +; Abort inside a Section only aborts the section and leaves the wizard stuck +; on the progress (InstFiles) page instead of closing the window. +Quit +SnapmakerOrcaUn_confirm_en: +MessageBox MB_ICONQUESTION|MB_YESNO|MB_DEFBUTTON2 "Are you sure you want to completely uninstall Snapmaker Orca V@Snapmaker_VERSION@ and all of its components?" IDYES SnapmakerOrcaUn_confirm_done +Quit +SnapmakerOrcaUn_confirm_done: diff --git a/cmake/nsis/SnapmakerUninstallFinish.nsh.in b/cmake/nsis/SnapmakerUninstallFinish.nsh.in new file mode 100644 index 000000000000..f4f814150144 --- /dev/null +++ b/cmake/nsis/SnapmakerUninstallFinish.nsh.in @@ -0,0 +1,225 @@ +; Welcome-style uninstall finish page, shown AFTER uninstall files are removed. +; Registered as a top-level UninstPage (after MUI_UNPAGE_INSTFILES) by the +; patched NSIS.template.in, so the Function/Var live outside Section "Uninstall". +; SetAutoClose true (appended to the uninstall commands) makes the details page +; advance to this page automatically once the uninstall section finishes. +; Language follows the NSIS installer language ($LANGUAGE). +; Bitmap: NSIS built-in win.bmp, resolved via the compile-time ${NSISDIR} constant. +; Version is substituted from Snapmaker_VERSION at CMake configure time. +; Silent /S runs (the installer's own upgrade uninstall) skip all pages. +!include "nsDialogs.nsh" +!include "WinMessages.nsh" +!include "LogicLib.nsh" + +; The template loads MUI_LANGUAGE after the pages block, so LANG_SIMPCHINESE +; is not yet defined when this file is included (0x0804 = SimpChinese locale). +!ifndef LANG_SIMPCHINESE +!define LANG_SIMPCHINESE 2052 +!endif + +Var SnapmakerFinTitle +Var SnapmakerFinText +Var SnapmakerFinBitmap +Var SnapmakerFinBitmapCtl +Var SnapmakerFinFont +Var SnapmakerFinCheckbox +Var SnapmakerFinCheckboxText +Var SnapmakerFinTextCtl + +; ---- Layout constants: the only values to hand-tune. ---- +; Vertical geometry is in dialog units (u suffix applied at use sites) and +; derived at compile time with !define /math. Horizontal geometry is in pixels +; and derived at run time from the measured wizard client width, so the +; banner:text width ratio holds at any DPI or window size. +!define SF_TOP 10 ; top of the title+text block +!define SF_TITLE_H 34 ; title height (reserves two wrapped lines) +!define SF_TEXT_H 16 ; text label height (one line) +!define SF_CHECK_H 16 ; user-data checkbox height (one line) +!define SF_CHECK_BAND_NUM 3 ; checkbox floats NUM/DEN of the way into the free +!define SF_CHECK_BAND_DEN 8 ; band below the text block (3/8 = reference design) +!define SF_GAP_V 4 ; vertical gap between title and text label +!define SF_MARGIN_X 16 ; right page margin (text box to right edge), px +!define SF_GAP_X 24 ; gap between banner and text box, in pixels +!define SF_BITMAP_PART 1 ; banner share of the usable width (ratio numerator) +!define SF_TITLE_PART 2 ; text share of the usable width (ratio numerator) +!define SF_BTN_RESERVE 40 ; bottom button row height kept out of the content + ; area, in pixels (buttons are ~23px at 96 DPI and + ; scale with DPI; raise this for high-DPI overlap) +; ---- Derived values: computed, do not edit. ---- +; NOTE: !define /math takes exactly two operands, so sums are chained through +; intermediate symbols instead of written as a + b + c. +!define /math SF_TITLE_BOTTOM ${SF_TOP} + ${SF_TITLE_H} +!define /math SF_TEXT_Y ${SF_TITLE_BOTTOM} + ${SF_GAP_V} +!define /math SF_PARTS ${SF_BITMAP_PART} + ${SF_TITLE_PART} + +Function un.SnapmakerUninstallFinishCreate + StrCmp $LANGUAGE ${LANG_SIMPCHINESE} 0 SnapmakerFin_l10n_en + StrCpy $SnapmakerFinTitle "Snapmaker Orca V@Snapmaker_VERSION@ 已成功从本机卸载。" + StrCpy $SnapmakerFinText "点击【完成】关闭卸载程序" + StrCpy $SnapmakerFinCheckboxText "删除个人数据" + GetDlgItem $0 $HWNDPARENT 1 + SendMessage $0 ${WM_SETTEXT} 0 "STR:完成(&F)" + Goto SnapmakerFin_l10n_done +SnapmakerFin_l10n_en: + StrCpy $SnapmakerFinTitle "Snapmaker Orca V@Snapmaker_VERSION@ has been successfully uninstalled from this computer." + StrCpy $SnapmakerFinText "Click Finish to close the uninstaller" + StrCpy $SnapmakerFinCheckboxText "Delete personal data" + GetDlgItem $0 $HWNDPARENT 1 + SendMessage $0 ${WM_SETTEXT} 0 "STR:&Finish" +SnapmakerFin_l10n_done: + ; Finish is the only way out: disable Back and Cancel. + GetDlgItem $0 $HWNDPARENT 2 + EnableWindow $0 0 + GetDlgItem $0 $HWNDPARENT 3 + EnableWindow $0 0 + ; Welcome-page look: remove the WHOLE MUI header strip, not just its texts. + ; 1034 = header background, 1035 = line, 1037/1038 = title/subtitle, + ; 1039 = header image. (1036 is unused by MUI1's header, kept for safety.) + GetDlgItem $0 $HWNDPARENT 1034 + ShowWindow $0 ${SW_HIDE} + GetDlgItem $0 $HWNDPARENT 1035 + ShowWindow $0 ${SW_HIDE} + GetDlgItem $0 $HWNDPARENT 1036 + ShowWindow $0 ${SW_HIDE} + GetDlgItem $0 $HWNDPARENT 1037 + ShowWindow $0 ${SW_HIDE} + GetDlgItem $0 $HWNDPARENT 1038 + ShowWindow $0 ${SW_HIDE} + GetDlgItem $0 $HWNDPARENT 1039 + ShowWindow $0 ${SW_HIDE} + ; Expand the content area (1018) to the full wizard client so no empty band + ; is left where the header strip used to be (what MUI's ioSpecial pages do). + System::Call '*(i, i, i, i) i .r2' + System::Call 'user32::GetClientRect(p $HWNDPARENT, p r2)' + System::Call '*$2(i, i, i .r3, i .r4)' + System::Free $2 + IntOp $4 $4 - ${SF_BTN_RESERVE} + GetDlgItem $2 $HWNDPARENT 1018 + System::Call 'user32::SetWindowPos(p $2, p 0, i 0, i 0, i r3, i r4, i 0x4)' + ; Horizontal layout, derived from the measured client width ($3, pixels): + ; usable = width - SF_MARGIN_X (right) - SF_GAP_X (banner-to-text), split into + ; SF_PARTS equal shares with the banner taking SF_BITMAP_PART and the text + ; box SF_TITLE_PART (1:2). The banner is flush against the left edge, so the + ; text x is banner width + gap. nsDialogs treats plain numbers as pixels and + ; "Nu" as dialog units, so controls mix run-time pixel x/w with u y/h. + IntOp $5 $3 - ${SF_MARGIN_X} + IntOp $5 $5 - ${SF_GAP_X} + IntOp $6 $5 / ${SF_PARTS} + IntOp $7 $6 * ${SF_TITLE_PART} + IntOp $8 $6 + ${SF_GAP_X} + ; Welcome-page look: banner fills the page below the button row on the left. + nsDialogs::Create 1018 + Pop $0 + SetCtlColors $0 "" 0xFFFFFF + ; Banner: same win.bmp asset MUI_WELCOMEFINISHPAGE stretches on the installer + ; welcome page -- full-height (client height $4, already minus SF_BTN_RESERVE) + ; and 1/3 of the client width, flush to the top-left corner. + ${NSD_CreateBitmap} 0 0 $6 $4 "" + Pop $SnapmakerFinBitmapCtl + ${NSD_SetStretchedImage} $SnapmakerFinBitmapCtl "${NSISDIR}\Contrib\Graphics\Wizard\win.bmp" $SnapmakerFinBitmap + ; Title in welcome-page style: 12pt bold via the built-in CreateFont + ; instruction, same font face and size/weight as MUI2's own Welcome/Finish + ; titles. $(^Font) resolves per installer language (MS Shell Dlg -> Segoe UI + ; for English, SimpChinese via SimpChinese.nlf), so Chinese and English both + ; render with native glyphs. The instruction takes POINTS ("12" = 12pt = + ; 16px at 96 DPI), unlike raw gdi32::CreateFont whose height is pixels. + CreateFont $SnapmakerFinFont "$(^Font)" "12" "700" + ; The English title is long; SS_EDITCONTROL makes the static control word-wrap + ; instead of clipping. SF_TITLE_H reserves two lines; SF_TEXT_Y sits below it. + nsDialogs::CreateControl "STATIC" ${DEFAULT_STYLES}|${SS_NOTIFY}|${SS_EDITCONTROL} ${WS_EX_TRANSPARENT} $8 ${SF_TOP}u $7 ${SF_TITLE_H}u $SnapmakerFinTitle + Pop $1 + SetCtlColors $1 0x000000 0xFFFFFF + SendMessage $1 ${WM_SETFONT} $SnapmakerFinFont 1 + ${NSD_CreateLabel} $8 ${SF_TEXT_Y}u $7 ${SF_TEXT_H}u $SnapmakerFinText + Pop $SnapmakerFinTextCtl + SetCtlColors $SnapmakerFinTextCtl 0x000000 0xFFFFFF + SendMessage $SnapmakerFinTextCtl ${WM_SETFONT} $SnapmakerFinFont 1 + ; User-data checkbox, left-aligned with the texts, created UNCHECKED by + ; default so a casual Finish click keeps user data. Created at y=0; the real + ; position is set below by measuring the actual text-block bottom. + ${NSD_CreateCheckBox} $8 0 $7 ${SF_CHECK_H}u $SnapmakerFinCheckboxText + Pop $SnapmakerFinCheckbox + SetCtlColors $SnapmakerFinCheckbox 0x000000 0xFFFFFF + ; Float the checkbox SF_CHECK_BAND_NUM/SF_CHECK_BAND_DEN of the way into the + ; free band between the text block and the content-area bottom. The band's + ; two ends live in different coordinate systems (text block in dialog units, + ; content bottom in measured pixels), so measure the text label's real pixel + ; rect and place the checkbox relative to it -- correct at any DPI/size. + ; The label and the checkbox are both SF_*_H tall, so the label height is + ; reused as the checkbox height. Registers: $4 content height (preserved), + ; $8 text x, then $2 struct / $5-$7 / $9 scratch. + IntOp $9 $8 + 0 ; keep text x for SetWindowPos below + System::Call '*(i, i, i, i) i .r2' + System::Call 'user32::GetWindowRect(p $SnapmakerFinTextCtl, p r2)' + System::Call '*$2(i .r5, i .r6, i, i .r7)' ; label left/top/bottom, screen px + System::Free $2 + IntOp $8 $7 - $6 ; label height (px) = SF_TEXT_H in u + System::Call '*(i, i) i .r2' + System::Call '*$2(i r5, i r6)' + System::Call 'user32::ScreenToClient(p $HWNDPARENT, p r2)' + System::Call '*$2(i .r5, i .r6)' ; label top-left in wizard-client px + System::Free $2 + IntOp $5 $6 + $8 ; label bottom, wizard-client px + IntOp $6 $4 - $5 ; free band height (px) + IntOp $6 $6 - $8 ; minus checkbox height + IntOp $6 $6 * ${SF_CHECK_BAND_NUM} + IntOp $6 $6 / ${SF_CHECK_BAND_DEN} + IntOp $6 $5 + $6 ; final checkbox y (px) + System::Call 'user32::SetWindowPos(p $SnapmakerFinCheckbox, p 0, i r9, i r6, i 0, i 0, i 0x15)' + nsDialogs::Show + ; Clear the bitmap from the control to get back the loaded handle, then free it. + SendMessage $SnapmakerFinBitmapCtl ${STM_SETIMAGE} 0 0 + Pop $SnapmakerFinBitmap + ${NSD_FreeImage} $SnapmakerFinBitmap + ; Free the font created by CreateFont (title + text no longer use it). + System::Call 'gdi32::DeleteObject(i $SnapmakerFinFont)' +FunctionEnd + +; Leave callback of the finish page (registered as the second parameter of +; "UninstPage custom ... ..."): runs after the user clicks Finish, before the +; wizard exits. Silent /S runs show no pages, so this NEVER fires during the +; installer's own upgrade uninstall -- user data survives upgrades by design. +Function un.SnapmakerUninstallFinishLeave + ${NSD_GetState} $SnapmakerFinCheckbox $0 + ${If} $0 == ${BST_CHECKED} + Call un.SnapmakerDeleteUserData + ${Else} + Call un.SnapmakerDeletePartUserData + ${EndIf} +FunctionEnd + +; Called when "Delete personal data" is checked: remove the whole user data, +; both Local and Roaming. SetShellVarContext current is required because the +; uninstaller may have switched to "all" in un.onInit (admin installs). +; Called ONLY from the finish page leave callback -- never in silent (/S) runs, +; so upgrades never touch user data. +Function un.SnapmakerDeleteUserData + SetShellVarContext current + RMDir /r "$LOCALAPPDATA\Snapmaker_Orca" + RMDir /r "$APPDATA\Snapmaker_Orca" +FunctionEnd + +; Called when "Delete personal data" is NOT checked: partial cleanup -- delete +; everything under Local except the EBWebView folder (WebView2 cache), keeping +; EBWebView and the whole Roaming config directory. Called ONLY from the finish +; page leave callback -- never in silent (/S) runs, so upgrades never touch user data. +Function un.SnapmakerDeletePartUserData + SetShellVarContext current + StrCpy $R0 "$LOCALAPPDATA\Snapmaker_Orca" + IfFileExists "$R0\*.*" 0 SnapmakerDelPart_done + FindFirst $R1 $R2 "$R0\*.*" + StrCmp $R2 "" SnapmakerDelPart_close +SnapmakerDelPart_loop: + StrCmp $R2 "." SnapmakerDelPart_next + StrCmp $R2 ".." SnapmakerDelPart_next + StrCmp $R2 "EBWebView" SnapmakerDelPart_next + Delete "$R0\$R2" + RMDir /r "$R0\$R2" +SnapmakerDelPart_next: + FindNext $R1 $R2 + StrCmp $R2 "" SnapmakerDelPart_close + Goto SnapmakerDelPart_loop +SnapmakerDelPart_close: + FindClose $R1 +SnapmakerDelPart_done: +FunctionEnd diff --git a/cmake/nsis/SnapmakerUpgradeCleanup.nsh.in b/cmake/nsis/SnapmakerUpgradeCleanup.nsh.in new file mode 100644 index 000000000000..8dc634b26d2f --- /dev/null +++ b/cmake/nsis/SnapmakerUpgradeCleanup.nsh.in @@ -0,0 +1,94 @@ +; Upgrade cleanup, run from the install section (via CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS) +; only when an existing install was detected. Removes version-specific user data +; that must be refreshed on upgrade: +; %APPDATA%\Snapmaker_Orca\system (system configuration) +; %APPDATA%\Snapmaker_Orca\ota (OTA update cache) +; $APPDATA is resolved under SetShellVarContext current so it points at the +; current user's Roaming profile, not machine-wide ProgramData (the installer's +; .onInit sets "all" for admin installs). +; Progress is shown in the InstFiles details list via DetailPrint, one line per +; removed path. +!ifndef LANG_SIMPCHINESE +!define LANG_SIMPCHINESE 2052 +!endif + +; "1" once an existing install (uninstaller) is confirmed -> upgrade path. +Var SnapmakerIsUpgrade +; Localized "deleting:" prefix, chosen once by SnapmakerCleanupUpgradeData. +Var SnapmakerDelPrefix + +; Recursively delete the directory tree rooted at $0, printing each removed path +; to the InstFiles details list. Deletes every file/subdirectory under $0 but +; leaves $0 itself (the caller removes it with RMDir). +; Usage: Push "" Call SnapmakerDeleteDirRecursive +Function SnapmakerDeleteDirRecursive + Exch $0 + Push $1 + Push $2 + Push $3 + FindFirst $1 $2 "$0\*.*" + StrCmp $2 "" done +loop: + StrCmp $2 "." next + StrCmp $2 ".." next + StrCpy $3 "$0\$2" + IfFileExists "$3\*.*" 0 is_file + DetailPrint "$SnapmakerDelPrefix$3" + Push "$3" + Call SnapmakerDeleteDirRecursive + RMDir "$3" + ClearErrors + Goto next +is_file: + DetailPrint "$SnapmakerDelPrefix$3" + Delete "$3" + ClearErrors +next: + FindNext $1 $2 + StrCmp $2 "" done + Goto loop +done: + FindClose $1 + Pop $3 + Pop $2 + Pop $1 + Pop $0 +FunctionEnd + +; Entry point: remove system/ota upgrade residue, then restore the all-users +; shell context so later start-menu shortcut creation is unaffected. +Function SnapmakerCleanupUpgradeData + SetShellVarContext current + + StrCmp $LANGUAGE ${LANG_SIMPCHINESE} 0 SnapmakerCleanup_en + StrCpy $SnapmakerDelPrefix "删除: " + DetailPrint "正在清理升级残留数据..." + Goto SnapmakerCleanup_do + SnapmakerCleanup_en: + StrCpy $SnapmakerDelPrefix "Deleting: " + DetailPrint "Cleaning up upgrade data..." + SnapmakerCleanup_do: + + StrCpy $0 "$APPDATA\Snapmaker_Orca\system" + IfFileExists "$0\*.*" 0 SnapmakerCleanup_ota + Push "$0" + Call SnapmakerDeleteDirRecursive + RMDir "$0" + SnapmakerCleanup_ota: + + StrCpy $0 "$APPDATA\Snapmaker_Orca\ota" + IfFileExists "$0\*.*" 0 SnapmakerCleanup_flutter + Push "$0" + Call SnapmakerDeleteDirRecursive + RMDir "$0" + SnapmakerCleanup_flutter: + + StrCpy $0 "$APPDATA\Snapmaker_Orca\web\flutter_web" + IfFileExists "$0\*.*" 0 SnapmakerCleanup_done + Push "$0" + Call SnapmakerDeleteDirRecursive + RMDir "$0" + SnapmakerCleanup_done: + + SetShellVarContext all +FunctionEnd diff --git a/gitlab_installer.nsi b/gitlab_installer.nsi index 62b1e2c6c0c4..8b3adb730a16 100644 --- a/gitlab_installer.nsi +++ b/gitlab_installer.nsi @@ -1,4 +1,5 @@ ; [1] PACK_SOURCE_DIR = compile-time only (e.g. .\gitlab_build\Snapmaker_Orca). [2] INSTALL_DIR_RUNTIME = runtime install dir (default .\ = $EXEDIR). +Unicode true !include "MUI2.nsh" !include "FileFunc.nsh" !include "LogicLib.nsh" @@ -11,8 +12,12 @@ !define PRODUCT_INSTALL_KEY "Software\${PRODUCT_PUBLISHER}\${PRODUCT_NAME}" !ifndef VERSION - !define VERSION "2.3.5" + !searchparse /noerrors /file "version.inc" `set(Snapmaker_VERSION "` VERSION `")` + !ifndef VERSION + !error "VERSION is not set. Pass /DVERSION=x.y.z or keep Snapmaker_VERSION in version.inc." + !endif !endif +!define PRODUCT_DISPLAY_NAME "${PRODUCT_NAME} V${VERSION}" !ifndef SOURCE_DIR !define SOURCE_DIR ".\gitlab_build\Snapmaker_Orca" @@ -42,7 +47,7 @@ VIAddVersionKey "ProductName" "${PRODUCT_NAME}" VIAddVersionKey "Comments" "Snapmaker Orca is an open source slicer for FDM printers" VIAddVersionKey "CompanyName" "${PRODUCT_PUBLISHER}" VIAddVersionKey "LegalCopyright" "Copyright (C) ${PRODUCT_PUBLISHER}" -VIAddVersionKey "FileDescription" "${PRODUCT_NAME} ${VERSION} Installer" +VIAddVersionKey "FileDescription" "${PRODUCT_DISPLAY_NAME} Installer" VIAddVersionKey "FileVersion" "${VERSION}" VIAddVersionKey "ProductVersion" "${VERSION}" VIAddVersionKey "InternalName" "${PRODUCT_NAME}" @@ -58,8 +63,8 @@ VIAddVersionKey "OriginalFilename" "${OUTPUT_FILE}" !define MUI_UNICON ".\resources\images\Snapmaker_Orca.ico" !endif -!define MUI_WELCOMEPAGE_TITLE "Welcome to ${PRODUCT_NAME} Setup" -!define MUI_WELCOMEPAGE_TEXT "This wizard will guide you through the installation of ${PRODUCT_NAME} ${VERSION}.$\r$\n$\r$\nClick Next to continue." +!define MUI_WELCOMEPAGE_TITLE "Welcome to ${PRODUCT_DISPLAY_NAME} Setup" +!define MUI_WELCOMEPAGE_TEXT "This wizard will guide you through the installation of ${PRODUCT_DISPLAY_NAME}.$\r$\n$\r$\nClick Next to continue." !insertmacro MUI_PAGE_WELCOME !ifdef LICENSE_FILE @@ -69,7 +74,7 @@ VIAddVersionKey "OriginalFilename" "${OUTPUT_FILE}" !insertmacro MUI_PAGE_COMPONENTS -!define MUI_DIRECTORYPAGE_TEXT_TOP "Choose the folder in which to install ${PRODUCT_NAME}." +!define MUI_DIRECTORYPAGE_TEXT_TOP "Choose the folder in which to install ${PRODUCT_DISPLAY_NAME}." !insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_INSTFILES @@ -81,13 +86,17 @@ VIAddVersionKey "OriginalFilename" "${OUTPUT_FILE}" !define MUI_FINISHPAGE_LINK_LOCATION "${PRODUCT_WEB_SITE}" !insertmacro MUI_PAGE_FINISH -!insertmacro MUI_UNPAGE_CONFIRM !insertmacro MUI_UNPAGE_INSTFILES !insertmacro MUI_LANGUAGE "SimpChinese" !insertmacro MUI_LANGUAGE "English" -Name "${PRODUCT_NAME} ${VERSION}" +LangString UNINSTALL_CONFIRM_TEXT ${LANG_SIMPCHINESE} "确定要彻底卸载 ${PRODUCT_DISPLAY_NAME} 及其全部组件吗?" +LangString UNINSTALL_CONFIRM_TEXT ${LANG_ENGLISH} "Are you sure you want to completely uninstall ${PRODUCT_DISPLAY_NAME} and all of its components?" +LangString UNINSTALLER_MISSING_TEXT ${LANG_SIMPCHINESE} "检测到本机已安装 Snapmaker Orca,但找不到卸载程序。$\r$\n$\r$\n卸载路径:$R8$\r$\n安装目录:$INSTDIR$\r$\n$\r$\n请先手动删除旧安装目录后再运行本安装程序。" +LangString UNINSTALLER_MISSING_TEXT ${LANG_ENGLISH} "An existing Snapmaker Orca installation was found, but the uninstaller is missing.$\r$\n$\r$\nUninstaller: $R8$\r$\nInstall dir: $INSTDIR$\r$\n$\r$\nRemove the old folder manually, then run this installer again." + +Name "${PRODUCT_DISPLAY_NAME}" OutFile "${OUTPUT_FILE}" Section "Main program" SecMain @@ -161,22 +170,31 @@ Section "Start menu shortcut" SecStartMenu SectionEnd !insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN - !insertmacro MUI_DESCRIPTION_TEXT ${SecMain} "Install ${PRODUCT_NAME} and all required files." - !insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} "Create a desktop shortcut for ${PRODUCT_NAME}." - !insertmacro MUI_DESCRIPTION_TEXT ${SecStartMenu} "Create a start menu shortcut for ${PRODUCT_NAME}." + !insertmacro MUI_DESCRIPTION_TEXT ${SecMain} "Install ${PRODUCT_DISPLAY_NAME} and all required files." + !insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} "Create a desktop shortcut for ${PRODUCT_DISPLAY_NAME}." + !insertmacro MUI_DESCRIPTION_TEXT ${SecStartMenu} "Create a start menu shortcut for ${PRODUCT_DISPLAY_NAME}." !insertmacro MUI_FUNCTION_DESCRIPTION_END Section "Uninstall" + IfSilent un_confirm_done + MessageBox MB_ICONQUESTION|MB_YESNO|MB_DEFBUTTON2 "$(UNINSTALL_CONFIRM_TEXT)" IDYES un_confirm_done + Abort + un_confirm_done: DetailPrint "Uninstalling ${PRODUCT_NAME}..." DetailPrint "Checking for running processes..." nsExec::ExecToLog 'taskkill /F /IM snapmaker-orca.exe /T' + Pop $0 + nsExec::ExecToLog 'taskkill /F /IM Snapmaker_Orca.exe /T' + Pop $0 Sleep 500 DetailPrint "Removing desktop shortcut..." + SetShellVarContext current Delete "$DESKTOP\Snapmaker Orca.lnk" Delete "$DESKTOP\${PRODUCT_NAME}.lnk" + SetShellVarContext all DetailPrint "Removing start menu shortcut..." RMDir /r "$SMPROGRAMS\${PRODUCT_NAME}" @@ -205,10 +223,12 @@ FunctionEnd ; Prevent overwriting locked DLLs when snapmaker-orca (or legacy Snapmaker_Orca.exe) is still running. Function EnsureSnapmakerNotRunning snapmaker_check_loop: - ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq snapmaker-orca.exe" 2>nul | find /i "snapmaker-orca.exe" >nul' $0 + nsExec::Exec 'cmd.exe /c tasklist /FI "IMAGENAME eq snapmaker-orca.exe" 2>nul | find /i "snapmaker-orca.exe" >nul' + Pop $0 IntCmp $0 0 snapmaker_in_use snapmaker_try_legacy snapmaker_try_legacy snapmaker_try_legacy: - ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq Snapmaker_Orca.exe" 2>nul | find /i "Snapmaker_Orca.exe" >nul' $0 + nsExec::Exec 'cmd.exe /c tasklist /FI "IMAGENAME eq Snapmaker_Orca.exe" 2>nul | find /i "Snapmaker_Orca.exe" >nul' + Pop $0 IntCmp $0 0 snapmaker_in_use snapmaker_idle snapmaker_idle snapmaker_in_use: IfSilent snapmaker_silent snapmaker_prompt @@ -221,24 +241,120 @@ Function EnsureSnapmakerNotRunning snapmaker_idle: FunctionEnd -Function .onInit +; Sets $R9 to a usable Uninstall.exe path. Aborts if an old install exists without an uninstaller. +Function FindExistingUninstaller + StrCpy $R9 "" + ReadRegStr $R8 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "UninstallString" + StrCmp $R8 "" 0 find_uninst_have_str + ReadRegStr $R8 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "UninstallString" + StrCmp $R8 "" 0 find_uninst_have_str + ReadRegStr $R8 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker Orca" "UninstallString" + StrCmp $R8 "" 0 find_uninst_have_str + ReadRegStr $R8 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker Orca" "UninstallString" + StrCmp $R8 "" 0 find_uninst_have_str + IfFileExists "$INSTDIR\Uninstall.exe" 0 find_uninst_check_app + StrCpy $R8 "$INSTDIR\Uninstall.exe" + Goto find_uninst_have_str + find_uninst_check_app: + IfFileExists "$INSTDIR\snapmaker-orca.exe" find_uninst_missing find_uninst_check_legacy + find_uninst_check_legacy: + IfFileExists "$INSTDIR\Snapmaker_Orca.exe" find_uninst_missing find_uninst_done + find_uninst_have_str: + StrCpy $0 $R8 1 0 + StrCmp $0 "$\"" 0 find_uninst_check_last + StrCpy $R8 $R8 "" 1 + find_uninst_check_last: + StrCpy $0 $R8 1 -1 + StrCmp $0 "$\"" 0 find_uninst_exists + StrCpy $R8 $R8 -1 + find_uninst_exists: + IfFileExists "$R8" 0 find_uninst_missing + StrCpy $R9 $R8 + Goto find_uninst_done + find_uninst_missing: + MessageBox MB_OK|MB_ICONSTOP "$(UNINSTALLER_MISSING_TEXT)" + Abort + find_uninst_done: +FunctionEnd - Call EnsureSnapmakerNotRunning +; Sets $R7 to the already-installed version from ARP DisplayVersion / Version. +Function ReadInstalledVersion + StrCpy $R7 "" + SetRegView 64 + Call ReadInstalledVersionFromView + StrCmp $R7 "" 0 read_ver_strip + SetRegView 32 + Call ReadInstalledVersionFromView + read_ver_strip: + SetRegView 32 + StrCmp $R7 "" read_ver_done + StrCpy $0 $R7 1 + StrCmp $0 "V" read_ver_strip_one + StrCmp $0 "v" read_ver_strip_one read_ver_done + read_ver_strip_one: + StrCpy $R7 $R7 "" 1 + read_ver_done: +FunctionEnd - ReadRegStr $R0 ${PRODUCT_UNINST_ROOT_KEY} "${PRODUCT_UNINST_KEY}" "UninstallString" - StrCmp $R0 "" done +Function ReadInstalledVersionFromView + ReadRegStr $R7 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "DisplayVersion" + StrCmp $R7 "" 0 read_ver_view_done + ReadRegStr $R7 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "DisplayVersion" + StrCmp $R7 "" 0 read_ver_view_done + ReadRegStr $R7 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker Orca" "DisplayVersion" + StrCmp $R7 "" 0 read_ver_view_done + ReadRegStr $R7 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker Orca" "DisplayVersion" + StrCmp $R7 "" 0 read_ver_view_done + ReadRegStr $R7 HKLM "Software\Snapmaker\Snapmaker_Orca" "Version" + StrCmp $R7 "" 0 read_ver_view_done + ReadRegStr $R7 HKCU "Software\Snapmaker\Snapmaker_Orca" "Version" + StrCmp $R7 "" 0 read_ver_view_done + ReadRegStr $R7 HKLM "Software\Snapmaker\Snapmaker Orca" "Version" + StrCmp $R7 "" 0 read_ver_view_done + ReadRegStr $R7 HKCU "Software\Snapmaker\Snapmaker Orca" "Version" + read_ver_view_done: +FunctionEnd - MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION \ - "${PRODUCT_NAME} is already installed.$\n$\nClick OK to uninstall the old version, or Cancel to abort." \ - IDOK uninst - Abort +Function .onInit + + Call EnsureSnapmakerNotRunning + Call FindExistingUninstaller + StrCmp $R9 "" done + StrCpy $R0 $R9 + Call ReadInstalledVersion + + StrCmp $R7 "" already_no_ver already_with_ver + already_with_ver: + MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION \ + "Snapmaker Orca V$R7 is already installed.$\n$\nClick OK to uninstall the old version, or Cancel to abort." \ + /SD IDOK IDOK uninst + Abort + already_no_ver: + MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION \ + "Snapmaker Orca is already installed.$\n$\nClick OK to uninstall the old version, or Cancel to abort." \ + /SD IDOK IDOK uninst + Abort uninst: ClearErrors - ExecWait '$R0 _?=$INSTDIR' - - IfErrors no_remove_uninstaller done - no_remove_uninstaller: + StrLen $R1 "Uninstall.exe" + StrCpy $R2 $R0 -$R1 + uninst_trim_slash: + StrCpy $R1 $R2 1 -1 + StrCmp $R1 "\" uninst_do_trim uninst_slash_done + uninst_do_trim: + StrCpy $R2 $R2 -1 + Goto uninst_trim_slash + uninst_slash_done: + IfFileExists "$R0" 0 no_remove_uninstaller + ; /S skips the new uninstall confirm dialog during upgrade. + ExecWait '"$R0" /S _?=$R2' $R3 + IfErrors no_remove_uninstaller + IntCmp $R3 0 done no_remove_uninstaller no_remove_uninstaller + + no_remove_uninstaller: + MessageBox MB_OK|MB_ICONSTOP "Uninstall failed.$\nUninstaller path: $R0$\nWorking dir: $R2" + Abort done: FunctionEnd diff --git a/installer.nsi b/installer.nsi index d48093c22281..a62f7d62d60b 100644 --- a/installer.nsi +++ b/installer.nsi @@ -1,4 +1,5 @@ -; [1] PACK_SOURCE_DIR = compile-time only (e.g. .\build\Snapmaker_Orca). [2] INSTALL_DIR_RUNTIME = runtime install dir (default .\ = $EXEDIR). +; [1] PACK_SOURCE_DIR = compile-time only (e.g. .\build\Snapmaker_Orca). [2] INSTALL_DIR_RUNTIME = runtime install dir (default .\ = $EXEDIR). +Unicode true !include "MUI2.nsh" !include "FileFunc.nsh" !include "LogicLib.nsh" @@ -11,8 +12,12 @@ !define PRODUCT_INSTALL_KEY "Software\${PRODUCT_PUBLISHER}\${PRODUCT_NAME}" !ifndef VERSION - !define VERSION "2.4.0" + !searchparse /noerrors /file "version.inc" `set(Snapmaker_VERSION "` VERSION `")` + !ifndef VERSION + !error "VERSION is not set. Pass /DVERSION=x.y.z or keep Snapmaker_VERSION in version.inc." + !endif !endif +!define PRODUCT_DISPLAY_NAME "${PRODUCT_NAME} V${VERSION}" !ifndef SOURCE_DIR !define SOURCE_DIR ".\build\Snapmaker_Orca" @@ -42,7 +47,7 @@ VIAddVersionKey "ProductName" "${PRODUCT_NAME}" VIAddVersionKey "Comments" "Snapmaker Orca is an open source slicer for FDM printers" VIAddVersionKey "CompanyName" "${PRODUCT_PUBLISHER}" VIAddVersionKey "LegalCopyright" "Copyright (C) ${PRODUCT_PUBLISHER}" -VIAddVersionKey "FileDescription" "${PRODUCT_NAME} ${VERSION} Installer" +VIAddVersionKey "FileDescription" "${PRODUCT_DISPLAY_NAME} Installer" VIAddVersionKey "FileVersion" "${VERSION}" VIAddVersionKey "ProductVersion" "${VERSION}" VIAddVersionKey "InternalName" "${PRODUCT_NAME}" @@ -58,8 +63,8 @@ VIAddVersionKey "OriginalFilename" "${OUTPUT_FILE}" !define MUI_UNICON ".\resources\images\Snapmaker_Orca.ico" !endif -!define MUI_WELCOMEPAGE_TITLE "Welcome to ${PRODUCT_NAME} Setup" -!define MUI_WELCOMEPAGE_TEXT "This wizard will guide you through the installation of ${PRODUCT_NAME} ${VERSION}.$\r$\n$\r$\nClick Next to continue." +!define MUI_WELCOMEPAGE_TITLE "Welcome to ${PRODUCT_DISPLAY_NAME} Setup" +!define MUI_WELCOMEPAGE_TEXT "This wizard will guide you through the installation of ${PRODUCT_DISPLAY_NAME}.$\r$\n$\r$\nClick Next to continue." !insertmacro MUI_PAGE_WELCOME !ifdef LICENSE_FILE @@ -69,7 +74,7 @@ VIAddVersionKey "OriginalFilename" "${OUTPUT_FILE}" !insertmacro MUI_PAGE_COMPONENTS -!define MUI_DIRECTORYPAGE_TEXT_TOP "Choose the folder in which to install ${PRODUCT_NAME}." +!define MUI_DIRECTORYPAGE_TEXT_TOP "Choose the folder in which to install ${PRODUCT_DISPLAY_NAME}." !insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_INSTFILES @@ -81,13 +86,17 @@ VIAddVersionKey "OriginalFilename" "${OUTPUT_FILE}" !define MUI_FINISHPAGE_LINK_LOCATION "${PRODUCT_WEB_SITE}" !insertmacro MUI_PAGE_FINISH -!insertmacro MUI_UNPAGE_CONFIRM !insertmacro MUI_UNPAGE_INSTFILES !insertmacro MUI_LANGUAGE "SimpChinese" !insertmacro MUI_LANGUAGE "English" -Name "${PRODUCT_NAME} ${VERSION}" +LangString UNINSTALL_CONFIRM_TEXT ${LANG_SIMPCHINESE} "确定要彻底卸载 ${PRODUCT_DISPLAY_NAME} 及其全部组件吗?" +LangString UNINSTALL_CONFIRM_TEXT ${LANG_ENGLISH} "Are you sure you want to completely uninstall ${PRODUCT_DISPLAY_NAME} and all of its components?" +LangString UNINSTALLER_MISSING_TEXT ${LANG_SIMPCHINESE} "检测到本机已安装 Snapmaker Orca,但找不到卸载程序。$\r$\n$\r$\n卸载路径:$R8$\r$\n安装目录:$INSTDIR$\r$\n$\r$\n请先手动删除旧安装目录后再运行本安装程序。" +LangString UNINSTALLER_MISSING_TEXT ${LANG_ENGLISH} "An existing Snapmaker Orca installation was found, but the uninstaller is missing.$\r$\n$\r$\nUninstaller: $R8$\r$\nInstall dir: $INSTDIR$\r$\n$\r$\nRemove the old folder manually, then run this installer again." + +Name "${PRODUCT_DISPLAY_NAME}" OutFile "${OUTPUT_FILE}" Section "Main program" SecMain @@ -101,10 +110,10 @@ Section "Main program" SecMain DetailPrint "Target dir: $INSTDIR" DetailPrint "Copying files..." - - ; PACK_SOURCE_DIR = compile time only. At runtime this File extracts from embedded payload to $INSTDIR. Exclude include and lib dirs. + + ; PACK_SOURCE_DIR = compile time only. At runtime this File extracts from embedded payload to $INSTDIR. File /r /x "*.pdb" /x "*.ilk" /x "*.exp" /x "*.lib" /x "*.obj" /x "*.idb" /x "*.tlog" /x "*.h" /x "*.hpp" /x "*.c" /x "*.cpp" /x "*.cxx" /x "*.cc" /x "*.vcxproj" /x "*.vcxproj.filters" /x "*.sln" /x "*.cmake" /x "*.py" /x "*.md" /x "*.vcxproj.user" /x "CMakeFiles" /x "RelWithDebInfo" /x "Debug" /x "MinSizeRel" /x ".vs" /x "vcpkg_installed" /x "*.dir" /x "include\*" /x "lib\*" "${PACK_SOURCE_DIR}\*.*" - + IfFileExists "$INSTDIR\snapmaker-orca.exe" 0 extract_error DetailPrint "Creating uninstaller..." @@ -161,22 +170,31 @@ Section "Start menu shortcut" SecStartMenu SectionEnd !insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN - !insertmacro MUI_DESCRIPTION_TEXT ${SecMain} "Install ${PRODUCT_NAME} and all required files." - !insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} "Create a desktop shortcut for ${PRODUCT_NAME}." - !insertmacro MUI_DESCRIPTION_TEXT ${SecStartMenu} "Create a start menu shortcut for ${PRODUCT_NAME}." + !insertmacro MUI_DESCRIPTION_TEXT ${SecMain} "Install ${PRODUCT_DISPLAY_NAME} and all required files." + !insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} "Create a desktop shortcut for ${PRODUCT_DISPLAY_NAME}." + !insertmacro MUI_DESCRIPTION_TEXT ${SecStartMenu} "Create a start menu shortcut for ${PRODUCT_DISPLAY_NAME}." !insertmacro MUI_FUNCTION_DESCRIPTION_END Section "Uninstall" + IfSilent un_confirm_done + MessageBox MB_ICONQUESTION|MB_YESNO|MB_DEFBUTTON2 "$(UNINSTALL_CONFIRM_TEXT)" IDYES un_confirm_done + Abort + un_confirm_done: DetailPrint "Uninstalling ${PRODUCT_NAME}..." DetailPrint "Checking for running processes..." nsExec::ExecToLog 'taskkill /F /IM snapmaker-orca.exe /T' + Pop $0 + nsExec::ExecToLog 'taskkill /F /IM Snapmaker_Orca.exe /T' + Pop $0 Sleep 500 DetailPrint "Removing desktop shortcut..." + SetShellVarContext current Delete "$DESKTOP\Snapmaker Orca.lnk" Delete "$DESKTOP\${PRODUCT_NAME}.lnk" + SetShellVarContext all DetailPrint "Removing start menu shortcut..." RMDir /r "$SMPROGRAMS\${PRODUCT_NAME}" @@ -205,10 +223,12 @@ FunctionEnd ; Prevent overwriting locked DLLs when snapmaker-orca (or legacy Snapmaker_Orca.exe) is still running. Function EnsureSnapmakerNotRunning snapmaker_check_loop: - ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq snapmaker-orca.exe" 2>nul | find /i "snapmaker-orca.exe" >nul' $0 + nsExec::Exec 'cmd.exe /c tasklist /FI "IMAGENAME eq snapmaker-orca.exe" 2>nul | find /i "snapmaker-orca.exe" >nul' + Pop $0 IntCmp $0 0 snapmaker_in_use snapmaker_try_legacy snapmaker_try_legacy snapmaker_try_legacy: - ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq Snapmaker_Orca.exe" 2>nul | find /i "Snapmaker_Orca.exe" >nul' $0 + nsExec::Exec 'cmd.exe /c tasklist /FI "IMAGENAME eq Snapmaker_Orca.exe" 2>nul | find /i "Snapmaker_Orca.exe" >nul' + Pop $0 IntCmp $0 0 snapmaker_in_use snapmaker_idle snapmaker_idle snapmaker_in_use: IfSilent snapmaker_silent snapmaker_prompt @@ -221,24 +241,120 @@ Function EnsureSnapmakerNotRunning snapmaker_idle: FunctionEnd +; Sets $R9 to a usable Uninstall.exe path. Aborts if an old install exists without an uninstaller. +Function FindExistingUninstaller + StrCpy $R9 "" + ReadRegStr $R8 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "UninstallString" + StrCmp $R8 "" 0 find_uninst_have_str + ReadRegStr $R8 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "UninstallString" + StrCmp $R8 "" 0 find_uninst_have_str + ReadRegStr $R8 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker Orca" "UninstallString" + StrCmp $R8 "" 0 find_uninst_have_str + ReadRegStr $R8 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker Orca" "UninstallString" + StrCmp $R8 "" 0 find_uninst_have_str + IfFileExists "$INSTDIR\Uninstall.exe" 0 find_uninst_check_app + StrCpy $R8 "$INSTDIR\Uninstall.exe" + Goto find_uninst_have_str + find_uninst_check_app: + IfFileExists "$INSTDIR\snapmaker-orca.exe" find_uninst_missing find_uninst_check_legacy + find_uninst_check_legacy: + IfFileExists "$INSTDIR\Snapmaker_Orca.exe" find_uninst_missing find_uninst_done + find_uninst_have_str: + StrCpy $0 $R8 1 0 + StrCmp $0 "$\"" 0 find_uninst_check_last + StrCpy $R8 $R8 "" 1 + find_uninst_check_last: + StrCpy $0 $R8 1 -1 + StrCmp $0 "$\"" 0 find_uninst_exists + StrCpy $R8 $R8 -1 + find_uninst_exists: + IfFileExists "$R8" 0 find_uninst_missing + StrCpy $R9 $R8 + Goto find_uninst_done + find_uninst_missing: + MessageBox MB_OK|MB_ICONSTOP "$(UNINSTALLER_MISSING_TEXT)" + Abort + find_uninst_done: +FunctionEnd + +; Sets $R7 to the already-installed version from ARP DisplayVersion / Version. +Function ReadInstalledVersion + StrCpy $R7 "" + SetRegView 64 + Call ReadInstalledVersionFromView + StrCmp $R7 "" 0 read_ver_strip + SetRegView 32 + Call ReadInstalledVersionFromView + read_ver_strip: + SetRegView 32 + StrCmp $R7 "" read_ver_done + StrCpy $0 $R7 1 + StrCmp $0 "V" read_ver_strip_one + StrCmp $0 "v" read_ver_strip_one read_ver_done + read_ver_strip_one: + StrCpy $R7 $R7 "" 1 + read_ver_done: +FunctionEnd + +Function ReadInstalledVersionFromView + ReadRegStr $R7 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "DisplayVersion" + StrCmp $R7 "" 0 read_ver_view_done + ReadRegStr $R7 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker_Orca" "DisplayVersion" + StrCmp $R7 "" 0 read_ver_view_done + ReadRegStr $R7 HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker Orca" "DisplayVersion" + StrCmp $R7 "" 0 read_ver_view_done + ReadRegStr $R7 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Snapmaker Orca" "DisplayVersion" + StrCmp $R7 "" 0 read_ver_view_done + ReadRegStr $R7 HKLM "Software\Snapmaker\Snapmaker_Orca" "Version" + StrCmp $R7 "" 0 read_ver_view_done + ReadRegStr $R7 HKCU "Software\Snapmaker\Snapmaker_Orca" "Version" + StrCmp $R7 "" 0 read_ver_view_done + ReadRegStr $R7 HKLM "Software\Snapmaker\Snapmaker Orca" "Version" + StrCmp $R7 "" 0 read_ver_view_done + ReadRegStr $R7 HKCU "Software\Snapmaker\Snapmaker Orca" "Version" + read_ver_view_done: +FunctionEnd + Function .onInit Call EnsureSnapmakerNotRunning + Call FindExistingUninstaller + StrCmp $R9 "" done + StrCpy $R0 $R9 + Call ReadInstalledVersion + + StrCmp $R7 "" already_no_ver already_with_ver + already_with_ver: + MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION \ + "Snapmaker Orca V$R7 is already installed.$\n$\nClick OK to uninstall the old version, or Cancel to abort." \ + /SD IDOK IDOK uninst + Abort + already_no_ver: + MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION \ + "Snapmaker Orca is already installed.$\n$\nClick OK to uninstall the old version, or Cancel to abort." \ + /SD IDOK IDOK uninst + Abort - ReadRegStr $R0 ${PRODUCT_UNINST_ROOT_KEY} "${PRODUCT_UNINST_KEY}" "UninstallString" - StrCmp $R0 "" done - - MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION \ - "${PRODUCT_NAME} is already installed.$\n$\nClick OK to uninstall the old version, or Cancel to abort." \ - IDOK uninst - Abort - uninst: ClearErrors - ExecWait '$R0 _?=$INSTDIR' - - IfErrors no_remove_uninstaller done - no_remove_uninstaller: - + StrLen $R1 "Uninstall.exe" + StrCpy $R2 $R0 -$R1 + uninst_trim_slash: + StrCpy $R1 $R2 1 -1 + StrCmp $R1 "\" uninst_do_trim uninst_slash_done + uninst_do_trim: + StrCpy $R2 $R2 -1 + Goto uninst_trim_slash + uninst_slash_done: + IfFileExists "$R0" 0 no_remove_uninstaller + ; /S skips the new uninstall confirm dialog during upgrade. + ExecWait '"$R0" /S _?=$R2' $R3 + IfErrors no_remove_uninstaller + IntCmp $R3 0 done no_remove_uninstaller no_remove_uninstaller + + no_remove_uninstaller: + MessageBox MB_OK|MB_ICONSTOP "Uninstall failed.$\nUninstaller path: $R0$\nWorking dir: $R2" + Abort + done: FunctionEnd diff --git a/resources/AppIcon.icon/Assets/glyph.svg b/resources/AppIcon.icon/Assets/glyph.svg new file mode 100644 index 000000000000..a51c5927aab8 --- /dev/null +++ b/resources/AppIcon.icon/Assets/glyph.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/AppIcon.icon/icon.json b/resources/AppIcon.icon/icon.json new file mode 100644 index 000000000000..acee721a1343 --- /dev/null +++ b/resources/AppIcon.icon/icon.json @@ -0,0 +1,28 @@ +{ + "fill": { + "automatic-gradient": "extended-srgb:0.09020,0.09020,0.09020,1.00000" + }, + "groups": [ + { + "layers": [ + { + "image-name": "glyph.svg", + "name": "glyph", + "hidden": false, + "position": { + "scale": 1, + "translation-in-points": [ + 0, + 0 + ] + } + } + ] + } + ], + "supported-platforms": { + "squares": [ + "macOS" + ] + } +} \ No newline at end of file diff --git a/resources/Assets.car b/resources/Assets.car new file mode 100644 index 000000000000..a76dbb22a965 Binary files /dev/null and b/resources/Assets.car differ diff --git a/resources/Icon.icns b/resources/Icon.icns index 51a2da34b45c..96481776d28d 100644 Binary files a/resources/Icon.icns and b/resources/Icon.icns differ diff --git a/resources/images/Snapmaker_Orca-mac_256px.ico b/resources/images/Snapmaker_Orca-mac_256px.ico deleted file mode 100644 index 2888d553c3de..000000000000 Binary files a/resources/images/Snapmaker_Orca-mac_256px.ico and /dev/null differ diff --git a/resources/images/Snapmaker_Orca.icns b/resources/images/Snapmaker_Orca.icns index 51a2da34b45c..96481776d28d 100644 Binary files a/resources/images/Snapmaker_Orca.icns and b/resources/images/Snapmaker_Orca.icns differ diff --git a/resources/profiles/Snapmaker/filament/filament_allow_list.json b/resources/profiles/Snapmaker/filament/filament_allow_list.json index afb5a8c3d6bd..c0381ab10586 100644 --- a/resources/profiles/Snapmaker/filament/filament_allow_list.json +++ b/resources/profiles/Snapmaker/filament/filament_allow_list.json @@ -4,6 +4,18 @@ "high_flow": { "not_recommended_filaments": ["PLA Wood", "TPU 90A", "PEBA 90A"], "unavailable_filaments": ["TPU 85A"] + }, + "filament_order": { + "Snapmaker": [ + "PLA SnapSpeed", + "PLA Matte", + "PETG HF", + "PLA Full Spectrum", + "TPU 95A HF", + "PEBA 90A", + "ABS", + "ASA" + ] } } } diff --git a/scripts/regen_app_icons.sh b/scripts/regen_app_icons.sh new file mode 100755 index 000000000000..e19df5c1c21d --- /dev/null +++ b/scripts/regen_app_icons.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Regenerate the macOS app icon artifacts under resources/: +# - Assets.car layered icon used by macOS 26+ (Tahoe); wired via CFBundleIconName +# - Icon.icns legacy fallback used on macOS 12-15 and older paths; wired via CFBundleIconFile +# +# Requires Xcode 26+ (actool for Assets.car). iconutil ships with any Xcode. +# The 1024px icon master is NOT committed to the repo (design source); the +# Icon.icns step is documented below and must be run from the design export. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +echo "Compiling layered icon (Assets.car) from resources/AppIcon.icon..." +xcrun actool "$ROOT/resources/AppIcon.icon" --compile "$ROOT/resources" \ + --platform macosx --minimum-deployment-target 10.15 \ + --target-device mac --app-icon AppIcon + +# Icon.icns regeneration (manual step): +# 1. Export 16/32/64/128/256/512/1024 px frames from the 1024px master into an +# iconset directory: icon_16x16.png, icon_16x16@2x.png, ..., icon_512x512@2x.png. +# HARD REQUIREMENT: the tile (dark rounded rect) must measure exactly +# 824x824 px on the 1024 frame and be centered on the canvas (Apple +# 824/1024 icon grid). A previous regen drifted to a ~748px tile (73% +# canvas, ~22px above center) because the tile was drawn around an +# under-scaled glyph - the geometry check below fails loudly on that. +# Keeping the @2x pairs is mandatory - dropping them (as happened once +# before) makes the icon change size between Dock, Cmd-Tab and Finder. +# 2. Then run: +# iconutil -c icns "" -o "$ROOT/resources/Icon.icns" +# 3. Keep the twin in sync (document-type icons reference the same file): +cp "$ROOT/resources/Icon.icns" "$ROOT/resources/images/Snapmaker_Orca.icns" +# 4. Geometry self-check (fails the script on grid/size/centering drift). +if ! python3 -c 'import PIL' 2>/dev/null; then + echo "WARNING: python3/PIL not available, skipping icns geometry check." +else + python3 - "$ROOT/resources/Icon.icns" << 'PYEOF' +import struct, io, sys +from PIL import Image + +data = open(sys.argv[1], 'rb').read() +assert data[:4] == b'icns', 'not an icns file' +total = struct.unpack('>I', data[4:8])[0] +assert total == len(data), 'icns length mismatch' +chunks, off = {}, 8 +while off < total: + typ = data[off:off+4].decode('latin1') + ln = struct.unpack('>I', data[off+4:off+8])[0] + chunks[typ] = data[off+8:off+ln] + off += ln + +errors = [] +for typ, canvas in (('ic10', 1024), ('ic09', 512), ('ic08', 256)): + if typ not in chunks: + errors.append(f'{typ}: chunk missing') + continue + img = Image.open(io.BytesIO(chunks[typ])).convert('RGBA') + w, h = img.size + px = img.load() + dark = [(x, y) for y in range(h) for x in range(w) + if px[x, y][3] > 128 and px[x, y][0] < 60 and px[x, y][1] < 60 and px[x, y][2] < 60] + xs = [p[0] for p in dark]; ys = [p[1] for p in dark] + x0, y0, x1, y1 = min(xs), min(ys), max(xs), max(ys) + tw, th = x1 - x0 + 1, y1 - y0 + 1 + cx, cy = (x0 + x1) / 2, (y0 + y1) / 2 + target = int(canvas * 824 / 1024) + if abs(tw - target) > canvas * 0.01 or abs(th - target) > canvas * 0.01: + errors.append(f'{typ}: tile {tw}x{th}, expected ~{target}x{target} (Apple 824/1024 grid)') + if abs(cx - w / 2) > canvas * 0.01 or abs(cy - h / 2) > canvas * 0.01: + errors.append(f'{typ}: tile center ({cx:.1f},{cy:.1f}) off canvas center ({w/2},{h/2})') + +if errors: + print('ICNS GEOMETRY CHECK FAILED:') + for e in errors: + print(' -', e) + sys.exit(1) +print('ICNS geometry OK: tile on the Apple 824/1024 grid, centered.') +PYEOF +fi diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index 893c8e832f60..56515ef2c1fd 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -8,6 +8,7 @@ #include "Thread.hpp" #include "format.hpp" #include "nlohmann/json.hpp" +#include "common_func/common_func.hpp" #include #include @@ -46,6 +47,13 @@ static const std::string MODELS_STR = "models"; #define APP_UPDATE_URL_BASE_CN "https://meta-cfg.snapmaker.cn" #define APP_UPDATE_URL_BASE_EN "https://meta-cfg.snapmaker.com" +// snapmaker-config gray release API (POST /config/get). Release builds default to the +// https prod endpoint; internal testing builds (BBL_INTERNAL_TESTING, flipped together +// with BBL_RELEASE_TO_PUBLIC in common_func.hpp) default to the http dev gateway, which +// resolves only inside the office network. Per-machine override: "orca_config_api_url". +#define CONFIG_API_URL_DEV "http://gateway.s.com/api/config/get" +#define CONFIG_API_URL_PROD "https://api.snapmaker.com/api/config/get" + #if defined(_WIN32) static const std::string APP_UPDATE_URL = std::string("/upgrade/orca/win/"); #elif defined(__APPLE__) @@ -1496,6 +1504,19 @@ std::string AppConfig::get_version_upgrade_url(bool stable_only /* = false*/) return url; } +std::string AppConfig::get_config_api_url() +{ + std::string overrideUrl = get("orca_config_api_url"); + if (!overrideUrl.empty()) + return overrideUrl; + +#if BBL_INTERNAL_TESTING + return CONFIG_API_URL_DEV; +#else + return CONFIG_API_URL_PROD; +#endif +} + std::string AppConfig::version_check_url(bool stable_only/* = false*/) const { auto from_settings = get("version_check_url"); diff --git a/src/libslic3r/AppConfig.hpp b/src/libslic3r/AppConfig.hpp index 1f4345c560ea..a7e0d781d4d2 100644 --- a/src/libslic3r/AppConfig.hpp +++ b/src/libslic3r/AppConfig.hpp @@ -382,6 +382,8 @@ class AppConfig std::string get_version_upgrade_url(bool stable_only = false); std::string get_preset_upgrade_url(); std::string get_web_resource_upgrade_url(); + // snapmaker-config gray release API endpoint; can be overridden by "orca_config_api_url" (debug/testing) + std::string get_config_api_url(); // Returns the original Slic3r version found in the ini file before it was overwritten // by the current version diff --git a/src/libslic3r/MixedFilamentConfigRemap.hpp b/src/libslic3r/MixedFilamentConfigRemap.hpp new file mode 100644 index 000000000000..c6a237f3568d --- /dev/null +++ b/src/libslic3r/MixedFilamentConfigRemap.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include "PrintConfig.hpp" + +#include +#include +#include + +namespace Slic3r { + +/// Feature-filament option keys that can be overridden at object or volume level. +inline const std::vector &mixed_filament_feature_keys() +{ + static const std::vector keys = { + "wall_filament", + "sparse_infill_filament", + "solid_infill_filament", + "support_filament", + "support_interface_filament", + }; + return keys; +} + +/// Map a 1-based filament ID through a deletion remap table. +/// +/// The table is indexed by the old 1-based ID and contains the new 1-based ID. +/// Zero means that the old filament no longer exists and the caller should use +/// its default behavior. `total_filaments` is a defensive upper bound. +inline unsigned int remap_filament_config_id(int old_id, + const std::vector &id_remap, + size_t total_filaments) +{ + if (old_id <= 0 || static_cast(old_id) >= id_remap.size()) + return 0; + + const unsigned int mapped_id = id_remap[static_cast(old_id)]; + return static_cast(mapped_id) > total_filaments ? 0 : mapped_id; +} + +/// Remap object/volume config-level filament references after a deletion. +/// +/// `extruder` uses explicit zero for the default filament, matching the existing +/// mixed-filament cleanup convention. Feature-filament overrides are erased when +/// their old filament is gone so the corresponding global default takes effect. +inline void remap_model_config_filament_ids(ModelConfig &config, + const std::vector &id_remap, + size_t total_filaments) +{ + if (config.has("extruder")) { + const int old_id = config.extruder(); + const unsigned int mapped_id = remap_filament_config_id(old_id, id_remap, total_filaments); + if (mapped_id == 0) { + if (old_id > 0) + config.set("extruder", 0); + } else { + config.set("extruder", static_cast(mapped_id)); + } + } + + for (const std::string &key : mixed_filament_feature_keys()) { + if (!config.has(key)) + continue; + + const int old_id = config.opt_int(key); + const unsigned int mapped_id = remap_filament_config_id(old_id, id_remap, total_filaments); + if (mapped_id == 0) { + if (old_id > 0) + config.erase(key); + } else { + config.set(key, static_cast(mapped_id)); + } + } +} + +/// Remap feature-filament keys in a global DynamicPrintConfig after a deletion. +inline void remap_dynamic_config_feature_filament_ids(DynamicPrintConfig &config, + const std::vector &id_remap, + size_t total_filaments) +{ + for (const std::string &key : mixed_filament_feature_keys()) { + if (!config.has(key)) + continue; + + const int old_id = config.opt_int(key); + const unsigned int mapped_id = remap_filament_config_id(old_id, id_remap, total_filaments); + if (mapped_id == 0) { + if (old_id > 0) + config.erase(key); + } else { + config.set(key, static_cast(mapped_id)); + } + } +} + +} // namespace Slic3r diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index b618641c35e3..64d1af344e5b 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -1126,7 +1126,7 @@ class Print : public PrintBaseWithState bool m_support_used {false}; //BBS: plate's origin - Vec3d m_origin; + Vec3d m_origin {Vec3d::Zero()}; //BBS: modified_count int m_modified_count {0}; //BBS diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index ab426363b29a..056757f0d2ed 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -378,6 +378,12 @@ set(SLIC3R_GUI_SOURCES GUI/Preferences.hpp GUI/PresetComboBoxes.cpp GUI/PresetComboBoxes.hpp + GUI/FilamentDropDown.cpp + GUI/FilamentDropDown.hpp + GUI/FilamentSort.cpp + GUI/FilamentSort.hpp + GUI/PlaterFilamentComboBox.cpp + GUI/PlaterFilamentComboBox.hpp GUI/PresetHints.cpp GUI/PresetHints.hpp GUI/PrinterCloudAuthDialog.cpp @@ -806,4 +812,4 @@ target_compile_definitions(libslic3r_gui PRIVATE SLIC3R_CURRENTLY_COMPILING_GUI_ if (SLIC3R_SENTRY) target_compile_definitions(libslic3r_gui PUBLIC SLIC3R_SENTRY) target_link_libraries(libslic3r_gui sentry::sentry) -endif() \ No newline at end of file +endif() diff --git a/src/slic3r/GUI/Field.cpp b/src/slic3r/GUI/Field.cpp index 4b9a0c00690b..cf123b1871d8 100644 --- a/src/slic3r/GUI/Field.cpp +++ b/src/slic3r/GUI/Field.cpp @@ -1546,7 +1546,23 @@ void Choice::set_value(const boost::any& value, bool change_event) ++idx; } if (m_list) - field->SetSelection(m_list->index_of(text_value)); + { + const int index = m_list->index_of(text_value); + const int item_count = int(field->GetCount()); + + if (index >= 0 && index < item_count) { + field->SetSelection(index); + } + else { + // Mirror filament deletion: rebuild the dynamic choices, keep the + // combo box unselected with the default drop-down icon, and display + // the first item label as the placeholder text. + m_list->update(); + field->SetSelection(-1); + if (field->GetCount() > 0) + field->SetLabel(field->GetString(0)); + } + } else if (idx == enums.size()) { // For editable Combobox under OSX is needed to set selection to -1 explicitly, // otherwise selection doesn't be changed diff --git a/src/slic3r/GUI/FilamentDropDown.cpp b/src/slic3r/GUI/FilamentDropDown.cpp new file mode 100644 index 000000000000..7bc5525b25b1 --- /dev/null +++ b/src/slic3r/GUI/FilamentDropDown.cpp @@ -0,0 +1,1176 @@ +#include "FilamentDropDown.hpp" +#include "Widgets/Label.hpp" + +#include +#include +#include + +#include + +#include +#include +#include +#include + +#ifdef __WXGTK__ +#include +#endif + +#ifdef __WIN32__ +#include +#endif + +namespace +{ + +constexpr int g_filament_submenu_width_dip = 200; + +size_t max_visible_row_count(int max_visible_rows) +{ + return max_visible_rows > 0 ? static_cast(max_visible_rows) : size_t{1}; +} + +int multiply_to_int(int value, size_t multiplier) +{ + if (value <= 0 || multiplier == 0) + return 0; + + const size_t positive_value = static_cast(value); + const size_t max_int = static_cast(std::numeric_limits::max()); + if (positive_value > max_int / multiplier) + return std::numeric_limits::max(); + return static_cast(positive_value * multiplier); +} + +int add_to_int(int left, int right) +{ + if (right > 0 && left > std::numeric_limits::max() - right) + return std::numeric_limits::max(); + if (right < 0 && left < std::numeric_limits::min() - right) + return std::numeric_limits::min(); + return left + right; +} + +bool point_in_anchor_gap(const wxWindow *anchor, const wxWindow *popup, const wxPoint &screen_point) +{ + if (anchor == nullptr || popup == nullptr) + return false; + + const wxRect anchor_rect = anchor->GetScreenRect(); + const wxRect popup_rect = popup->GetScreenRect(); + const int left = std::max(anchor_rect.GetLeft(), popup_rect.GetLeft()); + const int right = std::min(anchor_rect.GetRight(), popup_rect.GetRight()); + if (right < left) + return false; + + // Screen y grows downward. Boundary pixels belong to the anchor or popup, so the clickable gap is + // strictly between the two rectangles. + if (popup_rect.GetTop() > anchor_rect.GetBottom()) + { + return screen_point.x >= left && screen_point.x <= right && screen_point.y > anchor_rect.GetBottom() && + screen_point.y < popup_rect.GetTop(); + } + + if (popup_rect.GetBottom() < anchor_rect.GetTop()) + { + return screen_point.x >= left && screen_point.x <= right && screen_point.y > popup_rect.GetBottom() && + screen_point.y < anchor_rect.GetTop(); + } + + return false; +} + +} // namespace + +FilamentDropDown::FilamentDropDown(const std::vector &items) + : items(items) + , state_handler(this) + , text_color(0x363636) + , border_color(0xDBDBDB) + , selector_border_color(std::make_pair(0x009688, static_cast(StateColor::Hovered)), + std::make_pair(*wxWHITE, static_cast(StateColor::Normal))) + , selector_background_color(std::make_pair(0xBFE1DE, static_cast(StateColor::Checked)), + std::make_pair(*wxWHITE, static_cast(StateColor::Normal))) +{ +} + +FilamentDropDown::~FilamentDropDown() +{ + submenu_motion_timer.Stop(); + + // The submenu is a sibling window under the same wx parent, and wx destroys children in creation + // order, so it is still alive here; drop its raw back-pointer before it can use it again. + if (subDropDown != nullptr) + subDropDown->mainDropDown = nullptr; +} + +bool FilamentDropDown::Create(wxWindow *parent, long style) +{ + if (parent == nullptr || !PopupWindow::Create(parent, wxPU_CONTAINS_CONTROLS)) + return false; + + Bind(wxEVT_LEFT_DOWN, &FilamentDropDown::mouseDown, this); + Bind(wxEVT_LEFT_UP, &FilamentDropDown::mouseReleased, this); + Bind(wxEVT_MOUSE_CAPTURE_LOST, &FilamentDropDown::mouseCaptureLost, this); + Bind(wxEVT_MOTION, &FilamentDropDown::mouseMove, this); + Bind(wxEVT_MOUSEWHEEL, &FilamentDropDown::mouseWheelMoved, this); + Bind(wxEVT_PAINT, &FilamentDropDown::paintEvent, this); +#ifdef __WXGTK__ + submenu_motion_timer.SetOwner(this); + Bind(wxEVT_TIMER, &FilamentDropDown::on_submenu_motion_timer, this, submenu_motion_timer.GetId()); +#endif + + SetBackgroundStyle(wxBG_STYLE_PAINT); + SetBackgroundColour(*wxWHITE); + state_handler.attach({&border_color, &text_color, &selector_border_color, &selector_background_color}); + state_handler.update_binds(); + if ((style & DD_NO_CHECK_ICON) == 0) + check_bitmap = ScalableBitmap(this, "checked", 16); + arrow_bitmap = ScalableBitmap(this, "hms_arrow", 16); + text_off = style & DD_NO_TEXT; + + // BBS set default font + SetFont(Label::Body_14); +#ifdef __WXOSX__ + // PopupWindow releases mouse on idle, which may cause various problems, + // such as losting mouse move, and dismissing soon on first LEFT_DOWN event. + Bind(wxEVT_IDLE, [](wxIdleEvent &evt) {}); +#endif + + return true; +} + +void FilamentDropDown::Invalidate(bool clear) +{ + if (clear) + { + selection = hover_item = -1; + offset = wxPoint(); + SetToolTip(wxString()); + } + if (selection >= 0 && static_cast(selection) >= items.size()) + selection = -1; + need_sync = true; +} + +void FilamentDropDown::SetItems(const std::vector &new_items) +{ + items = new_items; + if (use_flat_fallback) + apply_flat_fallback(); + Invalidate(true); + if (subDropDown != nullptr) + { + subDropDown->setGroup(wxString()); + subDropDown->SetItems(new_items); + } +} + +void FilamentDropDown::SetSelection(int n) +{ + if (n < 0 || static_cast(n) >= items.size()) + n = -1; + if (selection == n) return; + selection = n; + if (need_sync) // for icon Size + { + messureSize(); + } + if (subDropDown) + subDropDown->SetSelection(n); + paintNow(); +} + +void FilamentDropDown::SetUseContentWidth(bool use, bool limit_max_content_width) +{ + if (use_content_width == use) + return; + use_content_width = use; + this->limit_max_content_width = limit_max_content_width; + need_sync = true; + messureSize(); +} + +void FilamentDropDown::SetAlignIcon(bool align) +{ + align_icon = align; +} + +void FilamentDropDown::Rescale() +{ + need_sync = true; + if (subDropDown != nullptr) + subDropDown->Rescale(); +} + +bool FilamentDropDown::HasDismissLongTime() +{ + auto now = boost::posix_time::microsec_clock::universal_time(); + return !IsShown() && (now - dismissTime).total_milliseconds() >= 20; +} + +void FilamentDropDown::PopupForParent() +{ + prepare_submenu(); + autoPosition(); + Popup(this); +} + +void FilamentDropDown::prepare_submenu() +{ + if (mainDropDown != nullptr || use_flat_fallback || subDropDown != nullptr) + return; + + const bool has_grouped_items = std::any_of(items.begin(), items.end(), [](const Item &item) + { + return !item.group.IsEmpty(); + }); + if (!has_grouped_items) + return; + + std::unique_ptr new_sub_drop_down = std::make_unique(items); + new_sub_drop_down->mainDropDown = this; + new_sub_drop_down->mainDropDownWeak = this; + new_sub_drop_down->check_bitmap = check_bitmap; + new_sub_drop_down->text_off = text_off; + new_sub_drop_down->use_content_width = true; + new_sub_drop_down->limit_max_content_width = true; + // One width for every group: switching groups must not resize the popup under the pointer. This value + // wins over the two switches above and is read as "the width is fixed" by the messureSize width + // override and the +6 padding branch, so set it here only. + new_sub_drop_down->fixed_width_dip = g_filament_submenu_width_dip; + new_sub_drop_down->max_visible_rows = 8; + if (!new_sub_drop_down->Create(GetParent())) + { + BOOST_LOG_TRIVIAL(warning) + << "Could not create the filament submenu; falling back to a flat filament list without the " + "fixed submenu width."; + apply_flat_fallback(); + return; + } + + subDropDown = new_sub_drop_down.release(); + wxWeakRef weak_root(this); + subDropDown->Bind(wxEVT_COMBOBOX, [weak_root](wxCommandEvent &e) + { + FilamentDropDown *root = weak_root.get(); + if (root == nullptr) + return; + e.SetEventObject(root); + e.SetId(root->GetId()); + root->GetEventHandler()->ProcessEvent(e); + }); +} + +void FilamentDropDown::apply_flat_fallback() +{ + use_flat_fallback = true; + for (Item &item : items) + item.group.clear(); + + hover_item = -1; + offset = wxPoint(); + SetToolTip(wxString()); + need_sync = true; +} + +void FilamentDropDown::DismissAll() +{ + dismiss_outside = false; + if (subDropDown != nullptr) + { + subDropDown->dismiss_outside = false; + subDropDown->submenu_motion_timer.Stop(); + // The child override intentionally keeps the root open while the + // pointer is over it. An owner-driven close must close both windows. + subDropDown->PopupWindow::Dismiss(); + subDropDown->Hide(); + } + submenu_motion_timer.Stop(); + PopupWindow::Dismiss(); + Hide(); +} + +int FilamentDropDown::group_row_of(const wxString &target) const +{ + if (target.IsEmpty()) + return -1; + + const std::vector rows = visible_rows(); + for (size_t row = 0; row < rows.size(); ++row) + { + const VisibleRow &visible_row = rows[row]; + if (!visible_row.group_header || items[visible_row.item_index].group != target) + continue; + if (row > static_cast(std::numeric_limits::max())) + return -1; + return static_cast(row); + } + return -1; +} + +std::vector FilamentDropDown::visible_rows() const +{ + return build_visible_rows(items, group); +} + +void FilamentDropDown::ensure_row_visible(int row) +{ + if (row < 0 || rowSize.y <= 0) + return; + + const size_t max_rows = max_visible_row_count(max_visible_rows); + size_t visible_count = std::min(max_rows, std::max(count, size_t{1})); + const int client_height = GetClientSize().y; + if (client_height > 0) + { + const size_t client_rows = static_cast(client_height / rowSize.y); + if (client_rows > 0) + visible_count = std::min(visible_count, client_rows); + } + if (count <= visible_count) + { + offset.y = 0; + return; + } + + const size_t row_index = static_cast(row); + if (row_index >= count) + return; + + const int viewport_height = multiply_to_int(rowSize.y, visible_count); + const int content_height = multiply_to_int(rowSize.y, count); + const int row_top = multiply_to_int(rowSize.y, row_index); + const int row_bottom = multiply_to_int(rowSize.y, row_index + 1); + + if (add_to_int(offset.y, row_top) < 0) + offset.y = -row_top; + else if (add_to_int(offset.y, row_bottom) > viewport_height) + offset.y = viewport_height - row_bottom; + + const int minimum_offset = viewport_height - content_height; + if (offset.y < minimum_offset) + offset.y = minimum_offset; + if (offset.y > 0) + offset.y = 0; +} + +void FilamentDropDown::ensure_selection_at_top() +{ + const int selected_row = selectedItem(); + if (selected_row < 0 || rowSize.y <= 0) + return; + + const size_t max_rows = max_visible_row_count(max_visible_rows); + size_t visible_count = std::min(max_rows, std::max(count, size_t{1})); + const int client_height = GetClientSize().y; + if (client_height > 0) + { + const size_t client_rows = static_cast(client_height / rowSize.y); + if (client_rows > 0) + visible_count = std::min(visible_count, client_rows); + } + if (count <= visible_count) + { + offset.y = 0; + return; + } + + const int viewport_height = multiply_to_int(rowSize.y, visible_count); + const int content_height = multiply_to_int(rowSize.y, count); + const int selected_top = multiply_to_int(rowSize.y, static_cast(selected_row)); + const int minimum_offset = viewport_height - content_height; + offset.y = std::max(minimum_offset, -selected_top); + if (offset.y > 0) + offset.y = 0; +} + +void FilamentDropDown::show_submenu() +{ + if (subDropDown == nullptr) + return; + + if (!subDropDown->IsShown()) + subDropDown->Popup(subDropDown); + +#ifdef __WXGTK__ + if (subDropDown->IsShown() && !subDropDown->submenu_motion_timer.IsRunning()) + subDropDown->submenu_motion_timer.Start(30, wxTIMER_CONTINUOUS); +#endif +} + +void FilamentDropDown::on_submenu_motion_timer(wxTimerEvent &event) +{ +#ifdef __WXGTK__ + static_cast(event); + FilamentDropDown *root = mainDropDownWeak.get(); + if (root == nullptr || !IsShown()) + { + submenu_motion_timer.Stop(); + return; + } + + const wxPoint mouse_pos = wxGetMousePosition(); + if (GetScreenRect().Contains(mouse_pos)) + return; + + const wxPoint local_pt = root->ScreenToClient(mouse_pos); + wxMouseEvent mouse_evt(wxEVT_MOTION); + mouse_evt.SetX(local_pt.x); + mouse_evt.SetY(local_pt.y); + wxPostEvent(root, mouse_evt); +#else + event.Skip(); +#endif +} + +bool FilamentDropDown::openSelectionGroup() +{ + if (!group.IsEmpty() || subDropDown == nullptr || selection < 0 || static_cast(selection) >= items.size()) + return false; + + const wxString target = items[selection].group; + const int row = group_row_of(target); + if (row < 0) + return false; + + hover_item = row; + ensure_row_visible(row); + paintNow(); + + auto &drop = *subDropDown; + if (drop.group != target) + { + drop.setGroup(target); + drop.messureSize(); + } + drop.SetSelection(selection); + drop.autoPosition(); + drop.ensure_selection_at_top(); + drop.paintNow(); + show_submenu(); + return true; +} + +void FilamentDropDown::paintEvent(wxPaintEvent &evt) +{ + // depending on your system you may need to look at double-buffered dcs + wxBufferedPaintDC dc(this); + render(dc); +} + +void FilamentDropDown::paintNow() +{ + Refresh(); +} + +static wxSize GetBmpSize(const wxBitmap &bmp) +{ + if (!bmp.IsOk()) + return wxSize(0, 0); +#ifdef __APPLE__ + return bmp.GetScaledSize(); +#else + return bmp.GetSize(); +#endif +} + +void FilamentDropDown::render(wxDC &dc) +{ + if (items.empty()) + return; + + int states = state_handler.states(); + if (subDropDown != nullptr) + states |= subDropDown->state_handler.states(); + + const wxSize size = GetSize(); + render_background(dc, size, states); + + const int selected_item = selectedItem(); + const int hover_index = hoverIndex(); + wxRect content = {{0, offset.y}, rowSize}; + SelectionRenderContext selection_context{size, states, selected_item, hover_index, content}; + render_selection(dc, selection_context); + + dc.SetBrush(*wxTRANSPARENT_BRUSH); + const wxSize text_offset = (rowSize - textSize) / 2; + content.Deflate(0, text_offset.y); + render_scroll_bar(dc, size, content); + + content.x += 5; + content.width -= 5; + if (check_bitmap.bmp().IsOk()) + { + const wxSize bitmap_size = check_bitmap.GetBmpSize(); + if (selected_item >= 0) + { + wxPoint point = content.GetLeftTop(); + point.y += (content.height - bitmap_size.y) / 2; + point.y = add_to_int(point.y, multiply_to_int(rowSize.y, static_cast(selected_item))); + if (point.y + bitmap_size.y > 0 && point.y < size.y) + dc.DrawBitmap(check_bitmap.bmp(), point); + } + content.x += bitmap_size.x + 5; + content.width -= bitmap_size.x + 5; + } + + render_items(dc, size, states, content); +} + +void FilamentDropDown::render_background(wxDC &dc, const wxSize &size, int states) +{ + dc.SetPen(wxPen(border_color.colorForStates(states))); + dc.SetBrush(wxBrush(StateColor::darkModeColorFor(GetBackgroundColour()))); + if (radius == 0) + dc.DrawRectangle(0, 0, size.x, size.y); + else + dc.DrawRoundedRectangle(0, 0, size.x, size.y, radius); +} + +void FilamentDropDown::render_selection(wxDC &dc, SelectionRenderContext &context) +{ + const wxSize &size = context.size; + if (hover_item >= 0 && (context.states & StateColor::Hovered) && + (context.hover_index < 0 || + !(items[context.hover_index].style & DD_ITEM_STYLE_DISABLED))) + { + context.content.y = add_to_int(context.content.y, multiply_to_int(rowSize.y, static_cast(hover_item))); + if (context.content.GetBottom() > 0 && context.content.y < size.y) + { + if (context.selected_item == hover_item) + dc.SetBrush(wxBrush(selector_background_color.colorForStates(context.states | StateColor::Checked))); + dc.SetPen(wxPen(selector_border_color.colorForStates(context.states))); + context.content.Deflate(4, 1); + dc.DrawRectangle(context.content); + context.content.Inflate(4, 1); + } + context.content.y = offset.y; + } + if (context.selected_item >= 0 && + (context.selected_item != hover_item || (context.states & StateColor::Hovered) == 0)) + { + context.content.y = add_to_int(context.content.y, + multiply_to_int(rowSize.y, static_cast(context.selected_item))); + if (context.content.GetBottom() > 0 && context.content.y < size.y) + { + dc.SetBrush(wxBrush(selector_background_color.colorForStates(context.states | StateColor::Checked))); + dc.SetPen(wxPen(selector_background_color.colorForStates(context.states))); + context.content.Deflate(4, 1); + dc.DrawRectangle(context.content); + context.content.Inflate(4, 1); + } + context.content.y = offset.y; + } +} + +void FilamentDropDown::render_scroll_bar(wxDC &dc, const wxSize &size, wxRect &content) +{ + const int total_height = multiply_to_int(rowSize.y, count); + if (total_height > size.y) + { + const int height = total_height; + const wxRect rect = {size.x - 6, -offset.y * size.y / height, 4, size.y * size.y / height}; + dc.SetPen(wxPen(border_color.defaultColor())); + dc.SetBrush(wxBrush(*wxLIGHT_GREY)); + dc.DrawRoundedRectangle(rect, 2); + content.width -= 6; + } +} + +void FilamentDropDown::render_items(wxDC &dc, const wxSize &size, int states, wxRect &rcContent) +{ + const std::vector rows = visible_rows(); + for (size_t index = 0; index < rows.size(); ++index) + { + const VisibleRow &visible_row = rows[index]; + const Item &item = items[visible_row.item_index]; + int states2 = states; + const bool is_dimmed = (item.style & DD_ITEM_STYLE_DIMMED) != 0; + if ((item.style & DD_ITEM_STYLE_DISABLED) != 0) + states2 &= ~StateColor::Enabled; + if (visible_row.group_header) + states2 |= StateColor::Enabled; + const bool is_hover = hover_item >= 0 && index == static_cast(hover_item); + if (rcContent.GetBottom() < 0) + { + rcContent.y += rowSize.y; + continue; + } + if (rcContent.y > size.y) break; + wxPoint pt = rcContent.GetLeftTop(); + + const bool is_top_level_group = visible_row.group_header; + auto & icon = item.icon; + auto size2 = GetBmpSize(icon); + if (iconSize.x > 0) + { + if (!is_top_level_group && icon.IsOk()) + { + pt.y += (rcContent.height - size2.y) / 2; + dc.DrawBitmap(icon, pt); + } + pt.x += iconSize.x + 5; + pt.y = rcContent.y; + } + else if (!is_top_level_group && icon.IsOk()) + { + pt.y += (rcContent.height - size2.y) / 2; + dc.DrawBitmap(icon, pt); + pt.x += size2.x + 5; + pt.y = rcContent.y; + } + // Full-row bitmap (icon height >> text height) already contains the text. + const bool icon_fills_row = !is_top_level_group && icon.IsOk() && size2.y > textSize.y * 2; + auto text = group.IsEmpty() ? (item.group.IsEmpty() ? item.text : item.group) + : strip_group_prefix(item.text, group); + if (!text_off && !text.IsEmpty() && !icon_fills_row) + { + // The popup paints through a buffered DC, which arrives without the popup font while the row is + // drawn with it: measuring first tests the overflow against a narrower font and clips the row + // instead of eliding it. + dc.SetFont(GetFont()); + wxSize tSize = dc.GetMultiLineTextExtent(text); + if (pt.x + tSize.x > rcContent.GetRight()) + { + if (is_hover && item.tip.IsEmpty()) + SetToolTip(text); + text = wxControl::Ellipsize(text, dc, wxELLIPSIZE_END, rcContent.GetRight() - pt.x); + } + pt.y += (rcContent.height - textSize.y) / 2; + dc.SetTextForeground(is_dimmed ? wxColour(0xCE, 0xCE, 0xCE) : text_color.colorForStates(states2)); + dc.DrawText(text, pt); + if (group.IsEmpty() && !item.group.IsEmpty()) + { + auto szBmp = arrow_bitmap.GetBmpSize(); + pt.x = rcContent.GetRight() - szBmp.x - 5; + pt.y = rcContent.y + (rcContent.height - szBmp.y) / 2; + dc.DrawBitmap(arrow_bitmap.bmp(), pt); + } + } + rcContent.y += rowSize.y; + } +} + +int FilamentDropDown::hoverIndex() +{ + return item_index_for_visible_row(visible_rows(), hover_item); +} + +int FilamentDropDown::selectedItem() +{ + return selected_row_for_item(items, group, selection); +} + +void FilamentDropDown::messureSize() +{ + if (!need_sync) return; + textSize = wxSize(); + iconSize = wxSize(); + count = 0; + wxClientDC dc(GetParent() ? GetParent() : this); + dc.SetFont(GetFont()); + const std::vector rows = visible_rows(); + for (const VisibleRow &visible_row : rows) + { + const Item &item = items[visible_row.item_index]; + ++count; + wxSize size1; + if (!text_off) + { + auto text = group.IsEmpty() ? (item.group.IsEmpty() ? item.text : item.group) + : strip_group_prefix(item.text, group); + size1 = dc.GetMultiLineTextExtent(text); + if (group.IsEmpty() && !item.group.IsEmpty()) + size1.x += 5 + arrow_bitmap.GetBmpWidth(); + } + const bool is_top_level_group = visible_row.group_header; + if (!is_top_level_group && item.icon.IsOk()) + { + wxSize size2 = GetBmpSize(item.icon); + if (size2.x > iconSize.x) iconSize = size2; + if (!align_icon) + { + // Full-row bitmap (icon height >> text height): width = bitmap width only. + if (size2.y > size1.y * 2) + size1.x = size2.x; + else + size1.x += size2.x + (text_off ? 0 : 5); + } + } + if (size1.x > textSize.x) textSize = size1; + } + if (!align_icon) iconSize.x = 0; + wxSize szContent = textSize; + szContent.x += 10; + if (check_bitmap.bmp().IsOk()) + { + auto szBmp = check_bitmap.GetBmpSize(); + szContent.x += szBmp.x + 5; + } + if (iconSize.x > 0) szContent.x += iconSize.x + (text_off ? 0 : 5); + if (iconSize.y > szContent.y) szContent.y = iconSize.y; + szContent.y += 10; + const size_t max_rows = max_visible_row_count(max_visible_rows); + if (count > max_rows) + szContent.x = add_to_int(szContent.x, 6); + if (GetParent() && group.IsEmpty()) + { + auto x = GetParent()->GetSize().x; + if (x > 0 && (!use_content_width || x > szContent.x)) + szContent.x = x; + } + rowSize = szContent; + if (limit_max_content_width) + { + wxSize parent_size = GetParent()->GetSize(); + const int max_width = add_to_int(parent_size.x, parent_size.x); + if (rowSize.x > max_width) + { + rowSize.x = max_width; + szContent = rowSize; + } + } + if (fixed_width_dip > 0) + { + // A fixed width outranks the content extent, the parent-width cap above and the scroll-bar padding + // for `count > max_rows`. Scale it with the anchor's display: the popup still sits at its creation + // position here, so its own DPI can belong to another monitor. + rowSize.x = GetParent()->FromDIP(fixed_width_dip); + szContent.x = rowSize.x; + } + if (mainDropDown != nullptr) + { + // The root row is the shared vertical rhythm for both popup levels. Measure it first so + // rescaling and font changes cannot leave the child with a stale row height. + mainDropDown->messureSize(); + rowSize.y = mainDropDown->rowSize.y; + szContent.y = rowSize.y; + } + const size_t visible_rows = std::min(max_rows, std::max(count, size_t{1})); + szContent.y = multiply_to_int(szContent.y, visible_rows); + if (items.size() > max_rows) + szContent.y = add_to_int(szContent.y, rowSize.y / 2); + wxWindow::SetSize(szContent); +#ifdef __WXGTK__ + // Gtk has a wrapper window for popup widget + // Fix for GNOME Platform 48 X11 backend: ensure size is valid before calling gtk_window_resize + int gtk_width = szContent.x; + int gtk_height = szContent.y; + if (gtk_width <= 0) gtk_width = 100; + if (gtk_height <= 0) gtk_height = 100; + gtk_window_resize(GTK_WINDOW(m_widget), gtk_width, gtk_height); +#endif + need_sync = false; +} + +void FilamentDropDown::position_submenu() +{ + if (mainDropDown == nullptr || GetParent() == nullptr) + return; + + const wxRect parent_rect = mainDropDown->GetScreenRect(); + const wxRect display_rect = wxDisplay(GetParent()).GetGeometry(); + const int parent_left = parent_rect.GetLeft(); + const int parent_right = parent_left + parent_rect.GetWidth(); + const int popup_width = GetSize().x; + const int popup_height = GetSize().y; + const int row_index = std::max(mainDropDown->hover_item, 0); + const int row_top = add_to_int(parent_rect.GetTop(), + add_to_int(multiply_to_int(mainDropDown->rowSize.y, + static_cast(row_index)), + mainDropDown->offset.y)); + const int right_x = parent_right; + const int left_x = parent_left - popup_width; + const int right_space = display_rect.GetRight() + 1 - right_x; + const int left_space = parent_left - display_rect.GetLeft(); + int popup_x = right_x; + if (right_space < popup_width && left_space >= popup_width) + popup_x = left_x; + else if (right_space < popup_width && left_space < popup_width && left_space > right_space) + popup_x = left_x; + + int popup_y = row_top; + const int display_bottom = display_rect.GetBottom() + 1; + if (popup_y + popup_height > display_bottom) + popup_y = std::max(display_rect.GetTop(), display_bottom - popup_height); + + const wxPoint popup_position(popup_x, popup_y); + if (popup_position != GetPosition()) + SetPosition(popup_position); +} + +void FilamentDropDown::autoPosition() +{ + messureSize(); + const size_t max_rows = max_visible_row_count(max_visible_rows); + wxPoint pos; + wxSize off; + if (mainDropDown != nullptr) + { + pos = mainDropDown->ClientToScreen(wxPoint(0, 0)); + pos.y = add_to_int(pos.y, + add_to_int(multiply_to_int(mainDropDown->rowSize.y, + static_cast(std::max(mainDropDown->hover_item, 0))), + mainDropDown->offset.y)); + } + else + { + pos = GetParent()->ClientToScreen(wxPoint(0, -6)); + off = GetParent()->GetSize(); + off.x = 0; + off.y += 12; + } + wxPoint old = GetPosition(); + wxSize size = GetSize(); + if (mainDropDown != nullptr) + position_submenu(); + else + Position(pos, off); + if (old != GetPosition()) + { + size = rowSize; + size.y = multiply_to_int(rowSize.y, std::min(max_rows, count)); + if (count > max_rows) + size.y = add_to_int(size.y, rowSize.y / 2); +#ifdef __WXGTK__ + if (size.x < 1) size.x = 1; + if (size.y < 1) size.y = 1; +#endif + if (size != GetSize()) + { + wxWindow::SetSize(size); + offset = wxPoint(); + if (mainDropDown != nullptr) + position_submenu(); + else + Position(pos, off); + } + } + if (GetPosition().y > pos.y) + { + // may exceed + auto drect = wxDisplay(GetParent()).GetGeometry(); + if (GetPosition().y + size.y + 10 > drect.GetBottom()) + { + // Mirror of the scroll-bar padding in messureSize(): +6 here while the list does not scroll, +6 + // there while it does. A fixed width must survive both. + if (use_content_width && fixed_width_dip == 0 && count <= max_rows) + size.x = add_to_int(size.x, 6); + size.y = drect.GetBottom() - GetPosition().y - 10; +#ifdef __WXGTK__ + if (size.y < 1) size.y = 1; + if (size.x < 1) size.x = 1; +#endif + wxWindow::SetSize(size); + // Scroll by the same rule as the group-switch path. Group folding and submenu filtering + // make the raw item index differ from the visible row index, so clamping against + // `selection` here could push every row above the viewport and paint an empty popup. + ensure_selection_at_top(); + const int minimum_offset = size.y - multiply_to_int(rowSize.y, count); + if (offset.y < minimum_offset) + offset.y = minimum_offset; + if (offset.y > 0) + offset.y = 0; + } + } + +} + +void FilamentDropDown::setGroup(const wxString &value) +{ + if (group == value) + return; + + group = value; + hover_item = -1; + offset = wxPoint(); + need_sync = true; + SetToolTip(wxString()); +} + +void FilamentDropDown::mouseDown(wxMouseEvent &event) +{ + // Receivce unexcepted LEFT_DOWN on Mac after OnDismiss + if (!IsShown()) + return; + // force calc hover item again + mouseMove(event); + pressedDown = true; + CaptureMouse(); + dragStart = event.GetPosition(); +} + +void FilamentDropDown::mouseReleased(wxMouseEvent &event) +{ + if (pressedDown) + { + dragStart = wxPoint(); + pressedDown = false; + if (HasCapture()) + ReleaseMouse(); + if (hover_item < 0) + return; + + // A top-level group header opens (or focuses) the drill-down submenu instead of + // dismissing, so a narrow one-row group is still reachable by click. + int idx = hoverIndex(); + if (idx < -1 && subDropDown) + { + const wxString &target_group = items[-idx - 2].group; + auto & drop = *subDropDown; + if (drop.group != target_group) + { + drop.setGroup(target_group); + drop.messureSize(); + drop.autoPosition(); + drop.paintNow(); + } + if (!drop.IsShown()) + show_submenu(); + return; + } + + if (hover_item >= 0 && (subDropDown == nullptr || subDropDown->group.empty())) // not moved + { + sendDropDownEvent(); + if (mainDropDown) + mainDropDown->hover_item = -1; // To Dismiss mainDropDown + DismissAndNotify(); + } + else if (subDropDown) + show_submenu(); + } +} + +void FilamentDropDown::mouseCaptureLost(wxMouseCaptureLostEvent &event) +{ + wxMouseEvent evt; + mouseReleased(evt); +} + +void FilamentDropDown::mouseMove(wxMouseEvent &event) +{ + wxPoint pt = event.GetPosition(); +#ifdef __WXOSX__ + if (mainDropDown) + { + auto size = GetSize(); + if (pt.x < 0 || pt.y < 0 || pt.x >= size.x || pt.y >= size.y) + { + auto diff = GetPosition() - mainDropDown->GetPosition(); + event.SetX(pt.x + diff.x); + event.SetY(pt.y + diff.y); + mainDropDown->mouseMove(event); + return; + } + } +#endif + if (pressedDown) + { + wxPoint pt2 = offset + pt - dragStart; + wxSize size = GetSize(); + dragStart = pt; + if (pt2.y > 0) + pt2.y = 0; + else + { + const int total_height = multiply_to_int(rowSize.y, count); + if (add_to_int(pt2.y, total_height) < size.y) + pt2.y = size.y - total_height; + } + if (pt2.y != offset.y) + { + offset = pt2; + hover_item = -1; // moved + // DismissAll, not Dismiss: the child's pointer guard would veto while the pointer is over the popup tree. + if (subDropDown != nullptr && subDropDown->IsShown()) + subDropDown->DismissAll(); + } + else + { + return; + } + } + if (rowSize.y > 0 && (!pressedDown || hover_item >= 0)) + { + int hover = (pt.y - offset.y) / rowSize.y; + if (hover < 0 || static_cast(hover) >= count) + hover = -1; + if (hover == hover_item) return; + hover_item = hover; + int index = hoverIndex(); + if (index < -1 && subDropDown) + { + SetToolTip(wxString()); + auto &drop = *subDropDown; + drop.setGroup(items[-index - 2].group); + drop.messureSize(); + drop.autoPosition(); + drop.paintNow(); + show_submenu(); + } + else if (index >= 0) + { + if (subDropDown) + { + subDropDown->setGroup(wxString()); + if (subDropDown->IsShown()) + subDropDown->Dismiss(); + } + SetToolTip(items[index].tip); + } + else + { + SetToolTip(wxString()); + } + } + paintNow(); +} + +void FilamentDropDown::mouseWheelMoved(wxMouseEvent &event) +{ + auto delta = event.GetWheelRotation(); + wxSize size = GetSize(); + wxPoint pt2 = offset + wxPoint{0, delta}; + if (pt2.y > 0) + pt2.y = 0; + else + { + const int total_height = multiply_to_int(rowSize.y, count); + if (add_to_int(pt2.y, total_height) < size.y) + pt2.y = size.y - total_height; + } + if (pt2.y != offset.y) + { + offset = pt2; + // DismissAll, not Dismiss: the child's pointer guard would veto while the pointer is over the popup tree. + if (subDropDown != nullptr && subDropDown->IsShown()) + subDropDown->DismissAll(); + } + else + { + return; + } + int hover = (event.GetPosition().y - offset.y) / rowSize.y; + if (hover < 0 || static_cast(hover) >= count) + hover = -1; + if (hover != hover_item) + { + hover_item = hover; + const int index = hoverIndex(); + if (index >= 0) + SetToolTip(items[index].tip); + } + paintNow(); +} + +void FilamentDropDown::sendDropDownEvent() +{ + int index = hoverIndex(); + if (index < 0 || static_cast(index) >= items.size() || (items[index].style & DD_ITEM_STYLE_DISABLED)) + return; + wxCommandEvent event(wxEVT_COMBOBOX, GetId()); + event.SetEventObject(this); + event.SetInt(index); + event.SetString(items[index].text); + GetEventHandler()->ProcessEvent(event); +} + +bool FilamentDropDown::ProcessLeftDown(wxMouseEvent &event) +{ +#ifdef __WXOSX__ + if (IsShown() && HitTest(event.GetPosition()) == wxHT_WINDOW_OUTSIDE) + { + const wxPoint screen_point = ClientToScreen(event.GetPosition()); + if (point_in_anchor_gap(GetParent(), this, screen_point)) + { + DismissAndNotify(); + + // wxOSX reposts an outside click to the control below the popup. The anchor/popup gap is not + // an activation target, so consume it after dismissing. + return true; + } + + // The popup handler will call DismissAndNotify() next. Force that path to release + // native mouse capture even when the submenu is still visible. + if (!is_pointer_over_popup_tree()) + { + dismiss_outside = true; + } + } +#endif + + return PopupWindow::ProcessLeftDown(event); +} + +bool FilamentDropDown::is_pointer_over_popup_tree() const +{ + const wxPoint mouse_pos = wxGetMousePosition(); + + // A submenu is a sibling of the root popup, not a child of it: only its own window and the + // shared anchor belong to its tree. Counting the root popup here kept the submenu open when + // the pointer moved onto the root, leaving a stale list behind and blocking root-item clicks. + if (mainDropDown != nullptr) + { + if (GetScreenRect().Contains(mouse_pos)) + return true; + const wxWindow *anchor = GetParent(); + return anchor != nullptr && anchor->GetScreenRect().Contains(mouse_pos); + } + + // Root popup: its tree is the root window, the visible submenu, and the shared anchor. + if (GetScreenRect().Contains(mouse_pos)) + return true; + if (subDropDown != nullptr && subDropDown->IsShown() && subDropDown->GetScreenRect().Contains(mouse_pos)) + return true; + const wxWindow *anchor = GetParent(); + return anchor != nullptr && anchor->GetScreenRect().Contains(mouse_pos); +} + +void FilamentDropDown::Dismiss() +{ + if (!dismiss_outside && is_pointer_over_popup_tree()) + { + return; + } + if (dismiss_outside && subDropDown != nullptr && subDropDown->IsShown()) + { + // The root popup is dismissed by wx's handler on an outside click. Close the + // sibling submenu explicitly as it has its own popup handler and capture. + subDropDown->dismiss_outside = false; + subDropDown->PopupWindow::Dismiss(); + subDropDown->Hide(); + } + PopupWindow::Dismiss(); +} + +void FilamentDropDown::OnDismiss() +{ + const bool was_dismissed_outside = dismiss_outside; + dismiss_outside = false; + submenu_motion_timer.Stop(); + hover_item = -1; + SetToolTip(wxString()); + + if (mainDropDown) + { + const wxPoint &mouse_pos = wxGetMousePosition(); + if (!mainDropDown->GetScreenRect().Contains(mouse_pos)) + mainDropDown->DismissAndNotify(); + else +#ifdef __WIN32__ + SetActiveWindow(mainDropDown->GetHandle()); +#else + ; +#endif + return; + } + if (!was_dismissed_outside && is_pointer_over_popup_tree()) + { + return; + } + dismissTime = boost::posix_time::microsec_clock::universal_time(); + wxCommandEvent e(EVT_DISMISS); + GetEventHandler()->ProcessEvent(e); +} diff --git a/src/slic3r/GUI/FilamentDropDown.hpp b/src/slic3r/GUI/FilamentDropDown.hpp new file mode 100644 index 000000000000..9d8c9f9f7ba2 --- /dev/null +++ b/src/slic3r/GUI/FilamentDropDown.hpp @@ -0,0 +1,301 @@ +#pragma once + +// Fork of Widgets/DropDown (e700c93d81) + BambuStudio DropDown grouping (77b9dd94d); Widgets/* untouched. +// Ported from upstream after that base: the macOS anchor-gap hit test of 5a7090fdf1, reworked here as +// the free function point_in_anchor_gap. Resync by diffing Widgets/DropDown.{cpp,hpp} against this +// file and re-porting each fix, not by re-applying the upstream commits. + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "wxExtensions.hpp" +#include "Widgets/StateHandler.hpp" +#include "Widgets/PopupWindow.hpp" +#include "Widgets/DropDown.hpp" + +constexpr int DD_ITEM_STYLE_DISABLED = 0x0002; +constexpr int DD_ITEM_STYLE_DIMMED = 0x0004; + +/** @brief Draws the grouped, two-level filament preset popup. */ +class FilamentDropDown : public PopupWindow +{ +public: + /** @brief Describes one selectable or header row in the popup. */ + struct Item + { + wxString text; + wxBitmap icon; + wxString group{}; + wxString tip{}; + int style{0}; + }; + + /** @brief Describes one row visible after grouping and filtering. */ + struct VisibleRow + { + size_t item_index{0}; + bool group_header{false}; + }; + + /** @brief Builds the visible-row mapping without creating a wx window. */ + static std::vector build_visible_rows(const std::vector &items, const wxString &group) + { + std::vector rows; + std::set groups; + rows.reserve(items.size()); + + for (size_t index = 0; index < items.size(); ++index) + { + const Item &item = items[index]; + if (!group.IsEmpty() && item.group != group) + continue; + + const bool is_group_header = group.IsEmpty() && !item.group.IsEmpty(); + if (is_group_header && !groups.insert(item.group).second) + continue; + + rows.push_back({index, is_group_header}); + } + + return rows; + } + + /** @brief Converts a visible row into an item index or a group-header sentinel. */ + static int item_index_for_visible_row(const std::vector &rows, int visible_row) + { + if (visible_row < 0 || static_cast(visible_row) >= rows.size()) + return -1; + + const VisibleRow &row = rows[static_cast(visible_row)]; + const size_t max_int = static_cast(std::numeric_limits::max()); + if (row.item_index > max_int || (row.group_header && row.item_index > max_int - 2)) + return -1; + + if (row.group_header) + return -static_cast(row.item_index) - 2; + return static_cast(row.item_index); + } + + /** @brief Finds the visible row containing an item, or -1 when it is hidden. */ + static int visible_row_for_item(const std::vector &rows, int item_index) + { + if (item_index < 0) + return -1; + + const size_t item_index_value = static_cast(item_index); + for (size_t row_index = 0; row_index < rows.size(); ++row_index) + { + if (rows[row_index].item_index != item_index_value) + continue; + if (row_index > static_cast(std::numeric_limits::max())) + return -1; + return static_cast(row_index); + } + return -1; + } + + /** @brief Maps a selected item to its visible row, including a folded group header. */ + static int selected_row_for_item(const std::vector &items, const wxString &group, int item_index) + { + if (item_index < 0 || static_cast(item_index) >= items.size()) + return -1; + + const Item &item = items[static_cast(item_index)]; + if (!group.IsEmpty() && item.group != group) + return -1; + + const std::vector rows = build_visible_rows(items, group); + const int visible_row = visible_row_for_item(rows, item_index); + if (visible_row >= 0) + return visible_row; + if (!group.IsEmpty() || item.group.IsEmpty()) + return -1; + + for (size_t row_index = 0; row_index < rows.size(); ++row_index) + { + const VisibleRow &row = rows[row_index]; + if (!row.group_header || items[row.item_index].group != item.group) + continue; + if (row_index > static_cast(std::numeric_limits::max())) + return -1; + return static_cast(row_index); + } + return -1; + } + + /** @brief Removes a redundant vendor/group prefix, matching only at a word boundary and ignoring case. */ + static wxString strip_group_prefix(const wxString &text, const wxString &group) + { + // Project/User pseudo-groups carry a trailing space and keep their text unchanged. + if (group.EndsWith(' ')) + return text; + + const wxString candidates[2] = {group, group.BeforeFirst(' ')}; + for (const wxString &prefix : candidates) + { + // Case-insensitive, like the product matching of the configured order: a casing drift between + // the vendor label and the preset name must not leave the prefix visible in the submenu. + if (prefix.IsEmpty() || text.length() < prefix.length() || text.Left(prefix.length()).CmpNoCase(prefix) != 0) + continue; + // A genuine prefix ends the text or is followed by a space; otherwise it matched inside a word + // (e.g. group "Prusa Polymers" against the text "Prusament PVB @CORE One"). + if (text.length() > prefix.length() && text[prefix.length()] != ' ') + continue; + return text.substr(prefix.size()).Trim(false); + } + return text; + } + +private: + std::vector items; + size_t count = 0; + wxString group; + bool need_sync = false; + int selection = -1; + int hover_item = -1; + + FilamentDropDown * subDropDown{nullptr}; // non-owning: child window owned by its wx parent + FilamentDropDown * mainDropDown{nullptr}; // non-owning root; null when this is the root, cleared by the root's destructor + wxWeakRef mainDropDownWeak; // liveness-checked accessor for the same root popup + wxTimer submenu_motion_timer; + + double radius = 0; + bool use_content_width = false; + bool limit_max_content_width = false; + // Pins the popup width in DIP, scaled by the parent's display, so a submenu does not resize with its + // contents; 0 keeps the content/parent-width logic in messureSize(). Written on the submenu only: the + // messureSize width override and the +6 padding branch read it as "the width is fixed". + int fixed_width_dip = 0; + bool align_icon = false; + bool text_off = false; + bool use_flat_fallback = false; + // macOS may invoke DismissAndNotify() after ProcessLeftDown(); this flag forces the + // outside-click path through the base implementation so mouse capture is released. + bool dismiss_outside = false; + int max_visible_rows = 15; + + wxSize textSize; + wxSize iconSize; + wxSize rowSize{100, 30}; + + StateHandler state_handler; + StateColor text_color; + StateColor border_color; + StateColor selector_border_color; + StateColor selector_background_color; + ScalableBitmap check_bitmap; + ScalableBitmap arrow_bitmap; + + bool pressedDown = false; + boost::posix_time::ptime dismissTime; + wxPoint offset; + wxPoint dragStart; + +public: + /** @brief Creates an unparented popup that will be initialized by Create(). */ + explicit FilamentDropDown(const std::vector &items); + + ~FilamentDropDown() override; + + /** @brief Initializes the wx popup and returns false when the parent cannot create it. */ + bool Create(wxWindow *parent, long style = 0); + +public: + /** @brief Marks cached geometry stale and optionally clears interaction state. */ + void Invalidate(bool clear = false); + + /** @brief Replaces popup rows and clears state that refers to the previous rows. */ + void SetItems(const std::vector &new_items); + + int GetSelection() const { return selection; } + + /** @brief Selects a valid row index or clears the selection for an invalid index. */ + void SetSelection(int n); + + /** @brief Chooses whether the popup follows parent width or its row content width. */ + void SetUseContentWidth(bool use, bool limit_max_content_width = false); + + /** @brief Chooses whether rows reserve a common icon column. */ + void SetAlignIcon(bool align); + +public: + /** @brief Invalidates this popup and its submenu geometry after a DPI change. */ + void Rescale(); + + /** @brief Reports whether enough time elapsed to reopen after dismissal. */ + bool HasDismissLongTime(); + + // The owner controls the grouped root/submenu lifetime. + /** @brief Opens the root popup at its parent-relative position. */ + void PopupForParent(); + /** @brief Dismisses both root and visible submenu. */ + void DismissAll(); + + /** @brief Opens the group that contains the current selection. */ + bool openSelectionGroup(); + +protected: + bool ProcessLeftDown(wxMouseEvent &event) override; + void Dismiss() override; + + void OnDismiss() override; + +private: + void paintEvent(wxPaintEvent &evt); + void paintNow(); + + /** @brief Carries the mutable drawing state for selection highlighting. */ + struct SelectionRenderContext + { + const wxSize &size; + int states; + int selected_item; + int hover_index; + wxRect & content; + }; + + void render(wxDC &dc); + void render_background(wxDC &dc, const wxSize &size, int states); + void render_selection(wxDC &dc, SelectionRenderContext &context); + void render_scroll_bar(wxDC &dc, const wxSize &size, wxRect &content); + void render_items(wxDC &dc, const wxSize &size, int states, wxRect &content); + + int hoverIndex(); + int selectedItem(); + int group_row_of(const wxString &target) const; + + /** @brief Applies the popup size; a set fixed_width_dip outranks the content and parent-width caps. */ + void messureSize(); + /** @brief Places a submenu flush against its parent without horizontal overlap. */ + void position_submenu(); + /** @brief Places the root popup at its anchor or the submenu at its parent row. */ + void autoPosition(); + void ensure_row_visible(int row); + void ensure_selection_at_top(); + bool is_pointer_over_popup_tree() const; + void setGroup(const wxString &value); + void show_submenu(); + void on_submenu_motion_timer(wxTimerEvent &event); + std::vector visible_rows() const; + /** @brief Creates the submenu before showing the root popup when grouped rows are present. */ + void prepare_submenu(); + /** @brief Permanently switches this popup instance to its selectable flat-list fallback. */ + void apply_flat_fallback(); + + // some useful events + void mouseDown(wxMouseEvent &event); + void mouseReleased(wxMouseEvent &event); + void mouseCaptureLost(wxMouseCaptureLostEvent &event); + void mouseMove(wxMouseEvent &event); + void mouseWheelMoved(wxMouseEvent &event); + + void sendDropDownEvent(); + +}; diff --git a/src/slic3r/GUI/FilamentSort.cpp b/src/slic3r/GUI/FilamentSort.cpp new file mode 100644 index 000000000000..91c7413c09b4 --- /dev/null +++ b/src/slic3r/GUI/FilamentSort.cpp @@ -0,0 +1,261 @@ +#include "FilamentSort.hpp" + +#include "nlohmann/json.hpp" + +#include +#include +#include +#include +#include + +namespace Slic3r +{ +namespace GUI +{ + +namespace +{ + +constexpr const char *g_snapmaker_vendor = "Snapmaker"; +constexpr const char *g_generic_vendor = "Generic"; +constexpr const char *g_undefined_vendor = "(Undefined)"; +constexpr int g_allowlist_schema_version = 1; + +/** @brief Removes leading and trailing whitespace. */ +std::string trimmed(std::string value) +{ + const auto is_space = [](unsigned char character) { return std::isspace(character) != 0; }; + const auto first = std::find_if_not(value.begin(), value.end(), is_space); + const auto last = std::find_if_not(value.rbegin(), value.rend(), is_space).base(); + if (first >= last) + return std::string(); + + return std::string(first, last); +} + +/** @brief Compares two strings case-insensitively over ASCII letters; every other byte must match exactly. */ +bool ascii_iequal(const std::string &left, const std::string &right) +{ + if (left.size() != right.size()) + return false; + + for (size_t index = 0; index < left.size(); ++index) + { + const unsigned char lhs = static_cast(left[index]); + const unsigned char rhs = static_cast(right[index]); + if (std::tolower(lhs) != std::tolower(rhs)) + return false; + } + return true; +} + +/** @brief Converts a vendor identifier into the label used by system sorting. */ +wxString vendor_label(const std::string &vendor) +{ + return vendor.empty() ? wxString::FromUTF8("System") : wxString::FromUTF8(vendor.c_str()); +} + +/** @brief Returns the fixed priority bucket for a system vendor. */ +int vendor_rank(const std::string &vendor) +{ + // Case-sensitive, like the vendor ordering below. Known vendors reach this point already spelled + // canonically (PlaterFilamentComboBox calls canonical_vendor() before sorting), so a profile that + // writes "snapmaker" is still ranked as Snapmaker; only unknown vendors keep their own spelling. + const wxString label = vendor_label(vendor); + if (label.Cmp(wxString::FromUTF8(g_snapmaker_vendor)) == 0) + return 0; + if (label.Cmp(wxString::FromUTF8(g_generic_vendor)) == 0) + return 1; + return 2; +} + +/** @brief Compares display names by code point and preserves the original order for ties. */ +bool default_name_less(const FilamentSortItem &left, const FilamentSortItem &right) +{ + // Case-sensitive, like the upstream Bambu collation: names that differ only in case are ordered + // by code point instead of being treated as equal. + const int name_compare = left.display_name.Cmp(right.display_name); + if (name_compare != 0) + return name_compare < 0; + return left.original_index < right.original_index; +} + +} // namespace + +FilamentOrder FilamentOrder::from_stream(std::istream &stream) +{ + nlohmann::json root = nlohmann::json::parse(stream, nullptr, false); + if (root.is_discarded() || !root.is_object()) + return FilamentOrder{}; + + const auto schema_version = root.find("schema_version"); + const auto sections_node = root.find("sections"); + if (schema_version == root.end() || !schema_version->is_number_integer() || + *schema_version != g_allowlist_schema_version || sections_node == root.end() || !sections_node->is_object()) + return FilamentOrder{}; + + const auto order_node = sections_node->find("filament_order"); + if (order_node == sections_node->end() || !order_node->is_object()) + return FilamentOrder{}; + + Orders orders; + for (const auto &vendor_order : order_node->items()) + { + if (vendor_order.key().empty() || !vendor_order.value().is_array() || vendor_order.value().empty()) + return FilamentOrder{}; + + Order values; + for (const auto &value : vendor_order.value()) + { + if (!value.is_string() || value.get_ref().empty()) + return FilamentOrder{}; + values.emplace_back(value.get_ref()); + } + orders.emplace_back(vendor_order.key(), std::move(values)); + } + + if (orders.empty()) + return FilamentOrder{}; + return FilamentOrder(std::move(orders)); +} + +FilamentOrder FilamentOrder::from_file(const std::filesystem::path &path, std::string *error) +{ + std::ifstream stream(path); + if (!stream) + { + if (error != nullptr) + *error = "cannot be opened"; + return FilamentOrder{}; + } + + const FilamentOrder order = from_stream(stream); + if (order.empty() && error != nullptr) + *error = "has an invalid or empty configuration"; + return order; +} + +FilamentOrder::FilamentOrder(Orders orders) : m_orders(std::move(orders)) +{ +} + +size_t FilamentOrder::rank(const std::string &vendor, const std::string &filament_product) const +{ + for (const auto &vendor_order : m_orders) + { + if (wxString::FromUTF8(vendor_order.first.c_str()).CmpNoCase(wxString::FromUTF8(vendor.c_str())) != 0) + continue; + + for (size_t index = 0; index < vendor_order.second.size(); ++index) + { + // Product names are authored both in the preset files and in filament_allow_list.json, so they match + // case-insensitively like the vendor key above: a casing drift must not silently drop an entry. + if (wxString::FromUTF8(vendor_order.second[index].c_str()) + .CmpNoCase(wxString::FromUTF8(filament_product.c_str())) == 0) + return index; + } + break; + } + return std::numeric_limits::max(); +} + +bool FilamentOrder::empty() const +{ + return m_orders.empty(); +} + +bool FilamentSorter::less(const FilamentSortItem &left, const FilamentSortItem &right) const +{ + return less_by_name(left, right); +} + +bool FilamentSorter::less_by_name(const FilamentSortItem &left, const FilamentSortItem &right) const +{ + return default_name_less(left, right); +} + +bool FilamentVendorSorter::less(const std::string &left, const std::string &right) const +{ + // Case-sensitive, like the upstream Bambu vendor table: the vendor axis orders by code point. + return vendor_label(left).Cmp(vendor_label(right)) < 0; +} + +bool SystemFilamentVendorSorter::less(const std::string &left, const std::string &right) const +{ + const int left_rank = vendor_rank(left); + const int right_rank = vendor_rank(right); + if (left_rank != right_rank) + return left_rank < right_rank; + return FilamentVendorSorter::less(left, right); +} + +SystemFilamentSorter::SystemFilamentSorter(FilamentOrder filament_order) + : m_filament_order(std::move(filament_order)) +{ +} + +bool SystemFilamentSorter::less(const FilamentSortItem &left, const FilamentSortItem &right) const +{ + // Narrow contract: the caller orders rows by vendor first (PlaterFilamentComboBox::sort_system_rows + // compares vendors and only falls back to this sorter when they are equivalent), so both sides + // carry the same vendor here and the Snapmaker gate is symmetric. Passing two different vendors to + // one call would mix the configured rank with the name order and stop being a strict weak ordering, + // which std::stable_sort requires. + if (is_snapmaker_vendor(left.vendor) && is_snapmaker_vendor(right.vendor)) + { + const size_t left_rank = m_filament_order.rank(left.vendor, left.filament_product); + const size_t right_rank = m_filament_order.rank(right.vendor, right.filament_product); + if (left_rank != right_rank) + return left_rank < right_rank; + } + + return less_by_name(left, right); +} + +bool is_snapmaker_vendor(const std::string &vendor) +{ + // Deliberately case-insensitive, unlike the ordering above: this gate activates the configured order, + // and a spelling drift must not silently disable it. + return wxString::FromUTF8(vendor.c_str()).CmpNoCase(wxString::FromUTF8(g_snapmaker_vendor)) == 0; +} + +std::string canonical_vendor(const std::string &vendor) +{ + const wxString label = wxString::FromUTF8(vendor.c_str()); + if (label.CmpNoCase(wxString::FromUTF8(g_snapmaker_vendor)) == 0) + return g_snapmaker_vendor; + if (label.CmpNoCase(wxString::FromUTF8(g_generic_vendor)) == 0) + return g_generic_vendor; + return vendor; +} + +std::string filament_product_key(const std::string &preset_name, const std::string &vendor) +{ + std::string product = trimmed(preset_name); + std::string owner = trimmed(vendor); + + // An unset vendor, or the schema placeholder, groups the preset by its own leading word. + if (owner.empty() || owner == g_undefined_vendor) + owner = product.substr(0, product.find_first_of(" \t")); + + if (!owner.empty() && product.size() > owner.size() && ascii_iequal(product.substr(0, owner.size()), owner) && + product[owner.size()] == ' ') + product = product.substr(owner.size() + 1); + + // Printer variants share one product name, e.g. "PLA Matte @BBL X1C" is the "PLA Matte" entry. + const size_t printer_suffix = product.find(" @"); + if (printer_suffix != std::string::npos) + product = product.substr(0, printer_suffix); + + return trimmed(product); +} + +std::filesystem::path choose_allow_list_copy(const std::filesystem::path &user_copy, + const std::filesystem::path &shipped_copy, + bool user_copy_exists) +{ + return user_copy_exists ? user_copy : shipped_copy; +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/FilamentSort.hpp b/src/slic3r/GUI/FilamentSort.hpp new file mode 100644 index 000000000000..0dcb8ec567a7 --- /dev/null +++ b/src/slic3r/GUI/FilamentSort.hpp @@ -0,0 +1,125 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace Slic3r +{ +namespace GUI +{ + +/** @brief Holds the values a row is ordered by, fixed once the row has been built. */ +struct FilamentSortItem +{ + wxString display_name; + std::string vendor; + std::string filament_product; + size_t original_index{0}; +}; + +/** @brief Stores the optional vendor-specific filament order. */ +class FilamentOrder +{ +public: + FilamentOrder() = default; + + /** @brief Parses a filament-order configuration stream and returns an empty order on invalid input. */ + static FilamentOrder from_stream(std::istream &stream); + + /** @brief Reads a configuration file and returns an empty order when it cannot be used; @p error + * receives a reason fragment, which the caller logs together with the component name. */ + static FilamentOrder from_file(const std::filesystem::path &path, std::string *error = nullptr); + + /** @brief Returns the configured rank or the maximum value when no rank exists. */ + size_t rank(const std::string &vendor, const std::string &filament_product) const; + + /** @brief Reports whether the configuration contains at least one valid vendor order. */ + bool empty() const; + +private: + using Order = std::vector; + using Orders = std::vector>; + + explicit FilamentOrder(Orders orders); + + Orders m_orders; +}; + +/** @brief Defines an overridable ordering for filament rows. */ +class FilamentSorter +{ +public: + FilamentSorter() = default; + virtual ~FilamentSorter() = default; + + FilamentSorter(const FilamentSorter &) = delete; + FilamentSorter &operator=(const FilamentSorter &) = delete; + FilamentSorter(FilamentSorter &&) = delete; + FilamentSorter &operator=(FilamentSorter &&) = delete; + + /** @brief Returns whether @p left precedes @p right. */ + virtual bool less(const FilamentSortItem &left, const FilamentSortItem &right) const; + +protected: + /** @brief Applies the default display-name ordering. */ + bool less_by_name(const FilamentSortItem &left, const FilamentSortItem &right) const; +}; + +/** @brief Defines an overridable ordering for filament vendors. */ +class FilamentVendorSorter +{ +public: + FilamentVendorSorter() = default; + virtual ~FilamentVendorSorter() = default; + + FilamentVendorSorter(const FilamentVendorSorter &) = delete; + FilamentVendorSorter &operator=(const FilamentVendorSorter &) = delete; + FilamentVendorSorter(FilamentVendorSorter &&) = delete; + FilamentVendorSorter &operator=(FilamentVendorSorter &&) = delete; + + /** @brief Returns whether vendor @p left precedes vendor @p right. */ + virtual bool less(const std::string &left, const std::string &right) const; +}; + +/** @brief Prioritizes Snapmaker and Generic vendors before lexical vendor order. */ +class SystemFilamentVendorSorter final : public FilamentVendorSorter +{ +public: + bool less(const std::string &left, const std::string &right) const override; +}; + +/** @brief Applies the configured Snapmaker order before the default name ordering. */ +class SystemFilamentSorter final : public FilamentSorter +{ +public: + /** @brief Creates a sorter using the supplied immutable filament order. */ + explicit SystemFilamentSorter(FilamentOrder filament_order); + + bool less(const FilamentSortItem &left, const FilamentSortItem &right) const override; + +private: + FilamentOrder m_filament_order; +}; + +/** @brief Reports whether a vendor is Snapmaker, ignoring case. */ +bool is_snapmaker_vendor(const std::string &vendor); + +/** @brief Normalizes the known system vendor names to their canonical spelling. */ +std::string canonical_vendor(const std::string &vendor); + +/** @brief Returns the product name a configured order matches: the preset name without vendor prefix or printer suffix. */ +std::string filament_product_key(const std::string &preset_name, const std::string &vendor); + +/** @brief Reads the deployed user copy of the allow-list when it exists, otherwise the shipped resource copy. */ +std::filesystem::path choose_allow_list_copy(const std::filesystem::path &user_copy, + const std::filesystem::path &shipped_copy, + bool user_copy_exists); + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 8f24d7e81a8b..68ea142bc5c5 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -1157,7 +1157,7 @@ void GUI_App::post_init() bool sys_preset = app_config->get("sync_system_preset") == "true"; this->preset_updater->sync(http_url, language, network_ver, sys_preset ? preset_bundle : nullptr); this->preset_updater->sync_web_async(true); - this->check_new_version_sf(false, false); + this->request_version_from_config(false, false); }); } @@ -5419,6 +5419,223 @@ void GUI_App::check_new_version_sf(bool show_tips, bool by_user) }) .perform(); } + +void GUI_App::request_version_from_config(bool show_tips, bool by_user) +{ + std::string url = app_config->get_config_api_url(); + + json req; + // appName encodes the platform (server contract): the config backend hosts two apps, + // snapmaker-orca-win / snapmaker-orca-mac, each with its own default + gray configs. +#if defined(_WIN32) + req["appName"] = "snapmaker-orca-win"; + const std::string client_platform_type = "win"; +#elif defined(__APPLE__) + req["appName"] = "snapmaker-orca-mac"; + const std::string client_platform_type = "mac"; +#else + req["appName"] = "snapmaker-orca"; + const std::string client_platform_type = ""; +#endif + // Three numeric segments (e.g. "2.4.0"): the server compares version levels numerically + // (versionInRange), so the zero-padded four-segment form must not be sent here. + req["version"] = std::string(Snapmaker_VERSION); + // Same source as the global X-BBL-Device-ID header (slicer_uuid), the gray bucketing key. + // The body carries no userId by contract: the gateway derives it from the Authorization + // token below and injects it into rule evaluation server-side. + req["deviceId"] = app_config->get("slicer_uuid"); + std::string req_body = req.dump(); + + // Type-guarded readers: unlike value(), a wrong-typed field is treated as missing + // instead of raising type_error.302 (e.g. a string "200" where a number is expected). + auto str_field = [](const json& j, const char* key) -> std::string { + auto it = j.find(key); + return it != j.end() && it->is_string() ? it->get() : std::string(); + }; + auto flag_field = [](const json& j, const char* key) -> bool { + auto it = j.find(key); + return it != j.end() && it->is_boolean() ? it->get() : false; + }; + auto obj_field = [](const json& j, const char* key) -> json { + auto it = j.find(key); + return it != j.end() && it->is_object() ? *it : json::object(); + }; + + auto http = Http::post(url); + http.header("Content-Type", "application/json"); + // Gateway auth (snapmaker-config): the SM account JWT goes in Authorization as a raw + // token, no "Bearer " prefix — same convention as the SM login requests. The gateway + // resolves the gray-rule variable userId from it; anonymous requests stay valid + // (update check must work without login) and rules evaluate with userId = nil. + // The account token must never travel over plaintext http — the orca_config_api_url + // override can point at any URL. Internal testing builds are the only exception, + // because the dev gateway has no TLS. + bool allow_http_auth = false; +#if BBL_INTERNAL_TESTING + allow_http_auth = true; +#endif + bool with_auth = false; + if (sm_get_userinfo()->is_user_login()) { + std::string auth_token = sm_get_userinfo()->get_user_token(); + if (!auth_token.empty()) { + if (url.rfind("https://", 0) == 0 || allow_http_auth) { + http.header("Authorization", auth_token); + with_auth = true; + } else { + BOOST_LOG_TRIVIAL(warning) << "config/get: refusing to send Authorization over non-https URL"; + } + } + } + // Normal-path diagnostics stay at info so the release warning stream is reserved + // for anomalies; failure paths log at warning. + BOOST_LOG_TRIVIAL(info) << format("config/get: posting to `%1%` %2%, deviceId `%3%`", url, with_auth ? "with Authorization" : "anonymously", req["deviceId"].get()); + http.set_post_body(req_body) + .timeout_connect(TIMEOUT_CONNECT) + // Total timeout: a stalled transfer after a successful connect must still + // trigger the static fallback (CURLOPT_TIMEOUT defaults to unlimited). + .timeout_max(30) + .on_error([this, show_tips, by_user](std::string body, std::string error, unsigned http_status) { + (void)body; + BOOST_LOG_TRIVIAL(warning) << format("Error posting: `%1%`: HTTP %2%, %3%, fallback to static version.json", "config/get", http_status, error); + check_new_version_sf(show_tips, by_user); + }) + .on_complete([this, show_tips, by_user, str_field, flag_field, obj_field, client_platform_type](std::string body, unsigned http_status) { + if (http_status != 200) { + BOOST_LOG_TRIVIAL(warning) << format("status not 200 with: `%1%`: HTTP %2%, fallback to static version.json", "config/get", http_status); + check_new_version_sf(show_tips, by_user); + return; + } + // allow_exceptions = false: a malformed or non-UTF-8 body yields a discarded value + // (never an exception) and degrades to the static check. A document that parses + // successfully is valid UTF-8 by construction, so every string below is FromUTF8-safe. + json jsonObj = json::parse(body, nullptr, false); + if (jsonObj.is_discarded() || !jsonObj.is_object()) { + BOOST_LOG_TRIVIAL(warning) << "config/get body is not valid JSON/UTF-8, fallback to static version.json"; + check_new_version_sf(show_tips, by_user); + return; + } + + // Server contract: 40001 = no default config, 604001 = bad params, 50001 = internal error + int errCode = 0; + auto code_it = jsonObj.find("code"); + if (code_it != jsonObj.end() && code_it->is_number_integer()) + errCode = code_it->get(); + if (errCode != 200 || !jsonObj.contains("data") || !jsonObj["data"].is_object()) { + BOOST_LOG_TRIVIAL(warning) << format("config/get rejected: code %1%, msg %2%, fallback to static version.json", errCode, str_field(jsonObj, "msg")); + check_new_version_sf(show_tips, by_user); + return; + } + + // The payload mirrors the data object of the static version.json. + // A malformed payload (missing/invalid required fields) must degrade to the + // static channel instead of silently suppressing the update check: a + // misconfigured gray release may never be worse than static-only behavior. + auto reject_payload = [this, show_tips, by_user](const char* reason) { + BOOST_LOG_TRIVIAL(warning) << format("config/get payload rejected: %1%, fallback to static version.json", reason); + check_new_version_sf(show_tips, by_user); + }; + + const json dataObj = jsonObj["data"]; + + std::string releaseType = str_field(dataObj, "release_type"); + if (releaseType.empty()) + return reject_payload("release_type missing"); + + bool isForceUpgrade = flag_field(dataObj, "is_force_upgrade"); + version_info.force_upgrade = isForceUpgrade; + version_info.version_str = str_field(dataObj, "version"); + + // An explicitly non-stable release is a server-side decision, not a malformed + // payload: ignore it exactly like the static check ignores non-stable channels. + if (releaseType != RELEASE_TYPE_STABLE) + { + if (by_user) + this->no_new_version(); + return; + } + + std::regex matcher("[0-9]+\\.[0-9]+(\\.[0-9]+)*(-[A-Za-z0-9]+)?(\\+[A-Za-z0-9]+)?"); + Semver current_version = get_version(Snapmaker_VERSION, matcher); + Semver server_version = get_version(version_info.version_str, matcher); + if (!server_version.valid()) + return reject_payload("version missing or unparsable"); + + std::string platformType = str_field(dataObj, "platform_type"); + // The payload must target the platform this build was compiled for (same + // mapping as appName): the server routes by appName, so a mismatched + // platform_type would offer the user another platform's installer. + if (platformType != client_platform_type) + return reject_payload("platform_type mismatch for this build"); + + // win x86_x64, mac arm/x86_64 universal + json fullObj = obj_field(dataObj, "full"); + json defaultObj = obj_field(fullObj, "default"); + json armObj = obj_field(fullObj, "arm"); + json intelObj = obj_field(fullObj, "intel"); + version_info.description = str_field(fullObj, "file_describe"); + + if (platformType == "win") { + version_info.url = str_field(defaultObj, "file_url"); + } + else if (platformType == "mac") + { + bool isArm64 = false; +#if defined(__aarch64__) || defined(__arm64__) || defined(_M_ARM64) + isArm64 = true; +#else + isArm64 = false; +#endif + json platformObj = defaultObj; + if (isArm64) { + if (!armObj.empty()) { + platformObj = armObj; + } + } + else + { + if (!intelObj.empty()) { + platformObj = intelObj; + } + } + + version_info.url = str_field(platformObj, "file_url"); + } + else + { + return reject_payload("unsupported platform_type"); + } + + // A payload without file_url must not open the update dialog: + // clicking download would launch the browser with an empty address. + if (version_info.url.empty()) { + return reject_payload("file_url missing"); + } + + if (current_version >= server_version) { + if(by_user) + this->no_new_version(); + return; + } + + if (isForceUpgrade) + { + wxGetApp().app_config->set_bool("force_upgrade", version_info.force_upgrade); + wxGetApp().app_config->set("upgrade", "force_upgrade", true); + wxGetApp().app_config->set("upgrade", "description", version_info.description); + wxGetApp().app_config->set("upgrade", "version", version_info.version_str); + wxGetApp().app_config->set("upgrade", "url", version_info.url); + GUI::wxGetApp().enter_force_upgrade(); + return; + } + + wxCommandEvent* evt = new wxCommandEvent(EVT_SLIC3R_VERSION_ONLINE); + evt->SetString(version_info.url); + if (by_user) + evt->SetInt(UPDATE_BY_USER); + GUI::wxGetApp().QueueEvent(evt); + }) + .perform(); +} void GUI_App::process_network_msg(std::string dev_id, std::string msg) { if (dev_id.empty()) { diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 417e26f40a21..5fc5267ff113 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -631,6 +631,8 @@ class GUI_App : public wxApp void check_web_version(); void check_preset_version(); void check_new_version_sf(bool show_tips = false, bool by_user = false); + // Gray release: POST /config/get (snapmaker-config) first, falls back to check_new_version_sf on failure + void request_version_from_config(bool show_tips = false, bool by_user = false); void process_network_msg(std::string dev_id, std::string msg); void enter_force_upgrade(); void set_skip_version(bool skip = true); diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index 93268019a0f1..a5e4f5a14ef3 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -968,7 +968,6 @@ void ObjectList::update_filament_values_for_items_when_delete_filament(const siz } } - void ObjectList::update_objects_list_filament_column_when_delete_filament(size_t filament_id, size_t filaments_count, int replace_filament_id) @@ -990,6 +989,30 @@ void ObjectList::update_objects_list_filament_column_when_delete_filament(size_t m_prevent_update_filament_in_config = false; } +void ObjectList::refresh_layer_range_filament_items() +{ + if (m_objects == nullptr || m_objects_model == nullptr) + return; + + for (size_t obj_idx = 0; obj_idx < m_objects->size(); ++obj_idx) { + const ModelObject* object = (*m_objects)[obj_idx]; + for (const auto& range : object->layer_config_ranges) { + const ModelConfig& config = range.second; + if (!config.has("extruder")) + continue; + + wxDataViewItem layer_item = + m_objects_model->GetItemByLayerRange(int(obj_idx), range.first); + if (!layer_item) + continue; + + m_objects_model->SetExtruder( + std::to_string(config.extruder()), + layer_item); + } + } +} + void ObjectList::update_objects_list_filament_column(size_t filaments_count) { assert(filaments_count >= 1); diff --git a/src/slic3r/GUI/GUI_ObjectList.hpp b/src/slic3r/GUI/GUI_ObjectList.hpp index b204439be2a6..59f1f6ff8400 100644 --- a/src/slic3r/GUI/GUI_ObjectList.hpp +++ b/src/slic3r/GUI/GUI_ObjectList.hpp @@ -221,6 +221,7 @@ class ObjectList : public wxDataViewCtrl void create_objects_ctrl(); // BBS void update_objects_list_filament_column(size_t filaments_count); + void refresh_layer_range_filament_items(); void update_objects_list_filament_column_when_delete_filament(size_t filament_id, size_t filaments_count, int replace_filament_id = -1); void update_filament_values_for_items_when_delete_filament(const size_t filament_id, const int replace_id = -1); void update_filament_colors(); diff --git a/src/slic3r/GUI/HttpServer.cpp b/src/slic3r/GUI/HttpServer.cpp index be9ade85124b..5c35b0528ca9 100644 --- a/src/slic3r/GUI/HttpServer.cpp +++ b/src/slic3r/GUI/HttpServer.cpp @@ -489,6 +489,19 @@ boost::asio::ip::port_type HttpServer::find_available_port(boost::asio::ip::port } void HttpServer::start() +{ + { + std::lock_guard lock(m_server_mtx); + start_locked(); + } + + // Started with m_server_mtx released: start_health_check() may join a + // retired health-check thread that is blocked in is_healthy() waiting for + // that lock — joining it under the lock would deadlock. + start_health_check(); +} + +void HttpServer::start_locked() { BOOST_LOG_TRIVIAL(info) << "start_http_service..."; @@ -500,14 +513,22 @@ void HttpServer::start() port = new_port; } - start_http_server = true; - m_http_server_thread = create_thread([this] { + start_http_server = true; + // Create the IOServer on the CALLING thread so the io thread never + // writes server_ (that write raced the locked readers in + // is_healthy()/stop()/restart()). Thread creation provides the + // happens-before, and stop()/restart() join the io thread before + // destroying the server, so the raw pointer below stays valid. If + // listen() throws, the next start() overwrites server_ (unique_ptr + // assignment destroys the stale server) — no cleanup needed here. + server_ = std::make_unique(*this); + server_->acceptor.listen(); + server_->do_accept(); + IOServer* srv = server_.get(); + m_http_server_thread = create_thread([this, srv] { try { set_current_thread_name("http_server"); - server_ = std::make_unique(*this); - server_->acceptor.listen(); - server_->do_accept(); - server_->io_service.run(); + srv->io_service.run(); } catch (const std::exception& e) { BOOST_LOG_TRIVIAL(error) << "HTTP server error: " << e.what(); Slic3r::sentryReportLog(Slic3r::SENTRY_LOG_FATAL,std::string("bury_point_HttpServer::start ") + e.what(), BP_LOCAL_SERVER); @@ -524,13 +545,10 @@ void HttpServer::start() } BOOST_LOG_TRIVIAL(info) << "HTTP server started successfully on port " << port; - - // 启动健康检查 - BOOST_LOG_TRIVIAL(debug) << "Starting health check for HTTP server..."; - start_health_check(); - - // 重启检查已集成到健康检查中,无需单独线程 - + + // The health check is started by start()/restart() AFTER m_server_mtx + // is released (see start_health_check's join path). + } catch (const std::exception& e) { BOOST_LOG_TRIVIAL(error) << "Failed to start HTTP server: " << e.what(); std::string error_msg = "bury_point_Failed to start HTTP server on port " + std::to_string(port) + ": " + e.what(); @@ -549,44 +567,67 @@ void HttpServer::stop() // 重启检查已集成到健康检查中,无需单独停止 + // the io thread was still dispatching handlers, and handlers themselves + // call IOServer::stop(session) — mutating the same sessions set from two + // threads at once, which crashed with EXC_BAD_ACCESS / heap corruption. + std::lock_guard lock(m_server_mtx); if (server_) { - server_->acceptor.close(); - server_->stop_all(); + boost::system::error_code ignored_ec; + server_->acceptor.close(ignored_ec); server_->io_service.stop(); } if (m_http_server_thread.joinable()) m_http_server_thread.join(); + if (server_) + server_->stop_all(); server_.reset(); } void HttpServer::restart() { BOOST_LOG_TRIVIAL(info) << "Restarting HTTP server on port " << port << "..."; - + BOOST_LOG_TRIVIAL(debug) << "Stopping current HTTP server..."; // 只停止HTTP服务器,不停止健康检查和重启检查线程 start_http_server = false; - - if (server_) { - server_->acceptor.close(); - server_->stop_all(); - server_->io_service.stop(); + + { + // Hold the lock across teardown AND start_locked(): if is_healthy() + // ran in the gap between the two it would see server_ == nullptr and + // trigger another restart on top of this one. + std::lock_guard lock(m_server_mtx); + if (server_) { + boost::system::error_code ignored_ec; + server_->acceptor.close(ignored_ec); + server_->io_service.stop(); + } + if (m_http_server_thread.joinable()) + m_http_server_thread.join(); + if (server_) + server_->stop_all(); + server_.reset(); + + BOOST_LOG_TRIVIAL(debug) << "Waiting for resources to be released..."; + std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 等待资源释放 + + BOOST_LOG_TRIVIAL(debug) << "Starting new HTTP server..."; + start_locked(); } - if (m_http_server_thread.joinable()) - m_http_server_thread.join(); - server_.reset(); - - BOOST_LOG_TRIVIAL(debug) << "Waiting for resources to be released..."; - std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 等待资源释放 - - BOOST_LOG_TRIVIAL(debug) << "Starting new HTTP server..."; - start(); - + + // Must run with m_server_mtx released: a retired health-check thread can + // still be blocked in is_healthy() waiting for this lock, and + // start_health_check() joins it — joining under the lock deadlocks + // (T1 holds m_server_mtx waiting on join, T2 waits on m_server_mtx to + // finish is_healthy()). + start_health_check(); + BOOST_LOG_TRIVIAL(info) << "HTTP server restart completed"; } bool HttpServer::is_healthy() { + // May run on the health-check thread concurrently with stop()/restart(). + std::lock_guard lock(m_server_mtx); if (!start_http_server || !server_) { BOOST_LOG_TRIVIAL(fatal) << "Health check failed: server not started or server object is null"; return false; @@ -629,13 +670,41 @@ bool HttpServer::is_healthy() void HttpServer::start_health_check() { + boost::thread retired; + { + std::lock_guard lock(m_health_check_mutex); + + if (m_health_check_enabled) { + BOOST_LOG_TRIVIAL(info) << "Health check is already running"; + return; // 已经在运行 + } + + if (m_health_check_thread.joinable()) { + if (m_health_check_thread.get_id() == boost::this_thread::get_id()) { + // stop() disabled the check while THIS (health-check) thread + // was inside restart() -> start(). Assigning to our own + // joinable boost::thread handle calls std::terminate(); + // detach the handle instead and leave the check disabled — + // our own loop exits on its next iteration. + m_health_check_thread.detach(); + BOOST_LOG_TRIVIAL(warning) << "Health check was disabled during restart; leaving it stopped"; + return; + } + retired = std::move(m_health_check_thread); // joined below, outside the lock + } + } + + // Join outside m_health_check_mutex: to exit its loop the retired thread + // must take the same mutex. + if (retired.joinable()) + retired.join(); + std::lock_guard lock(m_health_check_mutex); - if (m_health_check_enabled) { - BOOST_LOG_TRIVIAL(info) << "Health check is already running"; - return; // 已经在运行 + // Another thread started the check while we were joining. + return; } - + BOOST_LOG_TRIVIAL(info) << "Starting HTTP server health check with interval: " << m_health_check_interval << "ms"; m_health_check_enabled = true; m_health_check_thread = create_thread([this] { diff --git a/src/slic3r/GUI/HttpServer.hpp b/src/slic3r/GUI/HttpServer.hpp index 09e19426df11..b54a9e2dbb19 100644 --- a/src/slic3r/GUI/HttpServer.hpp +++ b/src/slic3r/GUI/HttpServer.hpp @@ -1,6 +1,7 @@ #ifndef slic3r_Http_App_hpp_ #define slic3r_Http_App_hpp_ +#include #include #include #include @@ -142,8 +143,11 @@ class HttpServer HttpServer(boost::asio::ip::port_type port = LOCALHOST_PORT); ~HttpServer(); // 添加析构函数 - boost::thread m_http_server_thread; - bool start_http_server = false; + boost::thread m_http_server_thread; + // Written by the io thread's exception handler without holding m_server_mtx + // and read unlocked by is_started()/setPort() and the health-check loop, + // so it must be atomic. + std::atomic start_http_server = false; // 添加自动健康检查相关成员 boost::thread m_health_check_thread; @@ -205,6 +209,18 @@ class HttpServer }; friend class session; + // Serializes server_ / m_http_server_thread lifecycle between stop(), + // restart() and is_healthy() (the latter runs on the health-check thread). + // The io thread never takes this lock; it only touches the IOServer, whose + // sessions set is joined before teardown (see HttpServer::stop). + std::mutex m_server_mtx; + + // Body of start() that runs under m_server_mtx. Deliberately does NOT + // start the health check: start_health_check() may join a retired + // health-check thread that is itself blocked in is_healthy() waiting for + // m_server_mtx, so it must only be called after the lock is released. + void start_locked(); + std::unique_ptr server_{nullptr}; std::function(const std::string&)> m_request_handler{&HttpServer::bbl_auth_handle_request}; diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index d5b5493918dd..43cc6483edbd 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -314,7 +314,13 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_ default: case GUI_App::EAppMode::Editor: m_taskbar_icon = std::make_unique(wxTBI_DOCK); - m_taskbar_icon->SetIcon(wxIcon(Slic3r::var("Snapmaker_Orca-mac_256px.ico"), wxBITMAP_TYPE_ICO), "Snapmaker Orca"); + // Do not set a custom Dock icon: NSApp.applicationIconImage renders the + // raw image in the Dock tile, bypassing the macOS 26 native icon + // pipeline, so the legacy ico (with baked-in margins) shows a size + // smaller than neighboring tiles. Let the Dock use the bundle icon + // (layered .icon with icns fallback) instead. The right-click "New + // Window" menu is unaffected: it is attached by the wxTaskBarIcon + // constructor, not by SetIcon. break; case GUI_App::EAppMode::GCodeViewer: break; @@ -2381,7 +2387,7 @@ static wxMenu* generate_help_menu() append_menu_item( helpMenu, wxID_ANY, _L("Check for Update"), _L("Check for Update"), [](wxCommandEvent&) { - wxGetApp().check_new_version_sf(true, UPDATE_BUSER); + wxGetApp().request_version_from_config(true, UPDATE_BUSER); }, "", nullptr, []() { return true; }); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 32f770e7b45b..88ee7771f80b 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -9,6 +9,7 @@ #include "libslic3r/FilamentColorLibrary.hpp" // kFullSpectrumSlotCount (recommended slot write-back) #include "libslic3r/Config.hpp" #include "libslic3r/MixedFilament.hpp" +#include "libslic3r/MixedFilamentConfigRemap.hpp" #include "libslic3r/filament_mixer.h" #include "common_func/common_func.hpp" #include "slic3r/Utils/SnapLogClient.hpp" @@ -154,6 +155,7 @@ #include "InstanceCheck.hpp" #include "NotificationManager.hpp" #include "PresetComboBoxes.hpp" +#include "PlaterFilamentComboBox.hpp" #include "MsgDialog.hpp" #include "ProjectDirtyStateManager.hpp" #include "Gizmos/GLGizmoSimplify.hpp" // create suggestion notification @@ -953,6 +955,7 @@ class CustomNotebook : public wxControl } UpdateLayout(); + InvalidateBestSize(); Refresh(); } @@ -966,6 +969,7 @@ class CustomNotebook : public wxControl m_tabs.clear(); m_selectedIndex = -1; UpdateLayout(); + InvalidateBestSize(); Refresh(); } @@ -1182,6 +1186,24 @@ class CustomNotebook : public wxControl } } + // Include page content so the parent sizer reserves the notebook height instead + // of allowing following sidebar panels to overlap it. + wxSize DoGetBestSize() const override + { + int best_width = m_tabWidth; + int best_height = m_tabHeight; + for (const auto &tab : m_tabs) + { + if (tab.page == nullptr) + continue; + + const wxSize page_size = tab.page->GetBestSize(); + best_width = std::max(best_width, page_size.x); + best_height = std::max(best_height, m_tabHeight + page_size.y + 4); + } + return wxSize(best_width, best_height); + } + private: std::vector m_tabs; int m_selectedIndex; @@ -2330,11 +2352,11 @@ Sidebar::Sidebar(Plater *parent) // add printer title scrolled_sizer->Add(p->m_panel_printer_title, 0, wxEXPAND | wxALL, 0); - p->m_panel_printer_title->Bind(wxEVT_LEFT_UP, [this] (auto & e) { - if (p->m_panel_printer_content->GetMaxHeight() == 0) - p->m_panel_printer_content->SetMaxSize({-1, -1}); - else - p->m_panel_printer_content->SetMaxSize({-1, 0}); + p->m_panel_printer_title->Bind(wxEVT_LEFT_UP, [this] (auto & e) + { + const bool expanded = p->m_panel_printer_content->IsShown(); + p->m_panel_printer_content->Show(!expanded); + p->m_panel_printer_content->SetMaxSize(expanded ? wxSize(-1, 0) : wxSize(-1, -1)); m_scrolled_sizer->Layout(); }); @@ -2528,14 +2550,14 @@ Sidebar::Sidebar(Plater *parent) p->m_panel_filament_title = new StaticBox(p->scrolled, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL | wxBORDER_NONE); p->m_panel_filament_title->SetBackgroundColor(title_bg); p->m_panel_filament_title->SetBackgroundColor2(0xF1F1F1); - p->m_panel_filament_title->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent &e) { + p->m_panel_filament_title->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent &e) + { if (e.GetPosition().x > (p->m_flushing_volume_btn->IsShown() ? p->m_flushing_volume_btn->GetPosition().x : (p->m_bpButton_ams_filament->GetPosition().x - FromDIP(30)))) return; - if (p->m_panel_filament_content->GetMaxHeight() == 0) - p->m_panel_filament_content->SetMaxSize({-1, -1}); - else - p->m_panel_filament_content->SetMaxSize({-1, 0}); + const bool expanded = p->m_panel_filament_content->IsShown(); + p->m_panel_filament_content->Show(!expanded); + p->m_panel_filament_content->SetMaxSize(expanded ? wxSize(-1, 0) : wxSize(-1, -1)); m_scrolled_sizer->Layout(); }); @@ -3089,7 +3111,7 @@ Sidebar::Sidebar(Plater *parent) /* first filament item */ // init_filament_combo(&p->combos_filament[0], 0); - p->combos_filament[0] = new PlaterPresetComboBox(p->m_panel_scrolled_filament_content, Preset::TYPE_FILAMENT); + p->combos_filament[0] = new PlaterFilamentComboBox(p->m_panel_scrolled_filament_content, Preset::TYPE_FILAMENT); auto combo_and_btn_sizer = new wxBoxSizer(wxHORIZONTAL); // BBS: filament double columns combo_and_btn_sizer->AddSpacer(FromDIP(SidebarProps::ContentMargin())); @@ -3353,7 +3375,8 @@ Sidebar::Sidebar(Plater *parent) scrolled_sizer->Add(p->m_panel_mixed_filaments_content, 0, wxEXPAND, 0); // Bind collapse/expand event to title bar - p->m_panel_mixed_filaments_title->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent& e) { + p->m_panel_mixed_filaments_title->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent& e) + { // Exclude button areas from collapse/expand int button_left = p->m_panel_mixed_filaments_title->GetClientSize().x; auto consider_button = [&button_left](wxWindow *button) { @@ -3366,10 +3389,9 @@ Sidebar::Sidebar(Plater *parent) if (e.GetPosition().x > button_left - FromDIP(12)) return; - if (p->m_panel_mixed_filaments_content->GetMaxHeight() == 0) - p->m_panel_mixed_filaments_content->SetMaxSize({-1, -1}); - else - p->m_panel_mixed_filaments_content->SetMaxSize({-1, 0}); + const bool expanded = p->m_panel_mixed_filaments_content->IsShown(); + p->m_panel_mixed_filaments_content->Show(!expanded); + p->m_panel_mixed_filaments_content->SetMaxSize(expanded ? wxSize(-1, 0) : wxSize(-1, -1)); m_scrolled_sizer->Layout(); }); @@ -3493,7 +3515,7 @@ void Sidebar::create_printer_preset() void Sidebar::init_filament_combo(PlaterPresetComboBox **combo, const int filament_idx) { - *combo = new PlaterPresetComboBox(p->m_panel_scrolled_filament_content, Slic3r::Preset::TYPE_FILAMENT); + *combo = new PlaterFilamentComboBox(p->m_panel_scrolled_filament_content, Slic3r::Preset::TYPE_FILAMENT); (*combo)->set_filament_idx(filament_idx); auto combo_and_btn_sizer = new wxBoxSizer(wxHORIZONTAL); @@ -8456,6 +8478,13 @@ void Sidebar::merge_mixed_filament(size_t from_id, size_t to_id, // Build remap table using PresetBundle method pb.build_merge_filament_remap(from_id, to_id, total_filaments); + // The remap is 1-based and also accounts for a mixed target shifting down + // when it follows the deleted source. + const std::vector merge_remap = pb.last_filament_id_remap(); + const int merged_target_id = + merge_remap.size() > from_id + 1 && merge_remap[from_id + 1] > 0 + ? int(merge_remap[from_id + 1] - 1) + : -1; // Mark source mixed filament as deleted mfs[source_mixed_idx].deleted = true; @@ -8472,9 +8501,11 @@ void Sidebar::merge_mixed_filament(size_t from_id, size_t to_id, if (auto* opt = pb.project_config.option("filament_is_mixed")) is_mixed_snapshot = opt->values; - // Update objects to use new filament IDs + // Pass the remapped target instead of -1 so ObjectList writes the selected + // physical/mixed target to config-level extruder assignments rather than + // falling back to filament 1. size_t total_after = pb.mixed_filaments.total_filaments(num_physical); - wxGetApp().plater()->on_filaments_delete(total_after, from_id, -1, is_mixed_snapshot); + wxGetApp().plater()->on_filaments_delete(total_after, from_id, merged_target_id, is_mixed_snapshot); BOOST_LOG_TRIVIAL(info) << "Mixed filament merge completed. Total filaments after: " << total_after; @@ -8595,16 +8626,51 @@ void Sidebar::delete_filament(size_t filament_id, int replace_filament_id, pb.build_merge_filament_remap(filament_id, replace_filament_id, old_total_filaments, old_num_physical); BOOST_LOG_TRIVIAL(info) << "Built custom remap for physical to mixed merge (accounts for virtual ID changes)"; - - // Call on_filaments_delete with -1 to trigger remap usage - // This updates object colors using the remap table - wxGetApp().plater()->on_filaments_delete(old_total_filaments, filament_id, -1, is_mixed_snapshot); - - // Now delete the physical filament + + // Preserve the custom merge target for config-level object/volume extruder + // assignments. The count update below replaces PresetBundle's transient + // remap with its generic deletion remap, so restore this merge-specific + // table before Plater::on_filaments_delete() consumes it. + const std::vector physical_to_mixed_remap = pb.last_filament_id_remap(); + const int merged_target_id = + physical_to_mixed_remap.size() > filament_id + 1 && + physical_to_mixed_remap[filament_id + 1] > 0 + ? int(physical_to_mixed_remap[filament_id + 1] - 1) + : -1; + + // Update PresetBundle before refreshing the sidebar. Sidebar::on_filaments_delete() + // first reduces the UI physical count and then reloads custom mixed definitions. + // If it runs while the bundle still contains old physical IDs, a row such as + // old (3, 5) is temporarily invalid against four UI slots and is discarded. + // Updating first renumbers that row to (3, 4) and keeps the merge target alive. pb.update_num_filaments(filament_id); - pb.consume_last_filament_id_remap(); // discard the remap built by update_num_filaments + (void)pb.consume_last_filament_id_remap(); + pb.set_filament_id_remap(physical_to_mixed_remap); + + const size_t total_after_delete = + pb.mixed_filaments.total_filaments(pb.filament_presets.size()); wxGetApp().plater()->get_partplate_list().on_filament_deleted( - pb.filament_presets.size(), filament_id); + total_after_delete, filament_id); + + // Pass the post-deletion mixed target so painted states and config-level + // object/volume extruder assignments follow the same remap. + wxGetApp().plater()->on_filaments_delete( + total_after_delete, filament_id, merged_target_id, is_mixed_snapshot); + + // Resynchronize filament_colour from the post-deletion project config; + // GLCanvas3D reads this config when updating GLVolume colors. + wxGetApp().plater()->update_filament_colors_in_full_config(); + + // Refresh controls that may still hold the pre-deletion filament list. + for (size_t idx = filament_id; idx < p->combos_filament.size(); ++idx) { + if (p->combos_filament[idx]) + p->combos_filament[idx]->update(); + } + obj_list()->update_objects_list_filament_column(pb.filament_presets.size()); + update_dynamic_filament_list(); + update_mixed_filament_panel(false); + update_color_mix_panel(); + Layout(); BOOST_LOG_TRIVIAL(info) << "Physical to mixed merge completed using custom remap mechanism"; @@ -9007,10 +9073,12 @@ void Sidebar::cleanup_unused_filaments_after_batch_match(const BatchMatchResult if (auto *opt = pb->project_config.option("mixed_filament_definitions")) opt->value = pb->mixed_filaments.serialize_custom_entries(); - // Rebuild panels once (skipped per-deletion in the loop above). + // Rebuild panels once (skipped per-deletion in the loop above). The object-list + // refresh performs the single final Plater update through its model sync path. update_mixed_filament_panel(); update_color_mix_panel(); - wxGetApp().plater()->update(); + obj_list()->update_objects_list_filament_column(pb->filament_presets.size()); + obj_list()->refresh_layer_range_filament_items(); } void Sidebar::add_custom_filament(wxColour new_col) { @@ -9177,6 +9245,7 @@ void Sidebar::sync_ams_list() wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config); update_dynamic_filament_list(); // Expand filament list + p->m_panel_filament_content->Show(); p->m_panel_filament_content->SetMaxSize({-1, -1}); // BBS:Synchronized consumables information // auto calculation of flushing volumes @@ -9622,6 +9691,15 @@ void Sidebar::update_nozzle_settings(bool switch_machine) p->m_nozzle_notebook->Layout(); p->m_nozzle_notebook->Thaw(); + p->m_nozzle_notebook->InvalidateBestSize(); + if (p->m_nozzle_notebook->GetParent() != nullptr) + { + p->m_nozzle_notebook->GetParent()->InvalidateBestSize(); + } + p->m_panel_printer_content->InvalidateBestSize(); + p->scrolled->InvalidateBestSize(); + p->m_panel_printer_content->Layout(); + m_scrolled_sizer->Layout(); if (switch_machine) { p->combo_printer->SetFocus(); @@ -15440,6 +15518,7 @@ void Plater::priv::on_process_completed(SlicingProcessCompletedEvent &evt) if (!has_error && !evt.cancelled() && evt.success()) { SNAP_LOG_BATCH(Info, "slice completed", {"eventName","slice_completed"}); } + m_slice_all = false; m_is_slicing = false; this->preview->reload_print(false); /* BBS if in publishing progress */ @@ -15966,6 +16045,9 @@ void Plater::priv::on_filament_color_changed(wxCommandEvent &event) wxGetApp().preset_bundle->update_multi_material_filament_presets(); sidebar->update_mixed_filament_panel(); sidebar->update_color_mix_panel(); + + if (GLCanvas3D* canvas = q->get_view3D_canvas3D()) + canvas->get_gizmos_manager().update_data(); } void Plater::priv::install_network_plugin(wxCommandEvent &event) @@ -22032,21 +22114,44 @@ void Plater::on_filaments_delete(size_t num_filaments, size_t filament_id, int r // update UI sidebar().on_filaments_delete(filament_id); - // update global feature filament selections - static const char* keys[] = {"wall_filament", "sparse_infill_filament", "solid_infill_filament", - "support_filament", "support_interface_filament"}; - for (auto key : keys) - if (p->config->has(key)) { - if (p->config->opt_int(key) == filament_id + 1) - (*(p->config)).erase(key); - else { - int new_value = p->config->opt_int(key) > filament_id ? p->config->opt_int(key) - 1 : p->config->opt_int(key); - (*(p->config)).set_key_value(key, new ConfigOptionInt(new_value)); + // An explicit remap also covers mixed-row deletion/cascade cases that cannot + // be expressed by the naive decrement path below. + if (should_remap_states) { + remap_dynamic_config_feature_filament_ids(*p->config, id_remap, num_filaments); + } else { + for (const std::string &key : mixed_filament_feature_keys()) { + if (!p->config->has(key)) + continue; + + if (p->config->opt_int(key) == static_cast(filament_id + 1)) { + p->config->erase(key); + } else { + const int old_id = p->config->opt_int(key); + const int new_id = old_id > static_cast(filament_id) ? old_id - 1 : old_id; + p->config->set(key, new_id); } } + } // update object/volume/support(object and volume) filament id - sidebar().obj_list()->update_objects_list_filament_column_when_delete_filament(filament_id, num_filaments, replace_filament_id); + if (should_remap_states) { + for (ModelObject* mo : wxGetApp().model().objects) { + remap_model_config_filament_ids(mo->config, id_remap, num_filaments); + for (ModelVolume* mv : mo->volumes) + remap_model_config_filament_ids(mv->config, id_remap, num_filaments); + for (auto &layer_range : mo->layer_config_ranges) + remap_model_config_filament_ids(layer_range.second, id_remap, num_filaments); + } + // Batch physical deletion defers list and scene refresh until its final + // composite rebuild; other deletion paths refresh immediately. + if (p->m_batch_physical_deletion == 0) { + sidebar().obj_list()->update_objects_list_filament_column( + std::max(sidebar().combos_filament().size(), 1)); + sidebar().obj_list()->refresh_layer_range_filament_items(); + } + } else { + sidebar().obj_list()->update_objects_list_filament_column_when_delete_filament(filament_id, num_filaments, replace_filament_id); + } // update customize gcode for (auto item = p->model.plates_custom_gcodes.begin(); item != p->model.plates_custom_gcodes.end(); ++item) { diff --git a/src/slic3r/GUI/PlaterFilamentComboBox.cpp b/src/slic3r/GUI/PlaterFilamentComboBox.cpp new file mode 100644 index 000000000000..58b9b3091839 --- /dev/null +++ b/src/slic3r/GUI/PlaterFilamentComboBox.cpp @@ -0,0 +1,751 @@ +#include "PlaterFilamentComboBox.hpp" + +#include "GUI_App.hpp" +#include "libslic3r/Config.hpp" +#include "libslic3r/Utils.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace Slic3r +{ +namespace GUI +{ + +namespace +{ + +wxWeakRef s_active_popup; +wxWeakRef s_active_owner; + +} // namespace + +#ifdef __WXOSX__ +// wxOSX reposts an outside click after dismissing a transient popup. The repost is a copied +// wxMouseEvent, so it keeps the original timestamp and screen position. Detect and consume that +// copy without retaining an event pointer beyond the event's lifetime. +class PlaterFilamentComboBox::RepostedClickDetector : public wxEventFilter +{ +public: + /** @brief Installs the shared event filter for the first filament combo. */ + static void acquire() + { + wxASSERT(wxIsMainThread()); + if (s_detector == nullptr) + { + s_history = {}; + s_next = 0; + s_detector = std::make_unique(); + wxEvtHandler::AddFilter(s_detector.get()); + } + ++s_owner_count; + } + + /** @brief Removes the shared event filter after the last filament combo is destroyed. */ + static void release() + { + wxASSERT(wxIsMainThread()); + wxASSERT(s_owner_count > 0); + if (s_owner_count == 0) + { + return; + } + + --s_owner_count; + if (s_owner_count == 0 && s_detector != nullptr) + { + wxEvtHandler::RemoveFilter(s_detector.get()); + s_detector.reset(); + } + } + + /** @brief Consumes a copied outside click only when it targets a closed filament combo. */ + int FilterEvent(wxEvent &event) override + { + if (event.GetEventType() != wxEVT_LEFT_DOWN || event.GetTimestamp() <= 0) + { + return Event_Skip; + } + + const wxMouseEvent *mouse_event = dynamic_cast(&event); + wxWindow *window = dynamic_cast(event.GetEventObject()); + if (mouse_event == nullptr || window == nullptr) + { + return Event_Skip; + } + + const wxPoint screen_position = + window->ClientToScreen(mouse_event->GetPosition()); + const std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + const long timestamp = event.GetTimestamp(); + constexpr std::chrono::seconds max_age{2}; + + for (ClickRecord &record : s_history) + { + if (!record.valid) + { + continue; + } + if (now - record.observed_at > max_age) + { + record.valid = false; + continue; + } + if (record.timestamp != timestamp || record.screen_position != screen_position) + { + continue; + } + + // A repost is a one-shot copy. Retire the value record for every target so a repost to + // another control cannot leave state that affects a later filament-combo click. + record.valid = false; + // Deliberately target-agnostic: wxOSX reposts the dismissing click to whatever window + // sits under the pointer (wxFindWindowAtPoint in src/common/popupcmn.cpp), which may be + // a different filament combo than the dismissed one. Narrowing this to "the dismissed + // combo" would let such a repost click through and reopen a menu the user never clicked. + PlaterFilamentComboBox *combo = dynamic_cast(window); + if (combo != nullptr && !combo->m_popup_visible) + { + return Event_Ignore; + } + return Event_Skip; + } + + ClickRecord &record = s_history[s_next]; + record.valid = true; + record.timestamp = timestamp; + record.screen_position = screen_position; + record.observed_at = now; + s_next = (s_next + 1) % s_history.size(); + return Event_Skip; + } + +private: + struct ClickRecord + { + bool valid; + long timestamp; + wxPoint screen_position; + std::chrono::steady_clock::time_point observed_at; + }; + + static constexpr size_t k_history = 16; + + inline static std::unique_ptr s_detector; + inline static std::array s_history{}; + inline static size_t s_next = 0; + inline static size_t s_owner_count = 0; +}; +#endif + +namespace +{ + +constexpr const char *g_allow_list_file_name = "filament_allow_list.json"; +constexpr const char *g_snapmaker_vendor = "Snapmaker"; +constexpr const char *g_bambu_vendor = "Bambu"; +constexpr const char *g_bambu_lab_vendor = "Bambu Lab"; + +wxWindow *scroll_parent(wxWindow *window) +{ + wxWindow *current = window; + while (current != nullptr && current->GetParent() != nullptr) { + wxWindow *parent = current->GetParent(); + if (dynamic_cast(parent) != nullptr) + return current; + current = parent; + } + return nullptr; +} + +std::filesystem::path filament_allow_list_path() +{ + const std::filesystem::path user_path = std::filesystem::u8path(Slic3r::data_dir()) / PRESET_SYSTEM_DIR / + g_snapmaker_vendor / "filament" / g_allow_list_file_name; + + std::error_code filesystem_error; + const bool user_copy_exists = std::filesystem::exists(user_path, filesystem_error); + if (filesystem_error) + BOOST_LOG_TRIVIAL(warning) << "FilamentOrder could not inspect " << user_path.u8string() << ": " + << filesystem_error.message(); + + const std::filesystem::path shipped_path = std::filesystem::u8path(Slic3r::resources_dir()) / "profiles" / + g_snapmaker_vendor / "filament" / g_allow_list_file_name; + return choose_allow_list_copy(user_path, shipped_path, user_copy_exists); +} + +/** @brief Loads the vendor filament order once from the user or resource allow-list path. */ +FilamentOrder load_filament_order() +{ + const std::filesystem::path path = filament_allow_list_path(); + + // The reason is logged together with the path so a missing file and a rejected file stay + // distinguishable in the log. + std::string failure; + const FilamentOrder order = FilamentOrder::from_file(path, &failure); + if (order.empty()) + BOOST_LOG_TRIVIAL(warning) << "FilamentOrder " << failure << ": " << path.u8string(); + else + BOOST_LOG_TRIVIAL(info) << "FilamentOrder loaded: " << path.u8string(); + return order; +} + +/** + * @brief Returns the process-wide immutable filament ordering configuration. + * + * The one-time load is intentional: popup refreshes and preset updates in the + * running process must not perform file I/O or change the active ordering. If + * PresetUpdater replaces the deployed allow-list file, the new order takes effect + * after the next application restart. + */ +const FilamentOrder &filament_order() +{ + static const FilamentOrder order = load_filament_order(); + return order; +} + +std::string config_string(const Preset *preset, const char *key) +{ + if (preset == nullptr) + return {}; + + const auto *option = preset->config.option(key); + if (option == nullptr || option->values.empty()) + return {}; + return option->values.front(); +} + +bool is_missing_vendor(const std::string &vendor) +{ + wxString label = from_u8(vendor); + label.Trim(true).Trim(false); + return label.empty() || label == wxString::FromUTF8("(Undefined)"); +} + +std::string vendor_from_display_name(const wxString &display_name) +{ + wxString name = display_name; + name.Trim(true).Trim(false); + wxStringTokenizer words(name); + return words.HasMoreTokens() ? into_u8(words.GetNextToken()) : std::string(); +} + +} // namespace + +PlaterFilamentComboBox::PlaterFilamentComboBox(wxWindow *parent, Preset::Type preset_type) + : PlaterPresetComboBox(parent, preset_type) +{ +#ifdef __WXOSX__ + RepostedClickDetector::acquire(); +#endif + Bind(wxEVT_LEFT_DOWN, &PlaterFilamentComboBox::on_mouse_down, this); + Bind(wxEVT_LEFT_DCLICK, &PlaterFilamentComboBox::on_mouse_down, this); + Bind(wxEVT_KEY_DOWN, &PlaterFilamentComboBox::on_key_down, this); + + if (preset_type != Preset::TYPE_FILAMENT) + return; + + // Load the vendor filament order once; popup refreshes never perform file I/O. + m_system_vendor_sorter = std::make_unique(); + m_system_filament_sorter = std::make_unique(filament_order()); + m_project_sorter = std::make_unique(); + m_user_sorter = std::make_unique(); + + std::unique_ptr popup = std::make_unique(m_popup_items); + if (!popup->Create(this)) + { + BOOST_LOG_TRIVIAL(warning) + << "Could not create the grouped filament popup; falling back to the standard dropdown."; + return; + } + m_popup = popup.release(); + m_popup->SetUseContentWidth(true, true); + m_popup->Bind(wxEVT_COMBOBOX, &PlaterFilamentComboBox::on_popup_selection, this); + m_popup->Bind(EVT_DISMISS, &PlaterFilamentComboBox::on_popup_dismiss, this); + + // Text-control key events do not reach this window's handlers; bind them separately. + if (GetTextCtrl() != nullptr) + GetTextCtrl()->Bind(wxEVT_KEY_DOWN, &PlaterFilamentComboBox::on_key_down, this); + + m_top_level = wxGetTopLevelParent(this); + if (m_top_level != nullptr) { + m_top_level->Bind(wxEVT_MOVE, &PlaterFilamentComboBox::on_top_level_move, this); + m_top_level->Bind(wxEVT_SIZE, &PlaterFilamentComboBox::on_top_level_size, this); + } + + // WHY: the scrolled content panel, not the top-level frame, moves when the sidebar scrolls. + // The top-level MOVE handler cannot observe that child movement, so both bindings are needed + // to dismiss a popup that would otherwise remain detached from its combo box. + m_scroll_parent = scroll_parent(this); + if (m_scroll_parent != nullptr && m_scroll_parent != m_top_level) + m_scroll_parent->Bind(wxEVT_MOVE, &PlaterFilamentComboBox::on_scroll_parent_move, this); +} + +PlaterFilamentComboBox::~PlaterFilamentComboBox() +{ + // Unbind the popup's handlers before closing it: close_popup() dismisses the popup, whose + // OnDismiss() posts EVT_DISMISS back to this window; that must not reach a partially + // destroyed object. Keep this order. + if (m_popup != nullptr) + { + m_popup->Unbind(wxEVT_COMBOBOX, &PlaterFilamentComboBox::on_popup_selection, this); + m_popup->Unbind(EVT_DISMISS, &PlaterFilamentComboBox::on_popup_dismiss, this); + } + + close_popup(false); + + if (m_top_level != nullptr) { + m_top_level->Unbind(wxEVT_MOVE, &PlaterFilamentComboBox::on_top_level_move, this); + m_top_level->Unbind(wxEVT_SIZE, &PlaterFilamentComboBox::on_top_level_size, this); + } + + if (m_scroll_parent != nullptr && m_scroll_parent != m_top_level) + m_scroll_parent->Unbind(wxEVT_MOVE, &PlaterFilamentComboBox::on_scroll_parent_move, this); + + if (GetTextCtrl() != nullptr) + GetTextCtrl()->Unbind(wxEVT_KEY_DOWN, &PlaterFilamentComboBox::on_key_down, this); + +#ifdef __WXOSX__ + RepostedClickDetector::release(); +#endif +} + +void PlaterFilamentComboBox::update() +{ + // WHY: the base update() below can emit a selection change that reaches + // Plater::priv::on_select_preset(), which calls combo->update() again (Plater.cpp:14993). + // The nested call must not dismiss the popup or rebuild the rows a second time. + if (m_rebuilding) + return; + + close_popup(true); + m_rebuilding = true; + + // Parent update() first; this class only changes how rows are presented. + PlaterPresetComboBox::update(); + rebuild_popup_rows(); + + m_rebuilding = false; +} + +void PlaterFilamentComboBox::msw_rescale() +{ + PlaterPresetComboBox::msw_rescale(); + if (m_popup != nullptr) { + m_popup->Rescale(); + m_popup->Invalidate(); + } +} + +PlaterFilamentComboBox::Section PlaterFilamentComboBox::section_from_header(const wxString &text) const +{ + // Contract with the base class: these three English labels must stay identical to the ones the + // combo builds its headers with (PresetComboBoxes.cpp, via PresetComboBox::separator). Renaming + // any of them there collapses every section into Section::Other without a compile error. The + // separator formats and translates the label itself, so the untranslated literals are correct here. + if (text == separator("Project-inside presets")) + return Section::Project; + if (text == separator("User presets")) + return Section::User; + if (text == separator("System presets")) + return Section::System; + return Section::Other; +} + +PlaterFilamentComboBox::PopupRow PlaterFilamentComboBox::make_header(const wxString &text, + Section section) const +{ + PopupRow row; + row.item.text = text; + row.item.style = 0; + row.section = section; + row.header = true; + return row; +} + +std::string PlaterFilamentComboBox::preset_vendor(const Preset *preset) const +{ + std::string vendor = config_string(preset, "filament_vendor"); + // Historical spelling: normalise it case-insensitively, like the allow-list key matching does. + if (from_u8(vendor).CmpNoCase(wxString::FromUTF8(g_bambu_lab_vendor)) == 0) + vendor = g_bambu_vendor; + return vendor; +} + +std::string PlaterFilamentComboBox::preset_filament_product(const Preset *preset) const +{ + if (preset == nullptr) + return {}; + + return filament_product_key(Preset::remove_suffix_modified(preset->name), preset_vendor(preset)); +} + +wxString PlaterFilamentComboBox::popup_group(Section section, const std::string &vendor) const +{ + switch (section) { + case Section::Project: return _L("Project") + " "; + case Section::User: return _L("Custom") + " "; + case Section::System: + return vendor.empty() ? _L("System") : from_u8(vendor); + case Section::Other: return wxString(); + } + return wxString(); +} + +bool PlaterFilamentComboBox::is_system_row(const PopupRow &row) const +{ + return !row.header && row.combo_index >= 0 && row.section == Section::System; +} + +void PlaterFilamentComboBox::rebuild_popup_rows() +{ + m_rows.clear(); + m_popup_to_combo.clear(); + m_popup_items.clear(); + + if (m_popup == nullptr || m_collection == nullptr || m_type != Preset::TYPE_FILAMENT) + return; + + Section current_section = Section::Other; + for (unsigned int combo_index = 0; combo_index < GetCount(); ++combo_index) { + if (combo_index > static_cast(std::numeric_limits::max())) + break; + const wxString text = GetString(combo_index); + const Marker marker = reinterpret_cast(GetClientData(combo_index)); + + if (marker >= LABEL_ITEM_MARKER && marker < LABEL_ITEM_MAX && marker != LABEL_ITEM_DISABLED) { + if (marker == LABEL_ITEM_WIZARD_FILAMENTS) { + PopupRow row; + row.item.text = text; + row.item.icon = GetItemBitmap(combo_index); + row.item.tip = GetItemTooltip(combo_index); + if (row.item.tip.IsEmpty()) + row.item.tip = text; + row.combo_index = static_cast(combo_index); + row.section = Section::Other; + m_rows.push_back(std::move(row)); + } else { + current_section = section_from_header(text); + m_rows.push_back(make_header(text, current_section)); + } + continue; + } + + PopupRow row; + row.item.text = text; + row.item.icon = GetItemBitmap(combo_index); + row.item.tip = GetItemTooltip(combo_index); + row.item.style = marker == LABEL_ITEM_DISABLED ? DD_ITEM_STYLE_DISABLED : 0; + row.combo_index = static_cast(combo_index); + row.section = current_section; + row.sort_item.display_name = text; + row.sort_item.original_index = combo_index; + + // AMS/machine rows stay outside Project/User/System even for system presets. + if (current_section != Section::Other) { + const std::string alias = Preset::remove_suffix_modified(into_u8(text)); + const std::string &resolved_name = m_collection->get_preset_name_by_alias(alias); + const Preset *preset = m_collection->find_preset(resolved_name); + if (preset != nullptr) { + if (preset->is_project_embedded) + row.section = Section::Project; + else if (preset->is_default || preset->is_system) + row.section = Section::System; + else + row.section = Section::User; + + row.sort_item.vendor = preset_vendor(preset); + row.sort_item.filament_product = preset_filament_product(preset); + row.item.tip = get_tooltip(*preset); + } + } + + // Fall back to the item text when no tooltip was resolved. + if (row.item.tip.IsEmpty()) + row.item.tip = row.item.text; + + // Group placeholder/empty system vendors by the display name's first word. + if (row.section == Section::System && is_missing_vendor(row.sort_item.vendor)) { + row.sort_item.vendor = vendor_from_display_name(row.item.text); + if (row.sort_item.vendor.empty()) + continue; + } + + if (row.section == Section::System) + row.sort_item.vendor = canonical_vendor(row.sort_item.vendor); + + if (current_section == Section::Other) { + row.item.group.clear(); + } else { + row.item.group = popup_group(row.section, row.sort_item.vendor); + } + + m_rows.push_back(std::move(row)); + } + + // Drop a system header that has no rows left. + for (auto it = m_rows.begin(); it != m_rows.end();) { + if (!it->header || it->section != Section::System) { + ++it; + continue; + } + + const auto next = std::next(it); + if (next == m_rows.end() || next->header || next->section != Section::System) + it = m_rows.erase(it); + else + ++it; + } + + // Null project/user sorters preserve base order. + sort_section_rows(Section::Project, m_project_sorter.get()); + sort_section_rows(Section::User, m_user_sorter.get()); + sort_system_rows(); + + m_popup_items.reserve(m_rows.size()); + m_popup_to_combo.reserve(m_rows.size()); + for (const PopupRow &row : m_rows) { + m_popup_items.push_back(row.item); + m_popup_to_combo.push_back(row.combo_index); + } + + m_popup->SetItems(m_popup_items); + int popup_selection = -1; + const int combo_selection = GetSelection(); + for (size_t i = 0; i < m_popup_to_combo.size(); ++i) { + if (i > static_cast(std::numeric_limits::max())) + break; + if (m_popup_to_combo[i] == combo_selection) { + popup_selection = static_cast(i); + break; + } + } + m_popup->SetSelection(popup_selection); +} + +void PlaterFilamentComboBox::sort_section_rows(Section section, const FilamentSorter *sorter) +{ + if (sorter == nullptr) + return; + + for (size_t begin = 0; begin < m_rows.size();) { + while (begin < m_rows.size() && (m_rows[begin].header || m_rows[begin].section != section)) + ++begin; + size_t end = begin; + while (end < m_rows.size() && !m_rows[end].header && m_rows[end].section == section) + ++end; + if (begin != end) { + std::stable_sort(m_rows.begin() + static_cast(begin), + m_rows.begin() + static_cast(end), + [sorter](const PopupRow &left, const PopupRow &right) { + return sorter->less(left.sort_item, right.sort_item); + }); + } + begin = end; + } +} + +void PlaterFilamentComboBox::sort_system_rows() +{ + // Vendor-equivalent rows delegate to the filament sorter (keeps a strict weak ordering). + for (size_t i = 0; i < m_rows.size(); ++i) { + if (!is_system_row(m_rows[i])) + continue; + + size_t end = i; + while (end < m_rows.size() && is_system_row(m_rows[end])) + ++end; + + std::stable_sort(m_rows.begin() + static_cast(i), + m_rows.begin() + static_cast(end), + [this](const PopupRow &left, const PopupRow &right) { + if (m_system_vendor_sorter->less(left.sort_item.vendor, right.sort_item.vendor)) + return true; + if (m_system_vendor_sorter->less(right.sort_item.vendor, left.sort_item.vendor)) + return false; + return m_system_filament_sorter->less(left.sort_item, right.sort_item); + }); + // Land on the first row after this system section. + i = end > 0 ? end - 1 : end; + } +} + +void PlaterFilamentComboBox::show_popup() +{ + if (m_popup == nullptr || !IsEnabled()) + return; + + // Dismiss a flat popup opened before this handler took ownership. + if (GetDropDown().IsShown()) + GetDropDown().Dismiss(); + + if (m_popup_visible) + { + close_popup(true); + return; + } + + // wxOSX and wxGTK may repost the click that dismissed the transient popup to this control. + // Keep the base ComboBox debounce so that dismissing never immediately reopens the popup. + if (!m_popup->HasDismissLongTime()) + return; + + if (s_active_owner && s_active_owner.get() != this) + s_active_owner->close_popup(true); + else if (s_active_popup && s_active_popup.get() != m_popup) + s_active_popup->DismissAll(); + + update(); + if (m_popup_items.empty()) + return; + + s_active_popup = m_popup; + s_active_owner = this; + // WHY: update() above rebuilds m_popup_items and may synchronously notify selection listeners. + // Set the visible flag only after that work succeeds, so callbacks cannot observe a half-open + // popup and close/reopen it before PopupForParent() owns the window. + m_popup_visible = true; + wxCommandEvent open_event(wxEVT_COMBOBOX_DROPDOWN, GetId()); + open_event.SetEventObject(this); + GetEventHandler()->ProcessEvent(open_event); + m_popup->PopupForParent(); + m_popup->openSelectionGroup(); +} + +void PlaterFilamentComboBox::close_popup(bool notify) +{ + const bool was_visible = m_popup_visible; + m_popup_visible = false; + if (s_active_popup && s_active_popup.get() == m_popup) + s_active_popup = nullptr; + if (s_active_owner && s_active_owner.get() == this) + s_active_owner = nullptr; + + if (m_popup != nullptr) + m_popup->DismissAll(); + + if (notify && was_visible) { + wxCommandEvent close_event(wxEVT_COMBOBOX_CLOSEUP, GetId()); + close_event.SetEventObject(this); + GetEventHandler()->ProcessEvent(close_event); + } +} + +void PlaterFilamentComboBox::on_popup_selection(wxCommandEvent &event) +{ + const int popup_index = event.GetInt(); + if (popup_index < 0 || static_cast(popup_index) >= m_popup_to_combo.size()) + return; + + const int combo_index = m_popup_to_combo[popup_index]; + if (combo_index < 0 || static_cast(combo_index) >= GetCount()) + return; + + close_popup(true); + SetSelection(combo_index); + + wxCommandEvent select_event(wxEVT_COMBOBOX, GetId()); + select_event.SetEventObject(this); + select_event.SetInt(combo_index); + select_event.SetString(GetString(combo_index)); + GetEventHandler()->ProcessEvent(select_event); +} + +void PlaterFilamentComboBox::on_popup_dismiss(wxCommandEvent &event) +{ + event.StopPropagation(); + if (m_popup_visible) + close_popup(true); + else { + if (s_active_popup && s_active_popup.get() == m_popup) + s_active_popup = nullptr; + if (s_active_owner && s_active_owner.get() == this) + s_active_owner = nullptr; + } +} + +void PlaterFilamentComboBox::on_mouse_down(wxMouseEvent &event) +{ + if (m_popup == nullptr) { + // Let ComboBox's static event table open its existing flat popup. + event.Skip(); + return; + } + + SetFocus(); + show_popup(); + + // Reset the skip flag so wxWidgets doesn't reach ComboBox's static table. + event.Skip(false); + event.StopPropagation(); +} + +void PlaterFilamentComboBox::on_key_down(wxKeyEvent &event) +{ + // Alt combinations (e.g. Alt+Space system menu) must not be consumed here. + if (event.AltDown()) { + event.Skip(); + return; + } + + switch (event.GetKeyCode()) { + case WXK_RETURN: + case WXK_SPACE: + case WXK_DOWN: + if (m_popup == nullptr) { + // Preserve the base ComboBox keyboard behaviour after popup creation fails. + event.Skip(); + return; + } + show_popup(); + event.Skip(false); + event.StopPropagation(); + return; + case WXK_ESCAPE: + if (m_popup_visible) { + close_popup(true); + event.Skip(false); + event.StopPropagation(); + return; + } + break; + default: + break; + } + event.Skip(); +} + +void PlaterFilamentComboBox::on_scroll_parent_move(wxMoveEvent &event) +{ + close_popup(true); + event.Skip(); +} + +void PlaterFilamentComboBox::on_top_level_move(wxMoveEvent &event) +{ + close_popup(true); + event.Skip(); +} + +void PlaterFilamentComboBox::on_top_level_size(wxSizeEvent &event) +{ + close_popup(true); + event.Skip(); +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/PlaterFilamentComboBox.hpp b/src/slic3r/GUI/PlaterFilamentComboBox.hpp new file mode 100644 index 000000000000..da6c89d1537f --- /dev/null +++ b/src/slic3r/GUI/PlaterFilamentComboBox.hpp @@ -0,0 +1,106 @@ +#pragma once + +#include "FilamentDropDown.hpp" +#include "FilamentSort.hpp" +#include "PresetComboBoxes.hpp" + +#include +#include + +namespace Slic3r +{ +namespace GUI +{ + +/** @brief Provides the filament-specific grouped presentation over the preset combo pipeline. */ +class PlaterFilamentComboBox : public PlaterPresetComboBox +{ +public: + /** @brief Creates the filament presentation for a filament preset combobox. */ + PlaterFilamentComboBox(wxWindow *parent, Preset::Type preset_type); + ~PlaterFilamentComboBox() override; + + /** @brief Rebuilds popup rows after the base preset collection refreshes. */ + void update() override; + /** @brief Invalidates popup geometry after a Windows DPI change. */ + void msw_rescale() override; + +private: +#ifdef __WXOSX__ + /** @brief Filters copied macOS outside-click events for filament combo boxes. */ + class RepostedClickDetector; +#endif + + /** @brief Identifies which preset source a popup row was read from. */ + enum class Section { + Other, + Project, + User, + System, + }; + + /** @brief Pairs one popup row with the combo index and sort keys it was built from. */ + struct PopupRow { + FilamentDropDown::Item item; + int combo_index{-1}; + Section section{Section::Other}; + FilamentSortItem sort_item; + bool header{false}; + }; + + /** @brief Rebuilds the popup items and rows from the current preset collection. */ + void rebuild_popup_rows(); + /** @brief Sorts one section's rows with @p sorter, leaving its headers in place. */ + void sort_section_rows(Section section, const FilamentSorter *sorter); + /** @brief Sorts the system rows by vendor priority and then by the configured filament order. */ + void sort_system_rows(); + /** @brief Opens the two-level popup, dismissing any flat popup the base class left open. */ + void show_popup(); + /** @brief Hides the popup and releases the active-popup bookkeeping; @p notify emits the closeup + * event when the popup was actually visible. */ + void close_popup(bool notify); + /** @brief Applies the selected popup row to the preset pipeline. */ + void on_popup_selection(wxCommandEvent &event); + /** @brief Clears the visible-popup state after the popup dismissed itself. */ + void on_popup_dismiss(wxCommandEvent &event); + /** @brief Opens the popup for a click on this control. */ + void on_mouse_down(wxMouseEvent &event); + /** @brief Handles the keys that open or close the popup, leaving other keys to the base class. */ + void on_key_down(wxKeyEvent &event); + /** @brief Dismisses the popup when the sidebar's scrolled panel moves. */ + void on_scroll_parent_move(wxMoveEvent &event); + /** @brief Dismisses the popup when the top-level window moves. */ + void on_top_level_move(wxMoveEvent &event); + /** @brief Dismisses the popup when the top-level window is resized. */ + void on_top_level_size(wxSizeEvent &event); + + /** @brief Maps a popup section header label back to its section. */ + Section section_from_header(const wxString &text) const; + /** @brief Builds a header row for @p section with the given label. */ + PopupRow make_header(const wxString &text, Section section) const; + /** @brief Returns the vendor recorded by @p preset, mapping the historical Bambu Lab spelling to Bambu. */ + std::string preset_vendor(const Preset *preset) const; + /** @brief Returns the product key that the configured order matches for @p preset. */ + std::string preset_filament_product(const Preset *preset) const; + /** @brief Returns the group label that @p vendor belongs to within @p section. */ + wxString popup_group(Section section, const std::string &vendor) const; + /** @brief Reports whether @p row is a real system preset row rather than a header or an auxiliary row. */ + bool is_system_row(const PopupRow &row) const; + + wxWindow *m_top_level{nullptr}; // non-owning wx parent + wxWindow *m_scroll_parent{nullptr}; // non-owning scroll viewport + FilamentDropDown *m_popup{nullptr}; // owned by wx parent + std::unique_ptr m_project_sorter; + std::unique_ptr m_user_sorter; + std::unique_ptr m_system_vendor_sorter; + std::unique_ptr m_system_filament_sorter; + std::vector m_rows; + std::vector m_popup_items; + std::vector m_popup_to_combo; + bool m_popup_visible{false}; + bool m_rebuilding{false}; + +}; + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/SSWCP.cpp b/src/slic3r/GUI/SSWCP.cpp index 71d44f97770d..72280551f350 100644 --- a/src/slic3r/GUI/SSWCP.cpp +++ b/src/slic3r/GUI/SSWCP.cpp @@ -4291,6 +4291,10 @@ void SSWCP_MachineConnect_Instance::process() { } } +// Static holder for the one-shot pin-code client (declared in SSWCP.hpp). +// Released on the UI thread after the response — never from a Paho callback. +std::shared_ptr SSWCP_MachineConnect_Instance::m_pin_client; + void SSWCP_MachineConnect_Instance::sw_get_pin_code() { try { @@ -4302,24 +4306,38 @@ void SSWCP_MachineConnect_Instance::sw_get_pin_code() auto weak_self = std::weak_ptr(shared_from_this()); wxGetApp().CallAfter([=]() { - MqttClient* mqtt_client = new MqttClient("mqtt://" + ip + ":" + std::to_string(port), "Snapmaker Orca"); + // Create the one-shot client through the factory so it has a + // shared_ptr owner (a raw `new`-ed client can never arm the + // auto-reconnect checker and its lifetime is unsafe). The + // static holder keeps it alive until the response arrives and + // is released on the UI thread (see the callback below). + std::shared_ptr mqtt_client = MqttClient::create("mqtt://" + ip + ":" + std::to_string(port), "Snapmaker Orca"); + m_pin_client = mqtt_client; + std::weak_ptr weak_client = mqtt_client; std::string connect_msg = ""; if (mqtt_client->Connect(connect_msg)) { std::string sub_msg = "success"; if (mqtt_client->Subscribe("cloud/config/response", 1, sub_msg)) { - mqtt_client->SetMessageCallback([weak_self, mqtt_client](const std::string& topic, const std::string& message) { - auto self = weak_self.lock(); - if (self) { + mqtt_client->SetMessageCallback([weak_self, weak_client](const std::string& topic, const std::string& message) { + auto self = weak_self.lock(); + auto mqtt_client = weak_client.lock(); + if (self && mqtt_client) { if (topic == "cloud/config/response") { - json response = json::parse(message); - if (response.count("result")) { + // Non-throwing parse (allow_exceptions=false): + // a parse error must not escape into Paho's + // C callback stack (uncaught = terminate). + json response = json::parse(message, nullptr, false); + if (!response.is_discarded() && response.count("result")) { self->m_res_data = response["result"]; self->send_to_js(); self->finish_job(); std::string dc_msg = "success"; bool flag = mqtt_client->Disconnect(dc_msg); - wxGetApp().CallAfter([mqtt_client]() { delete mqtt_client; }); + // Drop the holder on the UI thread: destroying + // the client from inside message_arrived (a + // Paho thread) is not allowed by Paho. + wxGetApp().CallAfter([]() { SSWCP_MachineConnect_Instance::release_pin_client(); }); return; } self->handle_general_fail(); @@ -4347,6 +4365,9 @@ void SSWCP_MachineConnect_Instance::sw_get_pin_code() if (self) { self->handle_general_fail(); } + // Connection failed: nothing to wait for, drop the client here + // (UI thread — safe to destroy). + m_pin_client.reset(); }); } else { handle_general_fail(); @@ -6895,9 +6916,9 @@ void SSWCP_MqttAgent_Instance::sw_create_mqtt_client() std::string type = "mqtt"; if (ca != "" && cert != "" && key != "") { type = "mqtts"; - client.reset(new MqttClient(server_address, clientId, ca, cert, key, username, password, clean_session)); + client = MqttClient::create(server_address, clientId, ca, cert, key, username, password, clean_session); }else{ - client.reset(new MqttClient(server_address, clientId, username, password, clean_session)); + client = MqttClient::create(server_address, clientId, username, password, clean_session); } if (client == nullptr) { @@ -6979,7 +7000,17 @@ void SSWCP_MqttAgent_Instance::sw_mqtt_connect() auto self = std::dynamic_pointer_cast(weak_ptr.lock()); const std::string session_id = self ? self->get_connect_session_id() : std::string{}; - engine->SetConnectionFailureCallback([engine, session_id]() { + // Capture the engine WEAKLY: this callback is stored inside the + // engine itself (MqttClient::connection_failure_callback_), so a + // shared_ptr capture would keep its refcount >= 1 forever and + // ~MqttClient — the only place the callback gets cleared — would + // never run, leaking the client and its Paho handles. + std::weak_ptr weak_engine = engine; + engine->SetConnectionFailureCallback([weak_engine, session_id]() { + auto engine = weak_engine.lock(); + if (!engine) { + return; + } SNAP_LOG_BATCH(Error, "mqtt connection failure callback", {"eventName", "mqtt_connect_failure"}, {"source", "cpp"}, {"connectSessionId", session_id}); diff --git a/src/slic3r/GUI/SSWCP.hpp b/src/slic3r/GUI/SSWCP.hpp index 971003b78ddb..122c7e24014b 100644 --- a/src/slic3r/GUI/SSWCP.hpp +++ b/src/slic3r/GUI/SSWCP.hpp @@ -265,6 +265,12 @@ class SSWCP_MachineConnect_Instance : public SSWCP_Instance // Subscribe to foreground/background change events (event_id=205890) void sw_SubscribeForegroundChange(); + // Owns the one-shot pin-code MQTT client (sw_get_pin_code). Released on + // the UI thread after the response — never from a Paho callback thread, + // where destroying a client is not allowed. + static std::shared_ptr m_pin_client; + static void release_pin_client() { m_pin_client.reset(); } + }; // mqtt-agent diff --git a/src/slic3r/GUI/Widgets/DropDown.cpp b/src/slic3r/GUI/Widgets/DropDown.cpp index 3497c11c989f..52f973bd7aae 100644 --- a/src/slic3r/GUI/Widgets/DropDown.cpp +++ b/src/slic3r/GUI/Widgets/DropDown.cpp @@ -1,6 +1,8 @@ #include "DropDown.hpp" #include "Label.hpp" +#include + #include #include #include @@ -420,6 +422,50 @@ void DropDown::autoPosition() } } +bool DropDown::PointInAnchorGap(const wxPoint& screen_point) const +{ + wxWindow* anchor = GetParent(); + if (!anchor) + return false; + + const wxRect anchor_rect = anchor->GetScreenRect(); + const wxRect popup_rect = GetScreenRect(); + + const int left = std::max(anchor_rect.GetLeft(), popup_rect.GetLeft()); + const int right = std::min(anchor_rect.GetRight(), popup_rect.GetRight()); + if (right < left) + return false; + + // Screen y grows downward. Boundary pixels belong to the anchor or popup, so the clickable gap is + // strictly between the two rectangles. + if (popup_rect.GetTop() > anchor_rect.GetBottom()) { + return screen_point.x >= left && screen_point.x <= right && screen_point.y > anchor_rect.GetBottom() && + screen_point.y < popup_rect.GetTop(); + } + + if (popup_rect.GetBottom() < anchor_rect.GetTop()) { + return screen_point.x >= left && screen_point.x <= right && screen_point.y > popup_rect.GetBottom() && + screen_point.y < anchor_rect.GetTop(); + } + + return false; +} + +bool DropDown::ProcessLeftDown(wxMouseEvent& event) +{ +#ifdef __WXOSX__ + if (IsShown() && HitTest(event.GetPosition()) == wxHT_WINDOW_OUTSIDE && PointInAnchorGap(ClientToScreen(event.GetPosition()))) { + DismissAndNotify(); + + // wxOSX reposts an outside click to the control below the popup. The anchor/popup gap is not + // an activation target, so consume it after dismissing. + return true; + } +#endif + + return PopupWindow::ProcessLeftDown(event); +} + void DropDown::mouseDown(wxMouseEvent& event) { // Receivce unexcepted LEFT_DOWN on Mac after OnDismiss diff --git a/src/slic3r/GUI/Widgets/DropDown.hpp b/src/slic3r/GUI/Widgets/DropDown.hpp index 24507cb77619..79389ab76c65 100644 --- a/src/slic3r/GUI/Widgets/DropDown.hpp +++ b/src/slic3r/GUI/Widgets/DropDown.hpp @@ -89,6 +89,7 @@ class DropDown : public PopupWindow bool HasDismissLongTime(); protected: + bool ProcessLeftDown(wxMouseEvent& event) override; void OnDismiss() override; private: @@ -100,6 +101,7 @@ class DropDown : public PopupWindow friend class ComboBox; void messureSize(); void autoPosition(); + bool PointInAnchorGap(const wxPoint& screen_point) const; // some useful events void mouseDown(wxMouseEvent& event); diff --git a/src/slic3r/Utils/Http.cpp b/src/slic3r/Utils/Http.cpp index a748300e0b57..23370176b0a7 100644 --- a/src/slic3r/Utils/Http.cpp +++ b/src/slic3r/Utils/Http.cpp @@ -849,7 +849,8 @@ std::string Http::tls_system_cert_store() std::string ret; #ifdef OPENSSL_CERT_OVERRIDE - ret = ::getenv(X509_get_default_cert_file_env()); + if (const char* cert_file = ::getenv(X509_get_default_cert_file_env())) + ret = cert_file; #endif return ret; diff --git a/src/slic3r/Utils/MQTT.cpp b/src/slic3r/Utils/MQTT.cpp index b3db4242bb69..68915ae01d62 100644 --- a/src/slic3r/Utils/MQTT.cpp +++ b/src/slic3r/Utils/MQTT.cpp @@ -376,12 +376,14 @@ bool MqttClient::Publish(const std::string& topic, const std::string& payload, i // @param callback: Function to be called when a message arrives void MqttClient::SetMessageCallback(std::function callback) { + std::lock_guard lock(cb_mtx_); message_callback1_ = nullptr; message_callback_ = callback; } void MqttClient::SetMessageCallback(std::function callback) { + std::lock_guard lock(cb_mtx_); message_callback_ = nullptr; message_callback1_ = callback; } @@ -436,23 +438,45 @@ void MqttClient::connection_lost(const std::string& cause) } connected_.store(false, std::memory_order_release); - + + // ~MqttClient nulls the callbacks and disconnects; a connection_lost that + // races destruction must not spawn the reconnect checker below (its + // shared_from_this() would throw bad_weak_ptr once the last owner is gone, + // and the exception escapes through Paho's C callback into terminate()). + if (tearing_down_.load(std::memory_order_acquire)) { + BOOST_LOG_TRIVIAL(warning) << "[MQTT_INFO] MQTT client is being destroyed, ignoring connection_lost"; + return; + } + if (!ever_connected_.load(std::memory_order_acquire)) { BOOST_LOG_TRIVIAL(error) << "The first connection failed. Since no successful connection has been made before, automatic reconnection remains disabled"; - if (connection_failure_callback_) { - connection_failure_callback_(); + std::function failure_cb; + { + std::lock_guard lock(cb_mtx_); + failure_cb = connection_failure_callback_; + } + if (failure_cb) { + failure_cb(); } return; } if (!is_reconnecting.load(std::memory_order_acquire)) { + // self_ is cached by create() while the client is owned, so reading it + // can never throw (unlike shared_from_this(), which throws + // bad_weak_ptr once the last owner has started destruction). An + // expired/empty weak_ptr means no owner: auto-reconnect is impossible + // for this client. + std::weak_ptr weak_self = self_; + if (weak_self.expired()) { + BOOST_LOG_TRIVIAL(error) << "[MQTT_INFO] MQTT client has no shared_ptr owner (or is being destroyed); automatic reconnection skipped"; + return; + } + is_reconnecting.store(true, std::memory_order_release); pending_reconnect_checks.fetch_add(1, std::memory_order_release); - - + { - std::weak_ptr weak_self = shared_from_this(); - std::thread([weak_self]() { std::this_thread::sleep_for(std::chrono::seconds(20)); @@ -469,8 +493,13 @@ void MqttClient::connection_lost(const std::string& cause) BOOST_LOG_TRIVIAL(error) << "[MQTT_INFO] MQTT connection not restored after 20 seconds"; std::string dc_msg = ""; self->Disconnect(dc_msg); - if (self->connection_failure_callback_) { - self->connection_failure_callback_(); + std::function failure_cb; + { + std::lock_guard lock(self->cb_mtx_); + failure_cb = self->connection_failure_callback_; + } + if (failure_cb) { + failure_cb(); } } @@ -491,12 +520,22 @@ void MqttClient::connection_lost(const std::string& cause) // @param msg: Pointer to the received message void MqttClient::message_arrived(mqtt::const_message_ptr msg) { - if (message_callback_) { - message_callback_(msg->get_topic(), msg->to_string()); + // Copy the callbacks under the lock and invoke the copies outside it: + // the setters (and ~MqttClient) may run concurrently on another thread. + std::function cb; + std::function cb1; + { + std::lock_guard lock(cb_mtx_); + cb = message_callback_; + cb1 = message_callback1_; } - if (message_callback1_) { - message_callback1_(msg->get_topic(), msg->to_string(), this); + if (cb) { + cb(msg->get_topic(), msg->to_string()); + } + + if (cb1) { + cb1(msg->get_topic(), msg->to_string(), this); } } @@ -522,10 +561,15 @@ void MqttClient::on_failure(const mqtt::token& tok) connected_.store(false, std::memory_order_release); - if (connection_failure_callback_) { - connection_failure_callback_(); + std::function failure_cb; + { + std::lock_guard lock(cb_mtx_); + failure_cb = connection_failure_callback_; } - } else { + if (failure_cb) { + failure_cb(); + } + } else { BOOST_LOG_TRIVIAL(error) << "[MQTT_INFO] Operation failed for token: " << tok.get_message_id(); if (tok.get_reason_code() != 0) { BOOST_LOG_TRIVIAL(error) << "[MQTT_INFO] Reason code: " << tok.get_reason_code(); @@ -557,11 +601,22 @@ void MqttClient::connected(const std::string& cause) } void MqttClient::resubscribe_topics() { - if (topics_to_resubscribe_.empty() || !client_) { + if (!client_) { + return; + } + + // Snapshot the map: subscribing can block for seconds per topic, so the + // lock must not be held while waiting on the broker. + std::map topics; + { + std::lock_guard lock(topics_mtx_); + topics = topics_to_resubscribe_; + } + if (topics.empty()) { return; } - for (const auto& topic_pair : topics_to_resubscribe_) { + for (const auto& topic_pair : topics) { try { auto tok = client_->subscribe(topic_pair.first, topic_pair.second, nullptr, subListener_); if (!tok->wait_for(std::chrono::seconds(5))) { @@ -578,11 +633,13 @@ void MqttClient::resubscribe_topics() { } void MqttClient::add_topic_to_resubscribe(const std::string& topic, int qos) { + std::lock_guard lock(topics_mtx_); topics_to_resubscribe_[topic] = qos; BOOST_LOG_TRIVIAL(debug) << "[MQTT_INFO] Added topic to resubscribe list: " << topic; } void MqttClient::remove_topic_from_resubscribe(const std::string& topic) { + std::lock_guard lock(topics_mtx_); auto it = topics_to_resubscribe_.find(topic); if (it != topics_to_resubscribe_.end()) { topics_to_resubscribe_.erase(it); @@ -592,38 +649,60 @@ void MqttClient::remove_topic_from_resubscribe(const std::string& topic) { MqttClient::~MqttClient() { - { - connected_.store(false, std::memory_order_release); - is_reconnecting.store(false, std::memory_order_release); - - int timeout_count = 0; - const int max_timeout = 20; // max 5s (50 * 100ms) - while (pending_reconnect_checks.load(std::memory_order_acquire) > 0 && timeout_count < max_timeout) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - timeout_count++; - } - - if (timeout_count >= max_timeout) { - BOOST_LOG_TRIVIAL(warning) << "[MQTT_INFO] timeout waiting for reconnect checks, forcing destruction"; - } - - if (client_) { - // Reuse Disconnect: soft-catches already-disconnected and clears shouldBeConnected. - std::string dc_msg; - Disconnect(dc_msg); - } - - topics_to_resubscribe_.clear(); - message_callback_ = nullptr; + // 0. Mark teardown before anything else: connection_lost() checks this + // before calling shared_from_this(), which would otherwise throw + // bad_weak_ptr (the last owner is the one running this destructor) and + // kill the process from Paho's C callback thread. + tearing_down_.store(true, std::memory_order_release); + + // 1. Null the callbacks FIRST so any Paho callback that is still in flight + // (or fires during teardown below) becomes a no-op instead of touching + // members being destroyed. Without this, message_arrived() could run on + // the Paho receive thread while these std::function members are being + // torn apart — one of the sources of STATUS_HEAP_CORRUPTION crashes. + { + std::lock_guard lock(cb_mtx_); + message_callback_ = nullptr; message_callback1_ = nullptr; connection_failure_callback_ = nullptr; + } - if (client_) - client_.reset(); + connected_.store(false, std::memory_order_release); + is_reconnecting.store(false, std::memory_order_release); + + // 2. Wait briefly for the detached reconnect-check threads to finish so + // they do not call Disconnect() on a client being destroyed. + int timeout_count = 0; + const int max_timeout = 20; // max 2s (20 * 100ms) + while (pending_reconnect_checks.load(std::memory_order_acquire) > 0 && timeout_count < max_timeout) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + timeout_count++; + } + + if (timeout_count >= max_timeout) { + BOOST_LOG_TRIVIAL(warning) << "[MQTT_INFO] timeout waiting for reconnect checks, forcing destruction"; + } - cleanup_temp_files(); - BOOST_LOG_TRIVIAL(info) << "[MQTT_INFO] MQTT client resources freed"; + // 3. Disconnect: clears shouldBeConnected in the C lib, stopping the + // auto-reconnect cycle before the client is destroyed. + if (client_) { + // Reuse Disconnect: soft-catches already-disconnected and clears shouldBeConnected. + std::string dc_msg; + Disconnect(dc_msg); } + + // 4. Destroy the async client. ~async_client calls MQTTAsync_destroy(), + // which stops the Paho send/receive threads; doing this BEFORE our own + // members are destroyed (end of destructor) closes the window in which + // a Paho thread could call back into this object after teardown. + client_.reset(); + + { + std::lock_guard lock(topics_mtx_); + topics_to_resubscribe_.clear(); + } + + cleanup_temp_files(); } void MqttClient::cleanup_temp_files() diff --git a/src/slic3r/Utils/MQTT.hpp b/src/slic3r/Utils/MQTT.hpp index f549d666197e..e6bf87fcafbb 100644 --- a/src/slic3r/Utils/MQTT.hpp +++ b/src/slic3r/Utils/MQTT.hpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include // Number of retries for connection and subscription attempts @@ -70,6 +72,19 @@ class MqttClient : public mqtt::callback, const std::string& password = "", bool clean_session = false); + // Factory: the supported way to create a MqttClient. It caches the + // client's own weak reference (self_) exactly when shared ownership is + // established, so Paho callbacks can arm the reconnect checker from the + // cached weak_ptr instead of calling shared_from_this() — which throws + // bad_weak_ptr once the last owner has started destruction (and always + // threw for raw `new`-ed clients). + template + static std::shared_ptr create(Args&&... args) { + std::shared_ptr p(new MqttClient(std::forward(args)...)); + p->self_ = p; + return p; + } + // Destructor ~MqttClient(); @@ -91,9 +106,16 @@ class MqttClient : public mqtt::callback, // Set callback for handling incoming messages void SetMessageCallback(std::function callback); void SetMessageCallback(std::function callback); + // Resolves ambiguity of SetMessageCallback(nullptr) between the two overloads above + void SetMessageCallback(std::nullptr_t) { + std::lock_guard lock(cb_mtx_); + message_callback_ = nullptr; + message_callback1_ = nullptr; + } // add set connect callback void SetConnectionFailureCallback(std::function callback) { + std::lock_guard lock(cb_mtx_); connection_failure_callback_ = callback; } @@ -115,20 +137,32 @@ class MqttClient : public mqtt::callback, std::string server_address_; // MQTT broker address std::string client_id_; // Unique client identifier std::unique_ptr client_; // Async MQTT client instance + // Guards message_callback_ / message_callback1_ / connection_failure_callback_, + // which are read on the Paho callback threads and written from owner threads + // (including being nulled at the start of ~MqttClient). + mutable std::mutex cb_mtx_; std::function message_callback_; // Message handler std::function message_callback1_; // Message handler mqtt::connect_options connOpts_; // Connection options std::atomic connected_; // Connection status flag + mutable std::mutex topics_mtx_; // Guards topics_to_resubscribe_ std::map topics_to_resubscribe_; // Topics to resubscribe after reconnection action_listener subListener_; // Subscription listener int connect_retry_time_; // Connection retry counter int subscribe_retry_time_; // Subscription retry counter std::function connection_failure_callback_; - std::atomic is_reconnecting; - std::atomic pending_reconnect_checks; - std::atomic ever_connected_; + std::atomic is_reconnecting; + std::atomic pending_reconnect_checks; + std::atomic ever_connected_; + // Set as the very first step of ~MqttClient. Paho callbacks (esp. + // connection_lost) check it before touching any other member. + std::atomic tearing_down_{false}; + // Cached by create() while the client is owned; connection_lost() reads + // this instead of calling shared_from_this() (which throws bad_weak_ptr + // once the last owner has started destruction). + std::weak_ptr self_; // tmp path boost::filesystem::path temp_ca_path_; diff --git a/src/slic3r/Utils/MoonRaker.cpp b/src/slic3r/Utils/MoonRaker.cpp index 9d2ffdfdc945..c69d0f9792e4 100644 --- a/src/slic3r/Utils/MoonRaker.cpp +++ b/src/slic3r/Utils/MoonRaker.cpp @@ -853,6 +853,20 @@ std::string Moonraker_Mqtt::m_notification_topic = "/notification"; std::string Moonraker_Mqtt::m_request_topic = "/request"; std::string Moonraker_Mqtt::m_sn = ""; std::mutex Moonraker_Mqtt::m_sn_mtx; +std::mutex Moonraker_Mqtt::m_client_mtx; +std::mutex Moonraker_Mqtt::m_cbs_mtx; + +std::shared_ptr Moonraker_Mqtt::get_mqtt_client() +{ + std::lock_guard lock(m_client_mtx); + return m_mqtt_client; +} + +std::shared_ptr Moonraker_Mqtt::get_mqtt_client_tls() +{ + std::lock_guard lock(m_client_mtx); + return m_mqtt_client_tls; +} std::string Moonraker_Mqtt::m_auth_topic = "/config/response"; std::string Moonraker_Mqtt::m_auth_req_topic = "/config/request"; nlohmann::json Moonraker_Mqtt::m_auth_info = nlohmann::json::object(); @@ -921,13 +935,40 @@ Moonraker_Mqtt::Moonraker_Mqtt(DynamicPrintConfig* config, bool change_engine) : BOOST_LOG_TRIVIAL(error) << "Error getting local IP: " << e.what(); local_ip = "0.0.0.0"; } + + // Construct the new client first (outside the lock), then swap the + // static pointers under the lock and destroy the old clients OUTSIDE + // it: ~MqttClient waits for the Paho threads, which must never wait on + // m_client_mtx (a message dispatch may publish a new request via + // get_mqtt_client_tls()) — the same invariant connect() / disconnect() + // / set_engine() follow. Destroying under the lock deadlocks and also + // blocks every reader for the full teardown duration. + std::shared_ptr new_client; try { - m_mqtt_client.reset(new MqttClient("mqtt://" + host_info, local_ip, "", "", true)); + new_client = MqttClient::create("mqtt://" + host_info, local_ip, "", "", true); } catch (const std::exception& e) { BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] failed to create MQTT client: " << e.what(); - m_mqtt_client.reset(); } - m_mqtt_client_tls.reset(); + std::shared_ptr old_client; + std::shared_ptr old_client_tls; + { + std::lock_guard lock(m_client_mtx); + old_client = std::move(m_mqtt_client); + old_client_tls = std::move(m_mqtt_client_tls); + m_mqtt_client = new_client; + } + if (old_client) { + old_client->SetMessageCallback(nullptr); + std::string dc_msg; + old_client->Disconnect(dc_msg); + } + if (old_client_tls) { + old_client_tls->SetMessageCallback(nullptr); + std::string dc_msg; + old_client_tls->Disconnect(dc_msg); + } + old_client.reset(); + old_client_tls.reset(); BOOST_LOG_TRIVIAL(error) << "local ip" << local_ip; wcp_loger.add_log("local IP: " + local_ip, false, "", "Moonraker_Mqtt", "error"); } @@ -969,12 +1010,22 @@ bool Moonraker_Mqtt::set_engine(const std::shared_ptr& engine, std:: } BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] new engine connection status OK"; - if (m_mqtt_client_tls) { + // Detach the old engine under the lock, then disconnect/destroy it outside + // the lock: ~MqttClient waits for the Paho threads, and those threads must + // never block on a mutex this function (or its callers) still hold. + std::shared_ptr old_engine; + { + std::lock_guard lock(m_client_mtx); + old_engine = std::move(m_mqtt_client_tls); + m_mqtt_client_tls = engine; + } + + if (old_engine) { BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] checking old engine connection status..."; - if (m_mqtt_client_tls->CheckConnected()) { + if (old_engine->CheckConnected()) { BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] old engine still connected, disconnecting..."; std::string dis_msg = "success"; - bool disconnect_result = m_mqtt_client_tls->Disconnect(dis_msg); + bool disconnect_result = old_engine->Disconnect(dis_msg); if (disconnect_result) { BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] old engine disconnected successfully: " << dis_msg; } else { @@ -983,25 +1034,23 @@ bool Moonraker_Mqtt::set_engine(const std::shared_ptr& engine, std:: } else { BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] old engine already disconnected"; } + old_engine->SetMessageCallback(nullptr); } else { BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] no old engine to disconnect"; } - BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] setting new engine pointer"; - m_mqtt_client_tls = engine; - BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] setting message callback"; - std::weak_ptr weak_tsm = time_sync_manager_; - m_mqtt_client_tls->SetMessageCallback([this, weak_tsm](const std::string& topic, const std::string& payload) { + std::weak_ptr weak_tsm = time_sync_manager_snapshot(); + engine->SetMessageCallback([this, weak_tsm](const std::string& topic, const std::string& payload) { if (weak_tsm.expired()) { BOOST_LOG_TRIVIAL(warning) << "[Moonraker_Mqtt] object destroyed, ignoring MQTTS message"; return; } this->on_mqtt_tls_message_arrived(topic, payload); }); - - if (time_sync_manager_) { - time_sync_manager_->reset(); + + if (auto time_sync_manager = time_sync_manager_snapshot()) { + time_sync_manager->reset(); BOOST_LOG_TRIVIAL(info) << "[Moonraker_Mqtt] time sync state reset"; } @@ -1014,13 +1063,14 @@ bool Moonraker_Mqtt::set_engine(const std::shared_ptr& engine, std:: bool Moonraker_Mqtt::ask_for_tls_info(const nlohmann::json& cn_params) { auto& wcp_loger = GUI::WCP_Logger::getInstance(); - if (!m_mqtt_client) { + std::shared_ptr client = get_mqtt_client(); + if (!client) { return false; } - if(m_mqtt_client->CheckConnected()) { + if (client->CheckConnected()) { std::string dc_msg = ""; - m_mqtt_client->Disconnect(dc_msg); + client->Disconnect(dc_msg); } m_sn_mtx.lock(); @@ -1028,7 +1078,7 @@ bool Moonraker_Mqtt::ask_for_tls_info(const nlohmann::json& cn_params) m_sn_mtx.unlock(); std::string connection_msg = ""; - bool is_connect = m_mqtt_client->Connect(connection_msg); + bool is_connect = client->Connect(connection_msg); if(!is_connect || !cn_params.count("code") || cn_params["code"].get() == "") { return false; @@ -1037,12 +1087,12 @@ bool Moonraker_Mqtt::ask_for_tls_info(const nlohmann::json& cn_params) std::string auth_code = cn_params["code"].get(); std::string sub_msg = "success"; - bool response_subscribed = m_mqtt_client->Subscribe(auth_code + m_auth_topic, 1, sub_msg); + bool response_subscribed = client->Subscribe(auth_code + m_auth_topic, 1, sub_msg); if (!response_subscribed) { return false; } - std::weak_ptr weak_tsm_mqtt = time_sync_manager_; - m_mqtt_client->SetMessageCallback([this, weak_tsm_mqtt](const std::string& topic, const std::string& payload) { + std::weak_ptr weak_tsm_mqtt = time_sync_manager_snapshot(); + client->SetMessageCallback([this, weak_tsm_mqtt](const std::string& topic, const std::string& payload) { if (weak_tsm_mqtt.expired()) { BOOST_LOG_TRIVIAL(warning) << "[Moonraker_Mqtt] object destroyed, ignoring MQTT message"; return; @@ -1055,7 +1105,7 @@ bool Moonraker_Mqtt::ask_for_tls_info(const nlohmann::json& cn_params) body["method"] = "server.request_key"; json params; std::string clientid = ""; - clientid = m_mqtt_client->get_client_id(); + clientid = client->get_client_id(); if(clientid == "0.0.0.0") { return false; } @@ -1103,13 +1153,13 @@ bool Moonraker_Mqtt::ask_for_tls_info(const nlohmann::json& cn_params) return false; } body["id"] = seq_id; - - if (time_sync_manager_) { - time_sync_manager_->addTimeFields(body); + + if (auto time_sync_manager = time_sync_manager_snapshot()) { + time_sync_manager->addTimeFields(body); } std::string pub_msg = ""; - if(!m_mqtt_client->Publish(auth_code + m_auth_req_topic, body.dump(), 1, pub_msg)){ + if(!client->Publish(auth_code + m_auth_req_topic, body.dump(), 1, pub_msg)){ return false; } @@ -1213,23 +1263,30 @@ bool Moonraker_Mqtt::connect(wxString& msg, const nlohmann::json& params) { << "\n - private key present: " << (!m_key.empty() ? "yes" : "no"); wcp_loger.add_log("MQTTS connection parameters: " + m_host + ":" + std::to_string(m_port) + ", client ID: " + m_client_id + ", CA cert present: " + (!m_ca.empty() ? "yes" : "no") + ", client cert present: " + (!m_cert.empty() ? "yes" : "no") + ", private key present: " + (!m_key.empty() ? "yes" : "no"), false, "", "Moonraker_Mqtt", "info"); - // Ensure old connection is disconnected before creating a new one - if (m_mqtt_client) { + // Detach the old clients under the lock, then disconnect/destroy them + // outside it: ~MqttClient waits for the Paho threads to stop, so the lock + // must not be held during teardown (same pattern as disconnect()). + std::shared_ptr old_client; + std::shared_ptr old_client_tls; + { + std::lock_guard lock(m_client_mtx); + old_client = std::move(m_mqtt_client); + old_client_tls = std::move(m_mqtt_client_tls); + } + if (old_client) { BOOST_LOG_TRIVIAL(info) << "[Moonraker_Mqtt] disconnecting old MQTT client"; wcp_loger.add_log("disconnecting old MQTT client", false, "", "Moonraker_Mqtt", "info"); std::string dc_msg = "success"; - m_mqtt_client->Disconnect(dc_msg); - std::this_thread::sleep_for(std::chrono::milliseconds(500)); - m_mqtt_client.reset(); + old_client->Disconnect(dc_msg); + old_client.reset(); } - if (m_mqtt_client_tls) { + if (old_client_tls) { BOOST_LOG_TRIVIAL(info) << "[Moonraker_Mqtt] disconnecting old MQTTS client"; wcp_loger.add_log("disconnecting old MQTTS client", false, "", "Moonraker_Mqtt", "info"); std::string dc_msg = "success"; - m_mqtt_client_tls->Disconnect(dc_msg); - std::this_thread::sleep_for(std::chrono::milliseconds(500)); - m_mqtt_client_tls.reset(); + old_client_tls->Disconnect(dc_msg); + old_client_tls.reset(); } // Create new MQTTS connection @@ -1244,23 +1301,32 @@ bool Moonraker_Mqtt::connect(wxString& msg, const nlohmann::json& params) { std::string mqtts_url = "mqtts://" + host_ip + ":" + std::to_string(m_port); BOOST_LOG_TRIVIAL(info) << "[Moonraker_Mqtt] creating MQTTS client, URL: " << mqtts_url << ", client ID: " << m_client_id; wcp_loger.add_log("creating MQTTS client, URL: " + mqtts_url + ", client ID: " + m_client_id, false, "", "Moonraker_Mqtt", "info"); + std::shared_ptr new_client; try { - m_mqtt_client_tls.reset(new MqttClient(mqtts_url, m_client_id, m_ca, m_cert, m_key)); + new_client = MqttClient::create(mqtts_url, m_client_id, m_ca, m_cert, m_key); } catch (const std::exception& e) { BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] failed to create MQTTS client: " << e.what(); wcp_loger.add_log("failed to create MQTTS client: " + std::string(e.what()), false, "", "Moonraker_Mqtt", "error"); - m_mqtt_client_tls.reset(); return false; } - if (!m_mqtt_client_tls) { + if (!new_client) { BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] failed to create MQTT client"; wcp_loger.add_log("failed to create MQTT client", false, "", "Moonraker_Mqtt", "error"); return false; } + // Publish the new client under the lock before using it. Concurrent + // readers (send_to_request, subscribe/unsubscribe, disconnect) take their + // own snapshot, so they either see the old client (still alive) or this + // one — never a half-destroyed pointer. + { + std::lock_guard lock(m_client_mtx); + m_mqtt_client_tls = new_client; + } + std::string connection_msg = ""; - bool is_connect = m_mqtt_client_tls->Connect(connection_msg); + bool is_connect = new_client->Connect(connection_msg); msg = connection_msg; if (!is_connect) { BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] MQTT connection failed"; @@ -1271,8 +1337,8 @@ bool Moonraker_Mqtt::connect(wxString& msg, const nlohmann::json& params) { wcp_loger.add_log("MQTTS connected successfully", false, "", "Moonraker_Mqtt", "info"); // Reset time sync state - if (time_sync_manager_) { - time_sync_manager_->reset(); + if (auto time_sync_manager = time_sync_manager_snapshot()) { + time_sync_manager->reset(); } m_sn_mtx.lock(); @@ -1288,16 +1354,16 @@ bool Moonraker_Mqtt::connect(wxString& msg, const nlohmann::json& params) { std::this_thread::sleep_for(std::chrono::milliseconds(500)); std::string no_sub_msg = "success"; - bool notification_subscribed = m_mqtt_client_tls->Subscribe(tmp_sn + m_notification_topic, 1, no_sub_msg); + bool notification_subscribed = new_client->Subscribe(tmp_sn + m_notification_topic, 1, no_sub_msg); std::string res_sub_msg = "success"; - bool response_subscribed = m_mqtt_client_tls->Subscribe(tmp_sn + m_response_topic, 1, res_sub_msg); + bool response_subscribed = new_client->Subscribe(tmp_sn + m_response_topic, 1, res_sub_msg); BOOST_LOG_TRIVIAL(warning) << "[Moonraker_Mqtt] topic subscription result - notification topic: " << (notification_subscribed ? "success" : "failed") << ", response topic: " << (response_subscribed ? "success" : "failed"); wcp_loger.add_log("topic subscription result - notification topic: " + std::string((notification_subscribed ? "success" : "failed")) + ", response topic: " + (response_subscribed ? "success" : "failed"), false, "", "Moonraker_Mqtt", "info"); - std::weak_ptr weak_tsm = time_sync_manager_; - m_mqtt_client_tls->SetMessageCallback([this, weak_tsm](const std::string& topic, const std::string& payload) { + std::weak_ptr weak_tsm = time_sync_manager_snapshot(); + new_client->SetMessageCallback([this, weak_tsm](const std::string& topic, const std::string& payload) { if (weak_tsm.expired()) { BOOST_LOG_TRIVIAL(warning) << "[Moonraker_Mqtt] object destroyed, ignoring MQTTS message"; return; @@ -1316,22 +1382,33 @@ bool Moonraker_Mqtt::disconnect(wxString& msg, const nlohmann::json& params) { auto& wcp_loger = GUI::WCP_Logger::getInstance(); BOOST_LOG_TRIVIAL(info) << "[Moonraker_Mqtt] starting MQTT disconnect"; wcp_loger.add_log("starting MQTT disconnect", false, "", "Moonraker_Mqtt", "info"); - if (!m_mqtt_client_tls) { + + // Take a snapshot of the client: the shared_ptr keeps it alive for the + // whole teardown even if another thread disconnects/connects concurrently. + // This function can be entered from several threads at once (UI thread via + // sm_disconnect_current_machine, SSWCP worker thread, ...); previously the + // unsynchronized m_mqtt_client_tls.reset() below raced between them and + // corrupted the heap. + std::shared_ptr client = get_mqtt_client_tls(); + if (!client) { BOOST_LOG_TRIVIAL(info) << "[Moonraker_Mqtt] MQTTS client does not exist, nothing to disconnect"; wcp_loger.add_log("MQTTS client does not exist, nothing to disconnect", false, "", "Moonraker_Mqtt", "info"); return false; } std::string dc_msg = "success"; - bool flag = m_mqtt_client_tls->Disconnect(dc_msg); + bool flag = client->Disconnect(dc_msg); BOOST_LOG_TRIVIAL(info) << "[Moonraker_Mqtt] MQTTS disconnect result: " << (flag ? "success" : "failed"); wcp_loger.add_log("MQTTS disconnect result: " + std::string((flag ? "success" : "failed")), false, "", "Moonraker_Mqtt", "info"); - // Release time_sync_manager_ first so weak_ptr in callbacks expires immediately, - // ensuring no callback thread accesses a destroyed object - time_sync_manager_.reset(); + // Stop delivering messages to this object BEFORE releasing time_sync_manager_: + // already-dispatched handlers read the manager through a locked snapshot + // (safe), but stopping the callback first means no NEW dispatch can start + // once the manager is dropped, and the weak_ptr sentinels above expire. + client->SetMessageCallback(nullptr); if (flag) { + std::lock_guard cbs_lock(m_cbs_mtx); m_status_cbs.clear(); m_notification_cbs.clear(); } @@ -1341,10 +1418,25 @@ bool Moonraker_Mqtt::disconnect(wxString& msg, const nlohmann::json& params) { m_sn_mtx.unlock(); BOOST_LOG_TRIVIAL(info) << "[Moonraker_Mqtt] SN reset"; wcp_loger.add_log("SN reset", false, "", "Moonraker_Mqtt", "info"); - - // Wait for MQTT client cleanup to complete, avoiding memory access issues - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - m_mqtt_client_tls.reset(); + + { + std::lock_guard tsm_lock(m_time_sync_manager_mtx); + time_sync_manager_.reset(); + } + + // Detach the global pointer (only if we still own this client — another + // thread may already have reconnected), then drop our reference OUTSIDE + // the lock. ~MqttClient waits for the Paho send/receive threads to exit, + // and those threads must never wait on m_client_mtx: destroying the client + // while holding the lock could deadlock against a message dispatch that + // publishes a new request. + { + std::lock_guard lock(m_client_mtx); + if (m_mqtt_client_tls == client) { + m_mqtt_client_tls.reset(); + } + } + client.reset(); BOOST_LOG_TRIVIAL(info) << "[Moonraker_Mqtt] MQTTS client reset"; wcp_loger.add_log("MQTTS client reset", false, "", "Moonraker_Mqtt", "info"); return flag; @@ -1357,7 +1449,12 @@ void Moonraker_Mqtt::async_subscribe_machine_info(const std::string& hash, std:: BOOST_LOG_TRIVIAL(info) << "[Moonraker_Mqtt] starting machine status subscription"; wcp_loger.add_log("starting machine status subscription", false, "", "Moonraker_Mqtt", "info"); - if (m_status_cbs.empty()) { + bool need_subscribe; + { + std::lock_guard cbs_lock(m_cbs_mtx); + need_subscribe = m_status_cbs.empty(); + } + if (need_subscribe) { std::string main_layer = "+"; m_sn_mtx.lock(); @@ -1367,8 +1464,9 @@ void Moonraker_Mqtt::async_subscribe_machine_info(const std::string& hash, std:: BOOST_LOG_TRIVIAL(info) << "[Moonraker_Mqtt] using SN topic: " << main_layer; wcp_loger.add_log("using SN topic: " + main_layer, false, "", "Moonraker_Mqtt", "info"); std::string sub_msg = "success"; - bool res_status = m_mqtt_client_tls ? m_mqtt_client_tls->Subscribe(main_layer + m_status_topic, 1, sub_msg) : false; - bool res_notification = m_mqtt_client_tls ? m_mqtt_client_tls->Subscribe(main_layer + m_notification_topic, 1, sub_msg) : false; + std::shared_ptr client = get_mqtt_client_tls(); + bool res_status = client ? client->Subscribe(main_layer + m_status_topic, 1, sub_msg) : false; + bool res_notification = client ? client->Subscribe(main_layer + m_notification_topic, 1, sub_msg) : false; if (!res_status || !res_notification) { BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] failed to subscribe to status topic"; @@ -1381,8 +1479,11 @@ void Moonraker_Mqtt::async_subscribe_machine_info(const std::string& hash, std:: wcp_loger.add_log("successfully subscribed to status topic: " + main_layer + m_status_topic, false, "", "Moonraker_Mqtt", "info"); } - m_status_cbs.insert({hash, callback}); - m_notification_cbs.insert({hash, callback}); + { + std::lock_guard cbs_lock(m_cbs_mtx); + m_status_cbs.insert({hash, callback}); + m_notification_cbs.insert({hash, callback}); + } callback(json::object()); @@ -1493,7 +1594,8 @@ void Moonraker_Mqtt::test_async_wcp_mqtt_moonraker(const nlohmann::json& mqtt_re cb(json::value_t::null); } - if (m_mqtt_client_tls) { + std::shared_ptr mqtt_client_tls = get_mqtt_client_tls(); + if (mqtt_client_tls) { std::string main_layer = "+"; if (wait_for_sn()) { @@ -1512,7 +1614,7 @@ void Moonraker_Mqtt::test_async_wcp_mqtt_moonraker(const nlohmann::json& mqtt_re BOOST_LOG_TRIVIAL(info) << "[Moonraker_Mqtt] publishing test request to topic: " << main_layer + m_request_topic; wcp_loger.add_log("publishing test request to topic: " + main_layer + m_request_topic, false, "", "Moonraker_Mqtt", "info"); std::string pub_msg = "success"; - bool res = m_mqtt_client_tls->Publish(main_layer + m_request_topic, mqtt_request_params.dump(), 1, pub_msg); + bool res = mqtt_client_tls->Publish(main_layer + m_request_topic, mqtt_request_params.dump(), 1, pub_msg); if (!res) { BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] failed to publish test request"; wcp_loger.add_log("failed to publish test request", false, "", "Moonraker_Mqtt", "error"); @@ -1613,13 +1715,19 @@ void Moonraker_Mqtt::async_unsubscribe_machine_info(const std::string& hash, std BOOST_LOG_TRIVIAL(info) << "[Moonraker_Mqtt] Starting unsubscribe machine status"; wcp_loger.add_log("Starting unsubscribe machine status", false, "", "Moonraker_Mqtt", "info"); + bool no_cbs_left; + { + std::lock_guard cbs_lock(m_cbs_mtx); if (m_status_cbs.count(hash)) m_status_cbs.erase(hash); if (m_notification_cbs.count(hash)) m_notification_cbs.erase(hash); - if (m_status_cbs.empty()) { + no_cbs_left = m_status_cbs.empty(); + } + + if (no_cbs_left) { std::string main_layer = "+"; m_sn_mtx.lock(); @@ -1627,7 +1735,8 @@ void Moonraker_Mqtt::async_unsubscribe_machine_info(const std::string& hash, std m_sn_mtx.unlock(); std::string un_sub_msg = "success"; - bool res = m_mqtt_client_tls ? m_mqtt_client_tls->Unsubscribe(main_layer + m_status_topic, un_sub_msg) : false; + std::shared_ptr client = get_mqtt_client_tls(); + bool res = client ? client->Unsubscribe(main_layer + m_status_topic, un_sub_msg) : false; if (!res) { BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] failed to unsubscribe from status topic"; @@ -2678,9 +2787,9 @@ bool Moonraker_Mqtt::send_to_request( body["id"] = seq_id; } - if (m_mqtt_client_tls) { + std::shared_ptr mqtt_client_tls = get_mqtt_client_tls(); + if (mqtt_client_tls) { std::string main_layer = "+"; - m_sn_mtx.lock(); main_layer = m_sn; m_sn_mtx.unlock(); @@ -2697,12 +2806,12 @@ bool Moonraker_Mqtt::send_to_request( wcp_loger.add_log("publishing to topic: " + topic, false, "", "Moonraker_Mqtt", "info"); // add time sync fields - if (time_sync_manager_) { - time_sync_manager_->addTimeFields(body); + if (auto time_sync_manager = time_sync_manager_snapshot()) { + time_sync_manager->addTimeFields(body); } std::string pub_msg = "success"; - bool res = m_mqtt_client_tls->Publish(topic, body.dump(), 1, pub_msg); + bool res = mqtt_client_tls->Publish(topic, body.dump(), 1, pub_msg); if (!res) { BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] failed to publish request, method: " << method; wcp_loger.add_log("failed to publish request, method: " + method, false, "", "Moonraker_Mqtt", "error"); @@ -2831,10 +2940,16 @@ void Moonraker_Mqtt::on_mqtt_message_arrived(const std::string& topic, const std // Handle auth messages void Moonraker_Mqtt::on_auth_arrived(const std::string& payload) { - json body = json::parse(payload); + // Non-throwing parse (allow_exceptions=false): a parse error must not + // escape into Paho's C callback stack (uncaught exception = terminate). + json body = json::parse(payload, nullptr, false); + if (body.is_discarded()) { + BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] malformed JSON in auth message, ignoring"; + return; + } - if (time_sync_manager_) { - time_sync_manager_->updateFromResponse(body); + if (auto time_sync_manager = time_sync_manager_snapshot()) { + time_sync_manager->updateFromResponse(body); } if (!body.count("id")) { @@ -2856,10 +2971,16 @@ void Moonraker_Mqtt::on_auth_arrived(const std::string& payload) { // Handle response messages void Moonraker_Mqtt::on_response_arrived(const std::string& payload) { - json body = json::parse(payload); + // Non-throwing parse: a parse error must not escape into Paho's C + // callback stack (uncaught exception = terminate). + json body = json::parse(payload, nullptr, false); + if (body.is_discarded()) { + BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] malformed JSON in response message, ignoring"; + return; + } - if (time_sync_manager_) { - time_sync_manager_->updateFromResponse(body); + if (auto time_sync_manager = time_sync_manager_snapshot()) { + time_sync_manager->updateFromResponse(body); } if (!body.count("id")) { @@ -2903,7 +3024,13 @@ void Moonraker_Mqtt::on_status_arrived(const std::string& payload) auto& wcp_loger = GUI::WCP_Logger::getInstance(); wcp_loger.add_log("handling status update message", false, "", "Moonraker_Mqtt", "info"); { - json body = json::parse(payload); + // Non-throwing parse: a parse error must not escape into Paho's C + // callback stack (uncaught exception = terminate). + json body = json::parse(payload, nullptr, false); + if (body.is_discarded()) { + BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] malformed JSON in status message, ignoring"; + return; + } json data; if (body.count("params")) { @@ -2924,14 +3051,23 @@ void Moonraker_Mqtt::on_status_arrived(const std::string& payload) wcp_loger.add_log("status update contains method: " + body["method"].get(), false, "", "Moonraker_Mqtt", "info"); } - if (m_status_cbs.empty()) { + // Snapshot the callbacks under m_cbs_mtx and invoke the copies: + // disconnect()/unsubscribe run on other threads and clear/erase these + // maps; iterating them directly raced with that and corrupted the heap. + std::unordered_map> cbs; + { + std::lock_guard cbs_lock(m_cbs_mtx); + cbs = m_status_cbs; + } + + if (cbs.empty()) { BOOST_LOG_TRIVIAL(info) << "[Moonraker_Mqtt] status callback not set"; wcp_loger.add_log("status callback not set", false, "", "Moonraker_Mqtt", "info"); return; } wcp_loger.add_log("invoking status callback", false, "", "Moonraker_Mqtt", "info"); - for (const auto& func : m_status_cbs) { + for (const auto& func : cbs) { func.second(data); } @@ -2945,7 +3081,13 @@ void Moonraker_Mqtt::on_notification_arrived(const std::string& payload) wcp_loger.add_log("handling notification message, payload length: " + std::to_string(payload.length()), false, "", "Moonraker_Mqtt", "info"); { // TODO: add msg notice - json body = json::parse(payload); + // Non-throwing parse: a parse error must not escape into Paho's C + // callback stack (uncaught exception = terminate). + json body = json::parse(payload, nullptr, false); + if (body.is_discarded()) { + BOOST_LOG_TRIVIAL(error) << "[Moonraker_Mqtt] malformed JSON in notification message, ignoring"; + return; + } json data; if (body.count("params")) { @@ -2967,11 +3109,18 @@ void Moonraker_Mqtt::on_notification_arrived(const std::string& payload) wcp_loger.add_log("status update contains method: " + body["method"].get(), false, "", "Moonraker_Mqtt", "info"); } - if (m_notification_cbs.empty()) { + // Snapshot the callbacks under m_cbs_mtx and invoke the copies: + // disconnect()/unsubscribe run on other threads and clear/erase these + // maps; iterating them directly raced with that and corrupted the heap. + std::unordered_map> cbs; + { + std::lock_guard cbs_lock(m_cbs_mtx); + cbs = m_notification_cbs; + } + if (cbs.empty()) { return; } - - for (const auto& func : m_notification_cbs) { + for (const auto& func : cbs) { func.second(data); } } @@ -3008,8 +3157,8 @@ void Moonraker_Mqtt::set_connection_lost(std::function callback) { auto& wcp_loger = GUI::WCP_Logger::getInstance(); BOOST_LOG_TRIVIAL(warning) << "[Moonraker_Mqtt] setting connection lost callback"; wcp_loger.add_log("setting connection lost callback", false, "", "Moonraker_Mqtt", "info"); - if (m_mqtt_client_tls) - m_mqtt_client_tls->SetConnectionFailureCallback(callback); + if (std::shared_ptr client = get_mqtt_client_tls()) + client->SetConnectionFailureCallback(callback); } diff --git a/src/slic3r/Utils/MoonRaker.hpp b/src/slic3r/Utils/MoonRaker.hpp index e26c0f54be33..a4083480c910 100644 --- a/src/slic3r/Utils/MoonRaker.hpp +++ b/src/slic3r/Utils/MoonRaker.hpp @@ -2,6 +2,7 @@ #define slic3r_Moonraker_hpp_ #include +#include #include #include #include @@ -160,8 +161,19 @@ class Moonraker : public PrintHost std::string m_cafile; bool m_ssl_revoke_best_effort; - // Time synchronization manager + // Time synchronization manager. + // The member itself is guarded: disconnect() (any thread) resets it while + // Paho receive-thread callbacks and async_* APIs on arbitrary threads + // read it. Always access through time_sync_manager_snapshot(); never + // touch the member directly outside the mutex. std::shared_ptr time_sync_manager_; + mutable std::mutex m_time_sync_manager_mtx; + + std::shared_ptr time_sync_manager_snapshot() const + { + std::lock_guard lock(m_time_sync_manager_mtx); + return time_sync_manager_; + } // Helper methods virtual void set_auth(Http &http) const; @@ -385,6 +397,19 @@ class Moonraker_Mqtt : public Moonraker static std::unordered_map> m_status_cbs; static std::unordered_map> m_notification_cbs; + // Serializes lifecycle changes of the static MQTT clients (connect / + // disconnect / engine swap). Readers must copy the shared_ptr under this + // lock (get_mqtt_client_tls()) and use the copy, so a concurrent + // disconnect can never destroy a client that is still being used. + static std::mutex m_client_mtx; + // Guards m_status_cbs / m_notification_cbs: they are inserted/erased from + // UI threads, iterated on the Paho receive thread and cleared on disconnect. + static std::mutex m_cbs_mtx; + + // Snapshot helpers: return a copy of the static client under the lock. + static std::shared_ptr get_mqtt_client(); + static std::shared_ptr get_mqtt_client_tls(); + // MQTT topics static std::string m_auth_topic; static std::string m_auth_req_topic; diff --git a/src/slic3r/Utils/PresetUpdater.cpp b/src/slic3r/Utils/PresetUpdater.cpp index 0dd81680b49e..c10a09895d47 100644 --- a/src/slic3r/Utils/PresetUpdater.cpp +++ b/src/slic3r/Utils/PresetUpdater.cpp @@ -1583,6 +1583,8 @@ bool PresetUpdater::priv::install_bundles_rsrc(const std::vector& b } } { + // Vendor-shipped deployment data: holds the allow-list sections and sections.filament_order. + // Updates may replace the deployed copy; the running GUI re-reads the order only after a restart. fs::path rules_src = rsrc_path / bundle / "filament" / "filament_allow_list.json"; fs::path rules_dst = vendor_path / bundle / "filament" / "filament_allow_list.json"; if (fs::exists(rules_src)) { @@ -1837,6 +1839,8 @@ Updates PresetUpdater::priv::get_config_updates(const Semver &old_slic3r_version } } { + // Same deployment data as the resource path above: an update may replace the + // deployed copy, and the new order is used only after a restart. fs::path rules_src = cache_profile_path / vendor_name / "filament" / "filament_allow_list.json"; fs::path rules_dst = vendor_path / vendor_name / "filament" / "filament_allow_list.json"; if (fs::exists(rules_src)) { diff --git a/tests/fff_print/test_bed_temperature.cpp b/tests/fff_print/test_bed_temperature.cpp index 7526aea3c8ef..e7f1af075a01 100644 --- a/tests/fff_print/test_bed_temperature.cpp +++ b/tests/fff_print/test_bed_temperature.cpp @@ -52,9 +52,11 @@ TEST_CASE("Mixed print uses the highest bed temperature", "[BedTemperature]") { DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config(); config.set_num_extruders(2); - config.set_num_filaments(2); + // Filament count is data-driven like a loaded preset (Preset.cpp derives it from + // filament_diameter); DynamicPrintConfig::set_num_filaments does not resize these vectors. config.set_deserialize_strict({ { "curr_bed_type", "High Temp Plate" }, + { "filament_diameter", "1.75,1.75" }, // two filaments, preset-style { "hot_plate_temp", "30,60" }, { "hot_plate_temp_initial_layer", "35,65" }, // Wall printed by extruder 1 (0-based 0, low bed temp 35C), infill by extruder 2 (0-based 1, 65C). @@ -73,9 +75,11 @@ TEST_CASE("Brim-introduced extruder is covered when wall_loops is zero", "[BedTe { DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config(); config.set_num_extruders(2); - config.set_num_filaments(2); + // Filament count is data-driven like a loaded preset (Preset.cpp derives it from + // filament_diameter); DynamicPrintConfig::set_num_filaments does not resize these vectors. config.set_deserialize_strict({ { "curr_bed_type", "High Temp Plate" }, + { "filament_diameter", "1.75,1.75" }, // two filaments, preset-style { "hot_plate_temp", "30,60" }, { "hot_plate_temp_initial_layer", "35,65" }, // No walls. Infill uses extruder 1 (0-based 0, 35C); the brim follows wall_filament @@ -98,9 +102,11 @@ TEST_CASE("bed_temperature_initial_layer_single expands to the max", "[BedTemper { DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config(); config.set_num_extruders(2); - config.set_num_filaments(2); + // Filament count is data-driven like a loaded preset (Preset.cpp derives it from + // filament_diameter); DynamicPrintConfig::set_num_filaments does not resize these vectors. config.set_deserialize_strict({ { "curr_bed_type", "High Temp Plate" }, + { "filament_diameter", "1.75,1.75" }, // two filaments, preset-style { "hot_plate_temp", "30,60" }, { "hot_plate_temp_initial_layer", "35,65" }, { "wall_filament", 1 }, diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 6db9a724a1b8..343bdaeb80ee 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -10,6 +10,7 @@ add_executable(${_TEST_NAME}_tests test_custom_gcode.cpp test_elephant_foot_compensation.cpp test_geometry.cpp + test_icns.cpp test_mixed_filament.cpp test_mixed_filament_color_golden.cpp test_triangle_selector.cpp diff --git a/tests/libslic3r/test_icns.cpp b/tests/libslic3r/test_icns.cpp new file mode 100644 index 000000000000..a6562939f1d8 --- /dev/null +++ b/tests/libslic3r/test_icns.cpp @@ -0,0 +1,85 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "libslic3r/Utils.hpp" + +using namespace Slic3r; + +namespace { + +std::vector read_resource_file(const std::string &filename) +{ + std::ifstream ifs(resources_dir() + "/" + filename, std::ios::binary); + REQUIRE(ifs.good()); + return {std::istreambuf_iterator(ifs), std::istreambuf_iterator()}; +} + +uint32_t read_be32(const std::vector &data, size_t offset) +{ + return (uint32_t(data[offset]) << 24) | (uint32_t(data[offset + 1]) << 16) | (uint32_t(data[offset + 2]) << 8) | + uint32_t(data[offset + 3]); +} + +} // namespace + +// Icon.icns is regenerated manually (scripts/regen_app_icons.sh) from a master +// file that is not committed. A previous regeneration silently dropped the +// @2x retina variants (ic11-ic14), which made the icon change size between +// Dock, Cmd-Tab and Finder contexts. Guard the container structure. +TEST_CASE("Icon.icns is a well-formed container with @2x variants", "[resources][icns]") +{ + auto data = read_resource_file("Icon.icns"); + + // 'icns' magic followed by a big-endian container length. + REQUIRE(data.size() >= 8); + REQUIRE(data[0] == 'i'); + REQUIRE(data[1] == 'c'); + REQUIRE(data[2] == 'n'); + REQUIRE(data[3] == 's'); + REQUIRE(read_be32(data, 4) == data.size()); + + // Walk the chunk table: every chunk must be a well-formed (type, length) pair. + bool has_1024px = false; + std::vector chunk_types; + size_t offset = 8; + while (offset + 8 <= data.size()) { + std::string type(reinterpret_cast(data.data()) + offset, 4); + uint32_t len = read_be32(data, offset + 4); + REQUIRE(len >= 8); + REQUIRE(offset + len <= data.size()); + chunk_types.push_back(type); + has_1024px |= type == "ic10"; + offset += len; + } + REQUIRE(offset == data.size()); + + // @2x retina variants, dropped in a previous regeneration. + for (const char *type : {"ic11", "ic12", "ic13", "ic14"}) + REQUIRE(std::find(chunk_types.begin(), chunk_types.end(), type) != chunk_types.end()); + // The 1024px master frame anchors all downscaled variants. + REQUIRE(has_1024px); +} + +// The layered Tahoe icon ships as a prebuilt actool artifact; guard that the +// file exists and still looks like a compiled asset catalog. +TEST_CASE("Assets.car is present and looks like a compiled asset catalog", "[resources][icns]") +{ + auto data = read_resource_file("Assets.car"); + + // Compiled asset catalogs start with the BOM ("Bill of Materials") signature. + REQUIRE(data.size() >= 8); + REQUIRE(data[0] == 'B'); + REQUIRE(data[1] == 'O'); + REQUIRE(data[2] == 'M'); + REQUIRE(data[3] == 'S'); + REQUIRE(data[4] == 't'); + REQUIRE(data[5] == 'o'); + REQUIRE(data[6] == 'r'); + REQUIRE(data[7] == 'e'); +} diff --git a/tests/libslic3r/test_mixed_filament.cpp b/tests/libslic3r/test_mixed_filament.cpp index 3d5ee537f60d..c84715a76a5a 100644 --- a/tests/libslic3r/test_mixed_filament.cpp +++ b/tests/libslic3r/test_mixed_filament.cpp @@ -3,6 +3,7 @@ #include "test_utils.hpp" #include "libslic3r/ExtrusionEntity.hpp" #include "libslic3r/FilamentColorLibrary.hpp" +#include "libslic3r/MixedFilamentConfigRemap.hpp" #include "libslic3r/PresetBundle.hpp" #include "libslic3r/Print.hpp" #include "libslic3r/GCode/ToolOrdering.hpp" @@ -5452,3 +5453,38 @@ TEST_CASE("Full Spectrum default selections normalize the default family argumen } } } + +TEST_CASE("Mixed filament config remap shifts object feature overrides", "[MixedFilament][ConfigRemap]") +{ + // Six physical filaments; deleting old ID 2 shifts old IDs 3..6 to 2..5. + const std::vector id_remap = {0, 1, 0, 2, 3, 4, 5}; + + DynamicPrintConfig global_config = DynamicPrintConfig::full_print_config(); + global_config.set("wall_filament", 5, true); + remap_dynamic_config_feature_filament_ids(global_config, id_remap, 5); + CHECK(global_config.opt_int("wall_filament") == 4); + + ModelConfig object_config; + object_config.set("extruder", 5); + object_config.set("wall_filament", 5); + object_config.set("sparse_infill_filament", 6); + remap_model_config_filament_ids(object_config, id_remap, 5); + CHECK(object_config.extruder() == 4); + CHECK(object_config.opt_int("wall_filament") == 4); + CHECK(object_config.opt_int("sparse_infill_filament") == 5); +} + +TEST_CASE("Mixed filament config remap sends deleted mixed rows to default", "[MixedFilament][ConfigRemap]") +{ + // Old mixed ID 5 was cascade-deleted; old mixed ID 6 survives as new ID 5. + const std::vector id_remap = {0, 1, 2, 3, 0, 0, 5}; + + ModelConfig object_config; + object_config.set("extruder", 5); + object_config.set("wall_filament", 5); + object_config.set("solid_infill_filament", 6); + remap_model_config_filament_ids(object_config, id_remap, 6); + CHECK(object_config.extruder() == 0); + CHECK_FALSE(object_config.has("wall_filament")); + CHECK(object_config.opt_int("solid_infill_filament") == 5); +} diff --git a/tests/libslic3r/test_placeholder_parser.cpp b/tests/libslic3r/test_placeholder_parser.cpp index e08fdbfb2a6e..5e4fcca69906 100644 --- a/tests/libslic3r/test_placeholder_parser.cpp +++ b/tests/libslic3r/test_placeholder_parser.cpp @@ -22,7 +22,7 @@ SCENARIO("Placeholder parser scripting", "[PlaceholderParser]") { config.option("initial_layer_line_width")->percent = false; config.option("support_line_width")->value = 0.675; config.option("support_line_width")->percent = false; - config.option("initial_layer_speed")->value = 50.; + config.option("initial_layer_speed")->values = {50., 50., 50., 50.}; parser.apply_config(config); parser.set("foo", 0); @@ -72,10 +72,10 @@ SCENARIO("Placeholder parser scripting", "[PlaceholderParser]") { SECTION("outer_wall_line_width") { REQUIRE_THAT(std::stod(parser.process("{outer_wall_line_width}")), WithinRel(0.67500001192092896, 0.001)); } SECTION("initial_layer_line_width") { REQUIRE_THAT(std::stod(parser.process("{initial_layer_line_width}")), WithinRel(0.9, 0.001)); } SECTION("support_line_width") { REQUIRE_THAT(std::stod(parser.process("{support_line_width}")), WithinRel(0.67500001192092896, 0.001)); } - // small_perimeter_speed over outer_wall_speed - SECTION("small_perimeter_speed") { REQUIRE_THAT(std::stod(parser.process("{small_perimeter_speed}")), WithinRel(30., 0.001)); } + // coFloatsOrPercents vectors are not addressable by the scalar placeholder engine. + SECTION("small_perimeter_speed") { REQUIRE_THROWS_WITH(parser.process("{small_perimeter_speed[foo]}"), Catch::Matchers::ContainsSubstring("Unsupported vector variable type")); } SECTION("infill_wall_overlap") { REQUIRE_THAT(std::stod(parser.process("{infill_wall_overlap}")), WithinRel(15., 0.001)); } - SECTION("initial_layer_speed") { REQUIRE_THAT(std::stod(parser.process("{initial_layer_speed}")), WithinRel(50., 0.001)); } + SECTION("initial_layer_speed") { REQUIRE_THAT(std::stod(parser.process("{initial_layer_speed[foo]}")), WithinRel(50., 0.001)); } // Test the boolean expression parser. auto boolean_expression = [&parser](const std::string& templ) { return parser.evaluate_boolean_expression(templ, parser.config()); }; diff --git a/tests/libslic3r/test_sswcp_protocol.cpp b/tests/libslic3r/test_sswcp_protocol.cpp index 9a925306eb33..047e83ab1979 100644 --- a/tests/libslic3r/test_sswcp_protocol.cpp +++ b/tests/libslic3r/test_sswcp_protocol.cpp @@ -1,4 +1,4 @@ -#include +#include #include "libslic3r/SSWCPProtocol.hpp" @@ -113,7 +113,8 @@ TEST_CASE("Complete cached slots override direct nozzle data atomically", "[SSWC REQUIRE(SSWCPProtocol::select_complete_cached_nozzle_info( {{"0.4", "standard"}, {"0.4", ""}}, diameters, flows)); CHECK(diameters == std::vector{"0.4", "0.4"}); - CHECK(flows.empty()); + // Incomplete cached flows must not wipe freshly resolved direct values (see SSWCPProtocol.hpp). + CHECK(flows == std::vector{"high_flow"}); } TEST_CASE("tpu_high_flow is never emitted in any protocol output", "[SSWCPProtocol]") diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index c25edbb57914..861ea88cf004 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -15,6 +15,14 @@ find_package(PNG REQUIRED) find_package(ZLIB REQUIRED) find_package(libnoise REQUIRED) find_package(Qhull 7.2 QUIET) + +if (SLIC3R_WX_STABLE) + find_package(wxWidgets 3.0 REQUIRED COMPONENTS base core adv html gl aui net media webview) +else () + find_package(wxWidgets 3.1 REQUIRED COMPONENTS base core adv html gl aui net media webview) +endif () +include(${wxWidgets_USE_FILE}) + if(UNIX AND NOT APPLE) target_link_options(${_TEST_NAME}_tests PRIVATE "-Wl,--no-as-needed") endif() @@ -40,3 +48,53 @@ endif() catch_discover_tests(${_TEST_NAME}_tests TEST_PREFIX "${_TEST_NAME}: " EXTRA_ARGS ${CATCH_EXTRA_ARGS}) # add_test(${_TEST_NAME}_tests ${_TEST_NAME}_tests "${CATCH_EXTRA_ARGS} exclude:[NotWorking]") + +set(_FILAMENT_TEST_NAME filament_dropdown_mapping) +add_executable(${_FILAMENT_TEST_NAME}_tests FilamentDropDown_test.cpp) +target_compile_definitions(${_FILAMENT_TEST_NAME}_tests PRIVATE wxDEBUG_LEVEL=0) +target_include_directories(${_FILAMENT_TEST_NAME}_tests PRIVATE + ${PROJECT_SOURCE_DIR}/src + ${PROJECT_SOURCE_DIR}/src/libslic3r + ${PROJECT_SOURCE_DIR}/deps_src + ${Boost_INCLUDE_DIR} + ${wxWidgets_INCLUDE_DIRS}) +if (WIN32 AND wxWidgets_LIB_DIR AND wxWidgets_CONFIGURATION) + target_include_directories(${_FILAMENT_TEST_NAME}_tests PRIVATE + "${wxWidgets_LIB_DIR}/${wxWidgets_CONFIGURATION}") +endif () +target_link_libraries(${_FILAMENT_TEST_NAME}_tests PRIVATE + Catch2::Catch2WithMain + ${wxWidgets_LIBRARIES} + JPEG::JPEG + PNG::PNG + ZLIB::ZLIB + ${EXPAT_LIBRARIES}) +set_property(TARGET ${_FILAMENT_TEST_NAME}_tests PROPERTY FOLDER "tests") +catch_discover_tests(${_FILAMENT_TEST_NAME}_tests TEST_PREFIX "${_FILAMENT_TEST_NAME}: " EXTRA_ARGS ${CATCH_EXTRA_ARGS}) + +set(_FILAMENT_SORT_TEST_NAME filament_sort) +# The shipped allow-list is a data contract with PresetUpdater (which deploys it) and with the +# loader under test, so the suite reads the real file instead of a copy that could drift from it. +set(_FILAMENT_ALLOW_LIST_FILE ${PROJECT_SOURCE_DIR}/resources/profiles/Snapmaker/filament/filament_allow_list.json) +file(TO_CMAKE_PATH "${_FILAMENT_ALLOW_LIST_FILE}" _FILAMENT_ALLOW_LIST_FILE) +add_executable(${_FILAMENT_SORT_TEST_NAME}_tests + FilamentSort_test.cpp + ${PROJECT_SOURCE_DIR}/src/slic3r/GUI/FilamentSort.cpp) +target_compile_definitions(${_FILAMENT_SORT_TEST_NAME}_tests PRIVATE + wxDEBUG_LEVEL=0 + "FILAMENT_ALLOW_LIST_FILE=R\"(${_FILAMENT_ALLOW_LIST_FILE})\"") +target_include_directories(${_FILAMENT_SORT_TEST_NAME}_tests PRIVATE + ${PROJECT_SOURCE_DIR}/src + ${PROJECT_SOURCE_DIR}/src/libslic3r + ${PROJECT_SOURCE_DIR}/deps_src + ${Boost_INCLUDE_DIR} + ${wxWidgets_INCLUDE_DIRS}) +if (WIN32 AND wxWidgets_LIB_DIR AND wxWidgets_CONFIGURATION) + target_include_directories(${_FILAMENT_SORT_TEST_NAME}_tests PRIVATE + "${wxWidgets_LIB_DIR}/${wxWidgets_CONFIGURATION}") +endif () +target_link_libraries(${_FILAMENT_SORT_TEST_NAME}_tests PRIVATE + Catch2::Catch2WithMain + ${wxWidgets_LIBRARIES}) +set_property(TARGET ${_FILAMENT_SORT_TEST_NAME}_tests PROPERTY FOLDER "tests") +catch_discover_tests(${_FILAMENT_SORT_TEST_NAME}_tests TEST_PREFIX "${_FILAMENT_SORT_TEST_NAME}: " EXTRA_ARGS ${CATCH_EXTRA_ARGS}) diff --git a/tests/slic3rutils/FilamentDropDown_test.cpp b/tests/slic3rutils/FilamentDropDown_test.cpp new file mode 100644 index 000000000000..ba8d59586d6c --- /dev/null +++ b/tests/slic3rutils/FilamentDropDown_test.cpp @@ -0,0 +1,162 @@ +#include + +#include "slic3r/GUI/FilamentDropDown.hpp" + +namespace +{ + +FilamentDropDown::Item make_item(const char *text, const char *group) +{ + FilamentDropDown::Item item; + item.text = wxString::FromUTF8(text); + item.group = wxString::FromUTF8(group); + return item; +} + +wxString strip_prefix(const char *text, const char *group) +{ + return FilamentDropDown::strip_group_prefix(wxString::FromUTF8(text), wxString::FromUTF8(group)); +} + +} // namespace + +TEST_CASE("FilamentDropDown maps grouped rows to item indices", "[GUI][FilamentDropDown]") +{ + std::vector items{ + make_item("PLA A1", "Vendor A"), + make_item("PLA A2", "Vendor A"), + make_item("PLA B1", "Vendor B"), + make_item("Loose", "")}; + + const std::vector rows = + FilamentDropDown::build_visible_rows(items, wxString()); + + REQUIRE(rows.size() == 3); + CHECK(rows[0].item_index == 0); + CHECK(rows[0].group_header); + CHECK(rows[1].item_index == 2); + CHECK(rows[1].group_header); + CHECK(rows[2].item_index == 3); + CHECK_FALSE(rows[2].group_header); + + CHECK(FilamentDropDown::item_index_for_visible_row(rows, 0) == -2); + CHECK(FilamentDropDown::item_index_for_visible_row(rows, 1) == -4); + CHECK(FilamentDropDown::item_index_for_visible_row(rows, 2) == 3); + CHECK(FilamentDropDown::item_index_for_visible_row(rows, -1) == -1); + CHECK(FilamentDropDown::item_index_for_visible_row(rows, 3) == -1); + + CHECK(FilamentDropDown::visible_row_for_item(rows, 0) == 0); + CHECK(FilamentDropDown::visible_row_for_item(rows, 1) == -1); + CHECK(FilamentDropDown::visible_row_for_item(rows, 2) == 1); + CHECK(FilamentDropDown::visible_row_for_item(rows, 3) == 2); + + CHECK(FilamentDropDown::selected_row_for_item(items, wxString(), 0) == 0); + CHECK(FilamentDropDown::selected_row_for_item(items, wxString(), 1) == 0); + CHECK(FilamentDropDown::selected_row_for_item(items, wxString(), 2) == 1); + CHECK(FilamentDropDown::selected_row_for_item(items, wxString(), 3) == 2); +} + +TEST_CASE("FilamentDropDown maps submenu rows without group sentinels", "[GUI][FilamentDropDown]") +{ + std::vector items{ + make_item("PLA A1", "Vendor A"), + make_item("PLA A2", "Vendor A"), + make_item("PLA B1", "Vendor B")}; + + const std::vector rows = + FilamentDropDown::build_visible_rows(items, wxString::FromUTF8("Vendor A")); + + REQUIRE(rows.size() == 2); + CHECK_FALSE(rows[0].group_header); + CHECK_FALSE(rows[1].group_header); + CHECK(FilamentDropDown::item_index_for_visible_row(rows, 0) == 0); + CHECK(FilamentDropDown::item_index_for_visible_row(rows, 1) == 1); + CHECK(FilamentDropDown::visible_row_for_item(rows, 0) == 0); + CHECK(FilamentDropDown::visible_row_for_item(rows, 1) == 1); + CHECK(FilamentDropDown::visible_row_for_item(rows, 2) == -1); + CHECK(FilamentDropDown::selected_row_for_item(items, wxString::FromUTF8("Vendor A"), 0) == 0); + CHECK(FilamentDropDown::selected_row_for_item(items, wxString::FromUTF8("Vendor A"), 1) == 1); + CHECK(FilamentDropDown::selected_row_for_item(items, wxString::FromUTF8("Vendor A"), 2) == -1); +} + +TEST_CASE("FilamentDropDown preserves singleton and folded group selection", "[GUI][FilamentDropDown]") +{ + std::vector items{ + make_item("Single", "Vendor A"), + make_item("First", "Vendor B"), + make_item("Second", "Vendor B")}; + + const std::vector rows = + FilamentDropDown::build_visible_rows(items, wxString()); + + REQUIRE(rows.size() == 2); + CHECK(rows[0].item_index == 0); + CHECK(rows[0].group_header); + CHECK(rows[1].item_index == 1); + CHECK(rows[1].group_header); + CHECK(FilamentDropDown::selected_row_for_item(items, wxString(), 0) == 0); + CHECK(FilamentDropDown::selected_row_for_item(items, wxString(), 1) == 1); + CHECK(FilamentDropDown::selected_row_for_item(items, wxString(), 2) == 1); + + const std::vector submenu_rows = + FilamentDropDown::build_visible_rows(items, wxString::FromUTF8("Vendor B")); + REQUIRE(submenu_rows.size() == 2); + CHECK_FALSE(submenu_rows[0].group_header); + CHECK_FALSE(submenu_rows[1].group_header); + CHECK(FilamentDropDown::selected_row_for_item(items, wxString::FromUTF8("Vendor B"), 2) == 1); +} + +TEST_CASE("FilamentDropDown preserves mapping for rows reached after scrolling", "[GUI][FilamentDropDown]") +{ + std::vector items; + for (int index = 0; index < 20; ++index) + { + items.push_back(make_item("Loose", "")); + } + + const std::vector rows = + FilamentDropDown::build_visible_rows(items, wxString()); + + REQUIRE(rows.size() == items.size()); + const int scrolled_row = 15; + const int item_index = FilamentDropDown::item_index_for_visible_row(rows, scrolled_row); + CHECK(item_index == scrolled_row); + CHECK(FilamentDropDown::visible_row_for_item(rows, item_index) == scrolled_row); + + for (int row = 0; row < static_cast(rows.size()); ++row) + { + const int mapped_item = FilamentDropDown::item_index_for_visible_row(rows, row); + REQUIRE(mapped_item >= 0); + CHECK(FilamentDropDown::visible_row_for_item(rows, mapped_item) == row); + } +} + +TEST_CASE("FilamentDropDown strips whole group prefixes only", "[GUI][FilamentDropDown]") +{ + // Single-word vendor: the vendor prefix is removed. + CHECK(strip_prefix("Snapmaker PLA SnapSpeed", "Snapmaker") == wxString::FromUTF8("PLA SnapSpeed")); + CHECK(strip_prefix("Generic ABS", "Generic") == wxString::FromUTF8("ABS")); + + // Multi-word vendor: the whole vendor name is removed before the first-word fallback. + CHECK(strip_prefix("PolyLite Pro PLA", "PolyLite Pro") == wxString::FromUTF8("PLA")); + + // First-word fallback when the preset name uses only the vendor's leading word. + CHECK(strip_prefix("PolyLite PLA", "PolyLite Pro") == wxString::FromUTF8("PLA")); + + // Word-interior coincidence must not be stripped (group "Prusa Polymers" vs "Prusament ..."). + CHECK(strip_prefix("Prusament PVB @CORE One", "Prusa Polymers") == wxString::FromUTF8("Prusament PVB @CORE One")); + + // The prefix strip ignores case, like the configured order's product matching, so a casing drift + // between the vendor label and the preset name still hides the prefix in the submenu. + CHECK(strip_prefix("snapmaker PLA Matte", "Snapmaker") == wxString::FromUTF8("PLA Matte")); + CHECK(strip_prefix("SNAPMaker ABS", "Snapmaker") == wxString::FromUTF8("ABS")); + CHECK(strip_prefix("prusament PVB @CORE One", "Prusa Polymers") == + wxString::FromUTF8("prusament PVB @CORE One")); + + // Preset names that do not start with the vendor name stay unchanged. + CHECK(strip_prefix("Arena ABS @Arena X1C", "Orca Arena") == wxString::FromUTF8("Arena ABS @Arena X1C")); + + // Project/User pseudo-groups carry a trailing space and never strip. + CHECK(strip_prefix("PLA Basic", "Custom ") == wxString::FromUTF8("PLA Basic")); + CHECK(strip_prefix("PLA Basic", "Project ") == wxString::FromUTF8("PLA Basic")); +} diff --git a/tests/slic3rutils/FilamentSort_test.cpp b/tests/slic3rutils/FilamentSort_test.cpp new file mode 100644 index 000000000000..2b9916673dad --- /dev/null +++ b/tests/slic3rutils/FilamentSort_test.cpp @@ -0,0 +1,315 @@ +#include + +#include "slic3r/GUI/FilamentSort.hpp" + +#include +#include +#include +#include +#include + +using namespace Slic3r::GUI; + +namespace +{ + +FilamentSortItem make_item(const char *display_name, + const char *vendor, + const char *filament_product, + size_t original_index) +{ + FilamentSortItem item; + item.display_name = wxString::FromUTF8(display_name); + item.vendor = vendor; + item.filament_product = filament_product; + item.original_index = original_index; + return item; +} + +FilamentOrder parse_order(const char *json) +{ + std::istringstream stream(json); + return FilamentOrder::from_stream(stream); +} + +} // namespace + +TEST_CASE("FilamentOrder parses valid configuration and matches vendor and product case-insensitively", + "[GUI][FilamentSort]") +{ + const FilamentOrder order = parse_order(R"({ + "schema_version": 1, + "sections": { + "high_flow": { + "not_recommended_filaments": ["PLA Wood"], + "unavailable_filaments": ["TPU 85A"] + }, + "filament_order": { + "Snapmaker": ["PLA Matte", "PLA SnapSpeed"] + } + } + })"); + + REQUIRE_FALSE(order.empty()); + CHECK(order.rank("Snapmaker", "PLA Matte") == 0); + CHECK(order.rank("sNaPmAkEr", "PLA SnapSpeed") == 1); + // Product names are authored in both the preset files and filament_allow_list.json, so a casing drift + // must not drop the entry: they match case-insensitively, like the vendor key. + CHECK(order.rank("Snapmaker", "pla matte") == 0); + CHECK(order.rank("Snapmaker", "Pla sNApsPeed") == 1); + CHECK(order.rank("sNaPmAkEr", "plA mATTe") == 0); + CHECK(order.rank("Generic", "PLA Matte") == std::numeric_limits::max()); + CHECK(order.rank("Snapmaker", "Unknown") == std::numeric_limits::max()); +} + +TEST_CASE("FilamentOrder rejects invalid configurations and supports sort fallback", "[GUI][FilamentSort]") +{ + const std::vector invalid_configs{ + "{", + R"({"schema_version": 1, "order": {"Snapmaker": ["PLA Matte"]}})", + R"({"schema_version": 2, "sections": {"filament_order": {"Snapmaker": ["PLA Matte"]}}})", + R"({"schema_version": 1.0, "sections": {"filament_order": {"Snapmaker": ["PLA Matte"]}}})", + R"({"schema_version": 1, "sections": []})", + R"({"schema_version": 1, "sections": {}})", + R"({"schema_version": 1, "sections": {"filament_order": []}})", + R"({"schema_version": 1, "sections": {"filament_order": {"Snapmaker": []}}})", + R"({"schema_version": 1, "sections": {"filament_order": {"Snapmaker": [3]}}})", + R"({"schema_version": 1, "sections": {"filament_order": {"Snapmaker": [""]}}})", + R"({"schema_version": 1, "sections": {"filament_order": {"Snapmaker": "PLA Matte"}}})", + R"({"schema_version": 1, "sections": {"filament_order": {"": ["PLA Matte"]}}})", + }; + + for (const char *json : invalid_configs) + { + CHECK(parse_order(json).empty()); + } + + const SystemFilamentSorter sorter(parse_order("{")); + const FilamentSortItem first = make_item("Alpha", "Snapmaker", "Unknown", 0); + const FilamentSortItem second = make_item("Beta", "Snapmaker", "Unknown", 1); + CHECK(sorter.less(first, second)); + CHECK_FALSE(sorter.less(second, first)); +} + +TEST_CASE("FilamentSorter orders names by code point and keeps the original order for ties", "[GUI][FilamentSort]") +{ + const FilamentSorter sorter; + const FilamentSortItem alpha = make_item("alpha", "", "", 0); + const FilamentSortItem beta = make_item("Beta", "", "", 1); + const FilamentSortItem first = make_item("PLA", "", "", 1); + const FilamentSortItem second = make_item("pla", "", "", 2); + + // Case-sensitive, like the upstream Bambu comparator: uppercase precedes lowercase, so this pair + // is ordered by code point rather than by the original index. + CHECK(sorter.less(beta, alpha)); + CHECK_FALSE(sorter.less(alpha, beta)); + CHECK(sorter.less(first, second)); + CHECK_FALSE(sorter.less(second, first)); + + // The characters between 'Z' and 'a' sort after uppercase and before lowercase. + const FilamentSortItem underscore = make_item("_", "", "", 3); + const FilamentSortItem upper_a = make_item("A", "", "", 4); + const FilamentSortItem lower_a = make_item("a", "", "", 5); + CHECK(sorter.less(upper_a, underscore)); + CHECK(sorter.less(underscore, lower_a)); + + // Identical names still keep the original order, since std::sort is not stable. + const FilamentSortItem same_first = make_item("PLA", "", "", 3); + const FilamentSortItem same_last = make_item("PLA", "", "", 7); + CHECK(sorter.less(same_first, same_last)); + CHECK_FALSE(sorter.less(same_last, same_first)); +} + +TEST_CASE("SystemFilamentVendorSorter prioritizes Snapmaker and Generic", "[GUI][FilamentSort]") +{ + const SystemFilamentVendorSorter sorter; + + CHECK(sorter.less("Snapmaker", "Generic")); + CHECK(sorter.less("Generic", "Other")); + CHECK(sorter.less("Another", "Other")); + CHECK_FALSE(sorter.less("Other", "Generic")); + CHECK_FALSE(sorter.less("SNAPMAKER", "Snapmaker")); + + // Case-sensitive vendor ordering: only the exact spelling takes the Snapmaker bucket, and + // vendors in the same bucket order by code point (uppercase before lowercase). + CHECK(sorter.less("Snapmaker", "snapmaker")); + CHECK(sorter.less("Zebra", "apple")); + CHECK_FALSE(sorter.less("apple", "Zebra")); +} + +TEST_CASE("SystemFilamentSorter applies configured order only to Snapmaker", "[GUI][FilamentSort]") +{ + const FilamentOrder order = parse_order(R"({ + "schema_version": 1, + "sections": { + "filament_order": { + "Snapmaker": ["PLA Matte", "PLA SnapSpeed"] + } + } + })"); + const SystemFilamentSorter sorter(order); + + const FilamentSortItem ordered_first = make_item("Z First", "Snapmaker", "PLA Matte", 0); + const FilamentSortItem ordered_second = make_item("A Second", "Snapmaker", "PLA SnapSpeed", 1); + const FilamentSortItem unknown = make_item("B Unknown", "Snapmaker", "ABS", 2); + CHECK(sorter.less(ordered_first, ordered_second)); + CHECK(sorter.less(ordered_second, unknown)); + + const FilamentSortItem generic_first = make_item("A Generic", "Generic", "PLA SnapSpeed", 3); + const FilamentSortItem generic_second = make_item("B Generic", "Generic", "PLA Matte", 4); + CHECK(sorter.less(generic_first, generic_second)); +} + +TEST_CASE("FilamentOrder keeps the first rank when a product name repeats", "[GUI][FilamentSort]") +{ + const FilamentOrder order = parse_order(R"({ + "schema_version": 1, + "sections": { + "filament_order": { + "Snapmaker": ["PLA Matte", "PLA Matte", "PLA SnapSpeed"] + } + } + })"); + + REQUIRE_FALSE(order.empty()); + CHECK(order.rank("Snapmaker", "PLA Matte") == 0); + CHECK(order.rank("Snapmaker", "PLA SnapSpeed") == 2); +} + +TEST_CASE("FilamentOrder matches non-ASCII product names", "[GUI][FilamentSort]") +{ + const FilamentOrder order = parse_order(R"({ + "schema_version": 1, + "sections": { + "filament_order": { + "Snapmaker": ["PLA 哑光", "TPU 95A"] + } + } + })"); + + REQUIRE_FALSE(order.empty()); + CHECK(order.rank("Snapmaker", "PLA 哑光") == 0); + CHECK(order.rank("Snapmaker", "TPU 95A") == 1); + CHECK(order.rank("Snapmaker", "PLA 亮光") == std::numeric_limits::max()); +} + +TEST_CASE("filament_product_key strips the vendor prefix and the printer suffix", "[GUI][FilamentSort]") +{ + CHECK(filament_product_key("Snapmaker PLA SnapSpeed", "Snapmaker") == "PLA SnapSpeed"); + + // The strip ignores case, like the configured order's product matching: a casing drift between the + // vendor label and the preset name must still map to the ordered entry. + CHECK(filament_product_key("snapmaker PLA Matte", "Snapmaker") == "PLA Matte"); + CHECK(filament_product_key("SNAPMaker ABS", "Snapmaker") == "ABS"); + + // Printer variants share one product entry. + CHECK(filament_product_key("PLA Matte @BBL X1C", "Snapmaker") == "PLA Matte"); + + // A word-interior coincidence is not a prefix (vendor "Prusa Polymers" against "Prusament ..."). + CHECK(filament_product_key("Prusament PVB @CORE One", "Prusa Polymers") == "Prusament PVB"); + + // An unset vendor, or the schema placeholder, falls back to the preset name's leading word. + CHECK(filament_product_key("Generic ABS", "") == "ABS"); + CHECK(filament_product_key("Generic ABS", "(Undefined)") == "ABS"); + CHECK(filament_product_key("SingleWord", "") == "SingleWord"); +} + +TEST_CASE("choose_allow_list_copy prefers the deployed user copy", "[GUI][FilamentSort]") +{ + const std::filesystem::path user_copy = "system/Snapmaker/filament/filament_allow_list.json"; + const std::filesystem::path shipped_copy = "resources/profiles/Snapmaker/filament/filament_allow_list.json"; + + CHECK(choose_allow_list_copy(user_copy, shipped_copy, true) == user_copy); + // A missing user copy falls back to the shipped one instead of leaving the order unconfigured. + CHECK(choose_allow_list_copy(user_copy, shipped_copy, false) == shipped_copy); +} + +TEST_CASE("is_snapmaker_vendor matches the vendor label case-insensitively", "[GUI][FilamentSort]") +{ + // Preset files spell the vendor inconsistently, so the TopN gate must not depend on casing. + CHECK(is_snapmaker_vendor("Snapmaker")); + CHECK(is_snapmaker_vendor("SNAPMAKER")); + CHECK(is_snapmaker_vendor("sNaPmAkEr")); + + // Only a whole-label match counts: neighbouring vendors must not take the Snapmaker path. + CHECK_FALSE(is_snapmaker_vendor("Generic")); + CHECK_FALSE(is_snapmaker_vendor("Snapmaker Lab")); + CHECK_FALSE(is_snapmaker_vendor("Bambu Lab")); + CHECK_FALSE(is_snapmaker_vendor("")); +} + +TEST_CASE("canonical_vendor normalizes the known system vendors and passes others through", "[GUI][FilamentSort]") +{ + CHECK(canonical_vendor("snapmaker") == "Snapmaker"); + CHECK(canonical_vendor("SNAPMAKER") == "Snapmaker"); + CHECK(canonical_vendor("Snapmaker") == "Snapmaker"); + CHECK(canonical_vendor("generic") == "Generic"); + CHECK(canonical_vendor("GENERIC") == "Generic"); + + // Unknown vendors keep their spelling; the empty label stays empty rather than becoming a vendor. + CHECK(canonical_vendor("Bambu Lab") == "Bambu Lab"); + CHECK(canonical_vendor("Snapmaker Lab") == "Snapmaker Lab"); + CHECK(canonical_vendor("") == ""); + + // System rows are grouped by the canonical label, so the TopN gate must agree with it. + CHECK(is_snapmaker_vendor(canonical_vendor("snapmaker"))); + CHECK_FALSE(is_snapmaker_vendor(canonical_vendor("generic"))); +} + +TEST_CASE("FilamentOrder::from_file reads a configuration file and reports unusable ones as empty", + "[GUI][FilamentSort]") +{ + const std::filesystem::path path = std::filesystem::temp_directory_path() / "filament_order_from_file_test.json"; + + SECTION("valid configuration") + { + std::ofstream stream(path); + REQUIRE(stream.good()); + stream << R"({"schema_version": 1, "sections": {"filament_order": {"Snapmaker": ["PLA Matte"]}}})"; + // Close before reading back: the write is not on disk while the stream still buffers it. + stream.close(); + + const FilamentOrder order = FilamentOrder::from_file(path); + CHECK_FALSE(order.empty()); + CHECK(order.rank("Snapmaker", "PLA Matte") == 0); + } + + SECTION("missing file") + { + std::filesystem::remove(path); + std::string reason; + CHECK(FilamentOrder::from_file(path, &reason).empty()); + // The loader logs this reason, so a file that is not there stays distinguishable from one + // that is there but rejected. + CHECK(reason == "cannot be opened"); + } + + SECTION("malformed file") + { + std::ofstream stream(path); + REQUIRE(stream.good()); + stream << "{"; + stream.close(); + + std::string reason; + CHECK(FilamentOrder::from_file(path, &reason).empty()); + CHECK(reason == "has an invalid or empty configuration"); + } + + std::filesystem::remove(path); +} + +TEST_CASE("the shipped allow-list parses and orders the Snapmaker products", "[GUI][FilamentSort]") +{ + // The shipped file is a data contract with PresetUpdater, which deploys it to the user data + // directory. A typo in it (schema version, vendor key, syntax) would otherwise drop the whole + // vendor order at runtime without any visible error. + std::string reason; + const FilamentOrder order = FilamentOrder::from_file(FILAMENT_ALLOW_LIST_FILE, &reason); + INFO("loader reason: " << reason); + REQUIRE_FALSE(order.empty()); + + // A shipped product proves the vendor key matched; an unknown one proves the name fallback. + CHECK(order.rank("Snapmaker", "PLA SnapSpeed") != std::numeric_limits::max()); + CHECK(order.rank("Snapmaker", "Not A Shipped Product") == std::numeric_limits::max()); +} diff --git a/tests/slic3rutils/SnapLogClient_test.cpp b/tests/slic3rutils/SnapLogClient_test.cpp index dc625cb679ef..0d64bf7c9e8c 100644 --- a/tests/slic3rutils/SnapLogClient_test.cpp +++ b/tests/slic3rutils/SnapLogClient_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -47,9 +48,9 @@ TEST_CASE("hmac_sha256_hex empty msg", "[snaplog]") TEST_CASE("level_str wire strings", "[snaplog]") { - REQUIRE(level_str(SnapLogLevel::Info) == "INFO"); - REQUIRE(level_str(SnapLogLevel::Warning) == "WARN"); - REQUIRE(level_str(SnapLogLevel::Error) == "ERROR"); + REQUIRE(std::string_view(level_str(SnapLogLevel::Info)) == "INFO"); + REQUIRE(std::string_view(level_str(SnapLogLevel::Warning)) == "WARN"); + REQUIRE(std::string_view(level_str(SnapLogLevel::Error)) == "ERROR"); } TEST_CASE("normalize_logger takes last segment", "[snaplog]")