diff --git a/packages/media/cpp/CMakeLists.txt b/packages/media/cpp/CMakeLists.txt index 49f860f5..19a0373c 100644 --- a/packages/media/cpp/CMakeLists.txt +++ b/packages/media/cpp/CMakeLists.txt @@ -144,6 +144,9 @@ add_executable(media-img src/http/serve.cpp src/ipc/ipc_serve.cpp ) +if(WIN32) + target_sources(media-img PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/win/register_explorer.cpp") +endif() target_include_directories(media-img PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src diff --git a/packages/media/cpp/README.md b/packages/media/cpp/README.md index 99f5155e..b6b96e19 100644 --- a/packages/media/cpp/README.md +++ b/packages/media/cpp/README.md @@ -50,6 +50,36 @@ cmake --build --preset release Binary: **`dist/media-img`** (`.exe` on Windows). +### Windows installer (NSIS) + +From `packages/media/cpp`, after a release build: + +```bash +npm run build:installer +``` + +Produces **`dist/media-img-Setup.exe`**, installs `media-img.exe`, libvips DLLs, `vips-modules-8.18`, and `scripts/explorer-resize.ps1`, and prepends the install directory to the **user** `PATH`. Uninstaller is registered under Add/Remove Programs. + +### Explorer context menu (`media-img register-explorer`) + +Implemented in **C++** (Windows registry). The NSIS installer runs `media-img.exe register-explorer` after copying files; uninstall runs `register-explorer --unregister --no-refresh-shell`. + +```bash +media-img register-explorer +media-img register-explorer --dry +media-img register-explorer --unregister +``` + +Registers a **PM-Media** cascading menu on image extensions (including **`.avif`**, **`.arw`**, **`.webp`**, TIFF, etc.), on **folders**, and on the **folder background** (empty area). **Resize** presets (default widths **1980, 1200, 800, 400**): **in place** or **copy** as `${SRC_NAME}_${width}${SRC_EXT}`. **Convert to JPG** writes `${SRC_NAME}_converted.jpg` via `explorer-convert.ps1`. Override widths with `--widths 1920,800`. + +Under `SystemFileAssociations`, each extension is registered as **`.ext`**, **`.EXT`**, and **`.Ext`** so Explorer picks up the menu regardless of filename casing. + +Defaults: **`--media-bin`** = this executable; **`--explorer-script`** / **`--explorer-convert-script`** = `\\scripts\\explorer-*.ps1`, or `packages/media/scripts\\…` from a dev `cpp\\dist` build. + +`pm-media register-explorer` forwards argv to `media-img.exe` for convenience. + +Context menus run `powershell.exe -WindowStyle Hidden`; re-run `media-img register-explorer` after updates so registry command lines refresh. + ## Formats — same idea as Sharp / libvips Sharp wraps libvips: **decode → process → encode**. We do the same with `vips_image_new_from_file` and format-specific savers. diff --git a/packages/media/cpp/dist/media-img-Setup.exe b/packages/media/cpp/dist/media-img-Setup.exe new file mode 100644 index 00000000..6a9fd3db Binary files /dev/null and b/packages/media/cpp/dist/media-img-Setup.exe differ diff --git a/packages/media/cpp/dist/media-img.exe b/packages/media/cpp/dist/media-img.exe index 63780790..5864d7d0 100644 Binary files a/packages/media/cpp/dist/media-img.exe and b/packages/media/cpp/dist/media-img.exe differ diff --git a/packages/media/cpp/dist/media-img.pdb b/packages/media/cpp/dist/media-img.pdb index 0057f5d7..3c90ab89 100644 Binary files a/packages/media/cpp/dist/media-img.pdb and b/packages/media/cpp/dist/media-img.pdb differ diff --git a/packages/media/cpp/dist/url-test_400.png b/packages/media/cpp/dist/url-test_400.png new file mode 100644 index 00000000..8ff1486a Binary files /dev/null and b/packages/media/cpp/dist/url-test_400.png differ diff --git a/packages/media/cpp/installer.nsi b/packages/media/cpp/installer.nsi new file mode 100644 index 00000000..c2e607aa --- /dev/null +++ b/packages/media/cpp/installer.nsi @@ -0,0 +1,274 @@ +; ============================================================================ +; PolyMech media-img — NSIS installer +; ============================================================================ +; From packages/media/cpp: +; npm run build:installer +; Output: dist\media-img-Setup.exe +; Requires: NSIS 3+, CMake release build (dist\media-img.exe + vips DLLs) +; ============================================================================ + +!include "MUI2.nsh" +!include "FileFunc.nsh" +!include "WinMessages.nsh" + +; --------------------------------------------------------------------------- +; Self-contained string helpers (PATH edit) +; --------------------------------------------------------------------------- + +!macro _StrContainsConstructor un + Function ${un}StrContains + Exch $R1 ; SubString + Exch 1 + Exch $R0 ; String + Push $R2 + Push $R3 + Push $R4 + StrLen $R2 $R0 + StrLen $R3 $R1 + StrCpy $R4 0 + loop: + IntCmp $R4 $R2 notfound 0 notfound + StrCpy $R5 $R0 $R3 $R4 + StrCmp $R5 $R1 found + IntOp $R4 $R4 + 1 + Goto loop + found: + StrCpy $R0 $R1 + Goto done + notfound: + StrCpy $R0 "" + done: + Pop $R4 + Pop $R3 + Pop $R2 + Pop $R1 + Exch $R0 + FunctionEnd +!macroend + +!insertmacro _StrContainsConstructor "" + +!macro StrContains ResultVar String SubString + Push `${String}` + Push `${SubString}` + Call StrContains + Pop `${ResultVar}` +!macroend + +!macro _StrReplaceConstructor un + Function ${un}StrReplace + Exch $R2 ; New + Exch 1 + Exch $R1 ; Old + Exch 2 + Exch $R0 ; String + Push $R3 + Push $R4 + Push $R5 + Push $R6 + Push $R7 + Push $R8 + Push $R9 + StrCpy $R3 0 + StrLen $R4 $R1 + StrLen $R6 $R0 + StrLen $R9 $R2 + loop: + StrCpy $R5 $R0 $R4 $R3 + StrCmp $R5 $R1 found + StrCmp $R3 $R6 done + IntOp $R3 $R3 + 1 + Goto loop + found: + StrCpy $R5 $R0 $R3 + IntOp $R8 $R3 + $R4 + StrCpy $R7 $R0 "" $R8 + StrCpy $R0 "$R5$R2$R7" + StrLen $R6 $R0 + IntOp $R3 $R3 + $R9 + Goto loop + done: + Pop $R9 + Pop $R8 + Pop $R7 + Pop $R6 + Pop $R5 + Pop $R4 + Pop $R3 + Push $R0 + Exch 3 + Pop $R1 + Pop $R0 + Pop $R2 + FunctionEnd +!macroend + +!insertmacro _StrReplaceConstructor "" +!insertmacro _StrReplaceConstructor "un." + +!macro StrReplace ResultVar String Old New + Push `${String}` + Push `${Old}` + Push `${New}` + !ifdef __UNINSTALL__ + Call un.StrReplace + !else + Call StrReplace + !endif + Pop `${ResultVar}` +!macroend + +; --------------------------------------------------------------------------- +; Metadata +; --------------------------------------------------------------------------- +!define PRODUCT_NAME "PolyMech media-img" +!define PRODUCT_VERSION "0.1.0" +!define PRODUCT_PUBLISHER "PolyMech" +!define PRODUCT_WEB_SITE "https://git.polymech.info/mono" +!define PRODUCT_EXE "media-img.exe" +!define PRODUCT_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" +!define ENV_KEY "Environment" + +Name "${PRODUCT_NAME} ${PRODUCT_VERSION}" +OutFile "dist\media-img-Setup.exe" +InstallDir "" +RequestExecutionLevel user + +Var IsAdmin + +!define MUI_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install.ico" +!define MUI_UNICON "${NSISDIR}\Contrib\Graphics\Icons\modern-uninstall.ico" +!define MUI_ABORTWARNING + +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH + +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES + +!insertmacro MUI_LANGUAGE "English" + +Function .onInit + UserInfo::GetAccountType + Pop $0 + StrCmp $0 "Admin" 0 not_admin + StrCpy $IsAdmin "1" + StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCT_NAME}" + Goto init_done + not_admin: + StrCpy $IsAdmin "0" + StrCpy $INSTDIR "$LOCALAPPDATA\${PRODUCT_NAME}" + init_done: +FunctionEnd + +Section "Install" + SetOutPath "$INSTDIR" + + File "dist\${PRODUCT_EXE}" + File /nonfatal "dist\*.dll" + + ; libvips optional modules (HEIF, etc.) — copied next to media-img by CMake + SetOutPath "$INSTDIR\vips-modules-8.18" + File /r "dist\vips-modules-8.18\*.*" + + SetOutPath "$INSTDIR\scripts" + File "..\scripts\explorer-resize.ps1" + File "..\scripts\explorer-convert.ps1" + + SetOutPath "$INSTDIR" + WriteUninstaller "$INSTDIR\Uninstall.exe" + + CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}" + CreateShortcut "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk" "$INSTDIR\${PRODUCT_EXE}" "--help" + CreateShortcut "$SMPROGRAMS\${PRODUCT_NAME}\Uninstall.lnk" "$INSTDIR\Uninstall.exe" + + StrCmp $IsAdmin "1" 0 reg_user + WriteRegStr HKLM "${PRODUCT_UNINST_KEY}" "DisplayName" "${PRODUCT_NAME}" + WriteRegStr HKLM "${PRODUCT_UNINST_KEY}" "DisplayVersion" "${PRODUCT_VERSION}" + WriteRegStr HKLM "${PRODUCT_UNINST_KEY}" "Publisher" "${PRODUCT_PUBLISHER}" + WriteRegStr HKLM "${PRODUCT_UNINST_KEY}" "URLInfoAbout" "${PRODUCT_WEB_SITE}" + WriteRegStr HKLM "${PRODUCT_UNINST_KEY}" "UninstallString" "$INSTDIR\Uninstall.exe" + WriteRegStr HKLM "${PRODUCT_UNINST_KEY}" "InstallLocation" "$INSTDIR" + WriteRegDWORD HKLM "${PRODUCT_UNINST_KEY}" "NoModify" 1 + WriteRegDWORD HKLM "${PRODUCT_UNINST_KEY}" "NoRepair" 1 + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKLM "${PRODUCT_UNINST_KEY}" "EstimatedSize" $0 + WriteRegStr HKLM "${PRODUCT_UNINST_KEY}" "InstallMode" "AllUsers" + Goto reg_done + reg_user: + WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "DisplayName" "${PRODUCT_NAME}" + WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "DisplayVersion" "${PRODUCT_VERSION}" + WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "Publisher" "${PRODUCT_PUBLISHER}" + WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "URLInfoAbout" "${PRODUCT_WEB_SITE}" + WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "UninstallString" "$INSTDIR\Uninstall.exe" + WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "InstallLocation" "$INSTDIR" + WriteRegDWORD HKCU "${PRODUCT_UNINST_KEY}" "NoModify" 1 + WriteRegDWORD HKCU "${PRODUCT_UNINST_KEY}" "NoRepair" 1 + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKCU "${PRODUCT_UNINST_KEY}" "EstimatedSize" $0 + WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "InstallMode" "CurrentUser" + reg_done: + + Call AddToPath + + DetailPrint "Registering Explorer context menu (media-img register-explorer)..." + ExecWait '"$INSTDIR\${PRODUCT_EXE}" register-explorer' $0 +SectionEnd + +Section "Uninstall" + DetailPrint "Removing Explorer context menu..." + IfFileExists "$INSTDIR\${PRODUCT_EXE}" 0 skip_unreg_explorer + ExecWait '"$INSTDIR\${PRODUCT_EXE}" register-explorer --unregister --no-refresh-shell' $0 + skip_unreg_explorer: + + Delete "$INSTDIR\${PRODUCT_EXE}" + Delete "$INSTDIR\*.dll" + RMDir /r "$INSTDIR\vips-modules-8.18" + Delete "$INSTDIR\scripts\explorer-resize.ps1" + Delete "$INSTDIR\scripts\explorer-convert.ps1" + RMDir "$INSTDIR\scripts" + Delete "$INSTDIR\Uninstall.exe" + RMDir "$INSTDIR" + + Delete "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk" + Delete "$SMPROGRAMS\${PRODUCT_NAME}\Uninstall.lnk" + RMDir "$SMPROGRAMS\${PRODUCT_NAME}" + + ReadRegStr $0 HKLM "${PRODUCT_UNINST_KEY}" "InstallMode" + StrCmp $0 "AllUsers" 0 unreg_user + DeleteRegKey HKLM "${PRODUCT_UNINST_KEY}" + Goto unreg_done + unreg_user: + DeleteRegKey HKCU "${PRODUCT_UNINST_KEY}" + unreg_done: + + Call un.RemoveFromPath +SectionEnd + +Function AddToPath + ReadRegStr $0 HKCU "${ENV_KEY}" "Path" + !insertmacro StrContains $1 "$0" "$INSTDIR" + StrCmp $1 "" 0 already_in_path + StrCpy $0 "$0;$INSTDIR" + WriteRegExpandStr HKCU "${ENV_KEY}" "Path" "$0" + SendMessage ${HWND_BROADCAST} ${WM_SETTINGCHANGE} 0 "STR:Environment" /TIMEOUT=5000 + already_in_path: +FunctionEnd + +Function un.RemoveFromPath + ReadRegStr $0 HKCU "${ENV_KEY}" "Path" + !insertmacro StrReplace $1 "$0" ";$INSTDIR" "" + StrCmp $1 "$0" 0 write_path + !insertmacro StrReplace $1 "$0" "$INSTDIR;" "" + StrCmp $1 "$0" 0 write_path + !insertmacro StrReplace $1 "$0" "$INSTDIR" "" + StrCmp $1 "$0" done_path + write_path: + WriteRegExpandStr HKCU "${ENV_KEY}" "Path" "$1" + SendMessage ${HWND_BROADCAST} ${WM_SETTINGCHANGE} 0 "STR:Environment" /TIMEOUT=5000 + done_path: +FunctionEnd diff --git a/packages/media/cpp/package.json b/packages/media/cpp/package.json index 03298b05..bbea6bc4 100644 --- a/packages/media/cpp/package.json +++ b/packages/media/cpp/package.json @@ -11,6 +11,7 @@ "config:release": "cmake --preset release", "build": "cmake --preset dev && cmake --build --preset dev", "build:release": "cmake --preset release && cmake --build --preset release", + "build:installer": "npm run build:release && makensis -V2 installer.nsi", "clean": "cmake -E rm -rf build dist", "rebuild": "npm run clean && npm run build", "generate:assets": "node tests/assets/build-fixtures.mjs", diff --git a/packages/media/cpp/src/main.cpp b/packages/media/cpp/src/main.cpp index bd70e785..f25ae957 100644 --- a/packages/media/cpp/src/main.cpp +++ b/packages/media/cpp/src/main.cpp @@ -17,6 +17,9 @@ #include "core/resize.hpp" #include "http/serve.hpp" #include "ipc/ipc_serve.hpp" +#if defined(_WIN32) +#include "win/register_explorer.hpp" +#endif #ifndef MEDIA_IMG_VERSION #define MEDIA_IMG_VERSION "0.1.0" @@ -142,6 +145,28 @@ int main(int argc, char **argv) { auto *kbot_cmd = app.add_subcommand("kbot", "Forward remaining args to kbot (KBOT_EXE)"); kbot_cmd->allow_extras(true); + std::string reg_group = "PM-Media"; + bool reg_unregister = false; + bool reg_dry = false; + bool reg_no_refresh = false; + std::string reg_media_bin; + std::string reg_explorer_script; + std::string reg_explorer_convert_script; + std::string reg_widths = "1980,1200,800,400"; + auto *reg_cmd = app.add_subcommand( + "register-explorer", + "Register Windows Explorer menus: resize presets + Convert to JPG (explorer-*.ps1 scripts)"); + reg_cmd->add_option("--group", reg_group)->default_val("PM-Media"); + reg_cmd->add_flag("--unregister", reg_unregister); + reg_cmd->add_flag("--dry", reg_dry); + reg_cmd->add_flag("--no-refresh-shell", reg_no_refresh); + reg_cmd->add_option("--media-bin", reg_media_bin, "Path to media-img.exe (default: this executable)"); + reg_cmd->add_option("--explorer-script", reg_explorer_script, + "Path to explorer-resize.ps1 (default: \\scripts\\explorer-resize.ps1)"); + reg_cmd->add_option("--explorer-convert-script", reg_explorer_convert_script, + "Path to explorer-convert.ps1 (default: \\scripts\\explorer-convert.ps1)"); + reg_cmd->add_option("--widths", reg_widths)->default_val("1980,1200,800,400"); + CLI11_PARSE(app, argc, argv); if (resize_cmd->parsed()) { @@ -224,6 +249,24 @@ int main(int argc, char **argv) { return forward_kbot(kbot_cmd->remaining()); } + if (reg_cmd->parsed()) { +#if defined(_WIN32) + media::win::RegisterExplorerOptions o; + o.group = reg_group; + o.unregister = reg_unregister; + o.dry = reg_dry; + o.refresh_shell = !reg_no_refresh; + o.media_bin = reg_media_bin; + o.explorer_script = reg_explorer_script; + o.explorer_convert_script = reg_explorer_convert_script; + o.widths = reg_widths; + return media::win::register_explorer_run(o); +#else + std::cerr << "media-img: register-explorer is only available on Windows.\n"; + return 1; +#endif + } + std::cout << app.help() << "\n"; return 0; } diff --git a/packages/media/cpp/src/win/register_explorer.cpp b/packages/media/cpp/src/win/register_explorer.cpp new file mode 100644 index 00000000..c6c7e60d --- /dev/null +++ b/packages/media/cpp/src/win/register_explorer.cpp @@ -0,0 +1,375 @@ +#include "register_explorer.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +#pragma comment(lib, "Advapi32.lib") + +namespace media::win { +namespace { + +std::wstring utf8_to_wide(const std::string &s) { + if (s.empty()) + return L""; + int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast(s.size()), nullptr, 0); + if (n <= 0) + return L""; + std::wstring w(static_cast(n), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast(s.size()), w.data(), n); + return w; +} + +std::string wide_to_utf8(const std::wstring &w) { + if (w.empty()) + return {}; + int n = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast(w.size()), nullptr, 0, nullptr, nullptr); + if (n <= 0) + return {}; + std::string s(static_cast(n), '\0'); + WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast(w.size()), s.data(), n, nullptr, nullptr); + return s; +} + +std::wstring exe_directory() { + wchar_t buf[MAX_PATH]{}; + DWORD n = GetModuleFileNameW(nullptr, buf, MAX_PATH); + if (n == 0 || n >= MAX_PATH) + return L"."; + std::wstring p(buf, n); + const size_t pos = p.find_last_of(L"\\/"); + if (pos == std::wstring::npos) + return L"."; + return p.substr(0, pos); +} + +std::wstring default_media_bin() { + wchar_t buf[MAX_PATH]{}; + DWORD n = GetModuleFileNameW(nullptr, buf, MAX_PATH); + if (n == 0 || n >= MAX_PATH) + return L"media-img.exe"; + return std::wstring(buf, n); +} + +bool file_exists_w(const std::wstring &path) { + const DWORD a = GetFileAttributesW(path.c_str()); + return a != INVALID_FILE_ATTRIBUTES && (a & FILE_ATTRIBUTE_DIRECTORY) == 0; +} + +std::wstring normalize_path(const std::wstring &p) { + wchar_t buf[MAX_PATH]{}; + DWORD n = GetFullPathNameW(p.c_str(), MAX_PATH, buf, nullptr); + if (n == 0 || n >= MAX_PATH) + return p; + return std::wstring(buf, n); +} + +std::wstring default_script_path(const std::wstring &exe_dir, const wchar_t *filename) { + const std::wstring candidates[] = { + exe_dir + L"\\scripts\\" + filename, + exe_dir + L"\\..\\..\\scripts\\" + filename, + }; + for (const auto &c : candidates) { + const std::wstring full = normalize_path(c); + if (file_exists_w(full)) + return full; + } + return normalize_path(candidates[0]); +} + +std::wstring default_explorer_script(const std::wstring &exe_dir) { + return default_script_path(exe_dir, L"explorer-resize.ps1"); +} + +std::wstring default_explorer_convert_script(const std::wstring &exe_dir) { + return default_script_path(exe_dir, L"explorer-convert.ps1"); +} + +bool parse_widths(const std::string &s, std::vector &out) { + out.clear(); + std::string cur; + auto flush = [&]() { + if (cur.empty()) + return; + try { + int v = std::stoi(cur); + if (v > 0) + out.push_back(v); + } catch (...) { + } + cur.clear(); + }; + for (char c : s) { + if (c == ',' || c == ' ' || c == '\t') { + flush(); + } else { + cur.push_back(c); + } + } + flush(); + return !out.empty(); +} + +/** Lowercase ASCII extension without dot, e.g. "jpg", "tiff", "arw". */ +static const char *k_canonical_ext[] = {"jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "tif", + "jpe", "jfif", "avif", "arw"}; + +/** Register .ext, .EXT, and .Ext so Explorer matches case-insensitively per user preference. */ +std::vector ext_dot_variants(const std::string &canon_lower_ascii) { + std::wstring lower = L"."; + std::wstring upper = L"."; + std::wstring title = L"."; + for (unsigned char ch : canon_lower_ascii) { + lower += static_cast(ch); + upper += static_cast(std::toupper(ch)); + } + if (!canon_lower_ascii.empty()) { + title += static_cast(std::toupper(static_cast(canon_lower_ascii[0]))); + for (size_t i = 1; i < canon_lower_ascii.size(); ++i) + title += static_cast(canon_lower_ascii[i]); + } + std::vector v; + v.push_back(lower); + v.push_back(upper); + v.push_back(title); + std::sort(v.begin(), v.end()); + v.erase(std::unique(v.begin(), v.end()), v.end()); + return v; +} + +struct AssocTarget { + std::wstring classes_suffix; + std::wstring path_token; +}; + +LONG create_key(HKEY root, const std::wstring &rel, HKEY *out) { + return RegCreateKeyExW(root, rel.c_str(), 0, nullptr, REG_OPTION_NON_VOLATILE, + KEY_READ | KEY_WRITE, nullptr, out, nullptr); +} + +bool set_sz(HKEY key, const wchar_t *name, const std::wstring &value) { + const BYTE *data = reinterpret_cast(value.c_str()); + const DWORD cb = static_cast((value.size() + 1) * sizeof(wchar_t)); + return RegSetValueExW(key, name, 0, REG_SZ, data, cb) == ERROR_SUCCESS; +} + +bool set_default_sz(HKEY key, const std::wstring &value) { + const BYTE *data = reinterpret_cast(value.c_str()); + const DWORD cb = static_cast((value.size() + 1) * sizeof(wchar_t)); + return RegSetValueExW(key, nullptr, 0, REG_SZ, data, cb) == ERROR_SUCCESS; +} + +std::wstring build_resize_command_line(const std::wstring &script, const std::wstring &media, + const std::wstring &path_token, int w, bool inplace) { + std::wstring cmd = + L"powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \"" + script + + L"\" -MediaImg \"" + media + L"\" -Path \"" + path_token + L"\" -MaxWidth " + std::to_wstring(w) + + L" -Mode "; + cmd += inplace ? L"InPlace" : L"Copy"; + return cmd; +} + +std::wstring build_convert_command_line(const std::wstring &convert_script, const std::wstring &media, + const std::wstring &path_token) { + return L"powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \"" + convert_script + + L"\" -MediaImg \"" + media + L"\" -Path \"" + path_token + L"\""; +} + +bool write_verbed_command(HKEY root, const std::wstring &shell_group, const std::wstring &id, + const std::wstring &mui_name, const std::wstring &command_line) { + const std::wstring sub = shell_group + L"\\shell\\" + id; + HKEY h_cmd = nullptr; + if (create_key(root, sub, &h_cmd) != ERROR_SUCCESS) + return false; + if (!set_sz(h_cmd, L"MUIVerb", mui_name)) { + RegCloseKey(h_cmd); + return false; + } + RegCloseKey(h_cmd); + + const std::wstring sub_cmd = sub + L"\\command"; + HKEY h_run = nullptr; + if (create_key(root, sub_cmd, &h_run) != ERROR_SUCCESS) + return false; + const bool ok = set_default_sz(h_run, command_line); + RegCloseKey(h_run); + return ok; +} + +bool register_one_target(HKEY root, const std::wstring &classes_suffix, const std::wstring &path_token, + const std::wstring &group, const std::wstring &resize_script, + const std::wstring &convert_script, const std::wstring &media, const std::vector &widths) { + const std::wstring base = std::wstring(L"Software\\Classes\\") + classes_suffix; + const std::wstring shell_group = base + L"\\shell\\" + group; + + HKEY h_shell = nullptr; + if (create_key(root, shell_group, &h_shell) != ERROR_SUCCESS) + return false; + const bool head_ok = set_sz(h_shell, L"MUIVerb", group) && set_sz(h_shell, L"subCommands", L""); + RegCloseKey(h_shell); + if (!head_ok) + return false; + + for (int w : widths) { + const std::wstring id_in = L"r" + std::to_wstring(w) + L"in"; + const std::wstring id_cp = L"r" + std::to_wstring(w) + L"cp"; + const std::wstring name_in = L"Resize max " + std::to_wstring(w) + L" (in place)"; + const std::wstring name_cp = L"Resize max " + std::to_wstring(w) + L" (copy _" + std::to_wstring(w) + L")"; + + auto write_resize = [&](const std::wstring &id, const std::wstring &mui_name, bool inplace) -> bool { + return write_verbed_command(root, shell_group, id, mui_name, + build_resize_command_line(resize_script, media, path_token, w, inplace)); + }; + + if (!write_resize(id_in, name_in, true)) + return false; + if (!write_resize(id_cp, name_cp, false)) + return false; + } + + const std::wstring cvt_line = build_convert_command_line(convert_script, media, path_token); + if (!write_verbed_command(root, shell_group, L"cvtjpg", L"Convert to JPG", cvt_line)) + return false; + + return true; +} + +void refresh_shell_notify() { + using SHChangeNotify_fn = void(WINAPI *)(LONG, UINT, const void *, const void *); + HMODULE sh = LoadLibraryW(L"shell32.dll"); + if (!sh) + return; + auto fn = reinterpret_cast(GetProcAddress(sh, "SHChangeNotify")); + if (fn) + fn(0x08000000, 0, nullptr, nullptr); + FreeLibrary(sh); +} + +std::vector build_file_association_targets() { + std::vector out; + for (const char *c : k_canonical_ext) { + const std::string canon(c); + for (const std::wstring &dot : ext_dot_variants(canon)) { + AssocTarget t; + t.classes_suffix = L"SystemFileAssociations\\" + dot; + t.path_token = L"%1"; + out.push_back(std::move(t)); + } + } + out.push_back(AssocTarget{L"Directory", L"%1"}); + out.push_back(AssocTarget{L"Directory\\Background", L"%V"}); + return out; +} + +std::wstring shell_group_rel(const std::wstring &classes_suffix, const std::wstring &group) { + return L"Software\\Classes\\" + classes_suffix + L"\\shell\\" + group; +} + +} // namespace + +int register_explorer_run(const RegisterExplorerOptions &opt) { + const std::wstring exe_dir = exe_directory(); + std::wstring media_w = opt.media_bin.empty() ? default_media_bin() : utf8_to_wide(opt.media_bin); + std::wstring resize_script_w = + opt.explorer_script.empty() ? default_explorer_script(exe_dir) : utf8_to_wide(opt.explorer_script); + std::wstring convert_script_w = opt.explorer_convert_script.empty() + ? default_explorer_convert_script(exe_dir) + : utf8_to_wide(opt.explorer_convert_script); + + std::vector widths; + if (!parse_widths(opt.widths, widths)) { + std::cerr << "register-explorer: invalid or empty --widths\n"; + return 1; + } + + if (!opt.unregister) { + if (!file_exists_w(media_w)) { + std::cerr << "register-explorer: media-img not found: " << wide_to_utf8(media_w) << "\n"; + return 1; + } + if (!file_exists_w(resize_script_w)) { + std::cerr << "register-explorer: resize script not found: " << wide_to_utf8(resize_script_w) << "\n"; + return 1; + } + if (!file_exists_w(convert_script_w)) { + std::cerr << "register-explorer: convert script not found: " << wide_to_utf8(convert_script_w) << "\n"; + return 1; + } + } + + const std::wstring group_w = utf8_to_wide(opt.group); + + if (opt.unregister) { + if (opt.dry) { + std::cout << "Dry run: would remove shell keys for group " << opt.group << "\n"; + return 0; + } + bool ok = true; + for (const char *c : k_canonical_ext) { + for (const std::wstring &dot : ext_dot_variants(std::string(c))) { + std::wstring rel = shell_group_rel(L"SystemFileAssociations\\" + dot, group_w); + const LONG r = RegDeleteTreeW(HKEY_CURRENT_USER, rel.c_str()); + if (r != ERROR_SUCCESS && r != ERROR_FILE_NOT_FOUND) + ok = false; + } + } + { + std::wstring rel = shell_group_rel(L"Directory", group_w); + const LONG r = RegDeleteTreeW(HKEY_CURRENT_USER, rel.c_str()); + if (r != ERROR_SUCCESS && r != ERROR_FILE_NOT_FOUND) + ok = false; + } + { + std::wstring rel = shell_group_rel(L"Directory\\Background", group_w); + const LONG r = RegDeleteTreeW(HKEY_CURRENT_USER, rel.c_str()); + if (r != ERROR_SUCCESS && r != ERROR_FILE_NOT_FOUND) + ok = false; + } + if (!ok) { + std::cerr << "register-explorer: failed to remove some registry keys\n"; + return 1; + } + std::cout << "Removed Explorer menus for group " << opt.group << "\n"; + if (opt.refresh_shell) + refresh_shell_notify(); + return 0; + } + + if (opt.dry) { + std::cout << "Dry run (sample for %1):\n"; + const std::wstring sample_tok = L"%1"; + for (int w : widths) { + std::cout << " " << wide_to_utf8(build_resize_command_line(resize_script_w, media_w, sample_tok, w, true)) + << "\n"; + std::cout << " " << wide_to_utf8(build_resize_command_line(resize_script_w, media_w, sample_tok, w, false)) + << "\n"; + } + std::cout << " " << wide_to_utf8(build_convert_command_line(convert_script_w, media_w, sample_tok)) << "\n"; + std::cout << "Also register folder background with %V.\n"; + std::cout << "File types: " << (sizeof(k_canonical_ext) / sizeof(k_canonical_ext[0])) + << " canonical extensions × 3 case variants under SystemFileAssociations.\n"; + return 0; + } + + const std::vector targets = build_file_association_targets(); + + for (const auto &t : targets) { + if (!register_one_target(HKEY_CURRENT_USER, t.classes_suffix, t.path_token, group_w, resize_script_w, + convert_script_w, media_w, widths)) { + std::cerr << "register-explorer: failed to register a context-menu target\n"; + return 1; + } + } + + std::cout << "Registered Explorer menus for group " << opt.group << "\n"; + if (opt.refresh_shell) + refresh_shell_notify(); + return 0; +} + +} // namespace media::win diff --git a/packages/media/cpp/src/win/register_explorer.hpp b/packages/media/cpp/src/win/register_explorer.hpp new file mode 100644 index 00000000..7a36ee4d --- /dev/null +++ b/packages/media/cpp/src/win/register_explorer.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +namespace media::win { + +struct RegisterExplorerOptions { + std::string group{"PM-Media"}; + bool unregister{false}; + bool dry{false}; + bool refresh_shell{true}; + /** Empty = use path of running media-img.exe */ + std::string media_bin; + /** Empty = \\scripts\\explorer-resize.ps1 */ + std::string explorer_script; + /** Empty = \\scripts\\explorer-convert.ps1 */ + std::string explorer_convert_script; + /** Comma-separated positive integers */ + std::string widths{"1980,1200,800,400"}; +}; + +/** Windows only. Returns 0 on success. */ +int register_explorer_run(const RegisterExplorerOptions &opt); + +} // namespace media::win diff --git a/packages/media/cpp/tests/assets/in/DSC01177_converted.jpg b/packages/media/cpp/tests/assets/in/DSC01177_converted.jpg new file mode 100644 index 00000000..ef2ec1f5 Binary files /dev/null and b/packages/media/cpp/tests/assets/in/DSC01177_converted.jpg differ