diff --git a/PowerToys.slnx b/PowerToys.slnx index 9e14ce1a6c41..a2b40cc7c15c 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -440,6 +440,11 @@ + + + + + @@ -1089,6 +1094,8 @@ + + diff --git a/doc/images/disk-usage/add_remove_size_v0.66_converted.jpeg b/doc/images/disk-usage/add_remove_size_v0.66_converted.jpeg new file mode 100644 index 000000000000..267c19bb90fa Binary files /dev/null and b/doc/images/disk-usage/add_remove_size_v0.66_converted.jpeg differ diff --git a/src/common/ManagedCommon/ModuleType.cs b/src/common/ManagedCommon/ModuleType.cs index 548276f72520..4d090c66085b 100644 --- a/src/common/ManagedCommon/ModuleType.cs +++ b/src/common/ManagedCommon/ModuleType.cs @@ -38,5 +38,6 @@ public enum ModuleType Workspaces, ZoomIt, GeneralSettings, + FileConverter, } } diff --git a/src/modules/FileConverter/FileConverterContextMenu/AppxManifest.xml b/src/modules/FileConverter/FileConverterContextMenu/AppxManifest.xml new file mode 100644 index 000000000000..6088f57c6128 --- /dev/null +++ b/src/modules/FileConverter/FileConverterContextMenu/AppxManifest.xml @@ -0,0 +1,97 @@ + + + + + PowerToys File Converter Context Menu + Microsoft + Assets\FileConverter\storelogo.png + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/LargeTile.png b/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/LargeTile.png new file mode 100644 index 000000000000..f8a9f8f193b6 Binary files /dev/null and b/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/LargeTile.png differ diff --git a/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/SmallTile.png b/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/SmallTile.png new file mode 100644 index 000000000000..8773d6366a6d Binary files /dev/null and b/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/SmallTile.png differ diff --git a/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/SplashScreen.png b/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/SplashScreen.png new file mode 100644 index 000000000000..9d421b9526c9 Binary files /dev/null and b/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/SplashScreen.png differ diff --git a/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/Square150x150Logo.png b/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/Square150x150Logo.png new file mode 100644 index 000000000000..f8a9f8f193b6 Binary files /dev/null and b/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/Square150x150Logo.png differ diff --git a/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/Square44x44Logo.png b/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/Square44x44Logo.png new file mode 100644 index 000000000000..8773d6366a6d Binary files /dev/null and b/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/Square44x44Logo.png differ diff --git a/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/Wide310x150Logo.png b/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/Wide310x150Logo.png new file mode 100644 index 000000000000..266b41d25bb1 Binary files /dev/null and b/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/Wide310x150Logo.png differ diff --git a/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/storelogo.png b/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/storelogo.png new file mode 100644 index 000000000000..dfbca016bc82 Binary files /dev/null and b/src/modules/FileConverter/FileConverterContextMenu/Assets/FileConverter/storelogo.png differ diff --git a/src/modules/FileConverter/FileConverterContextMenu/FileConverterContextMenu.vcxproj b/src/modules/FileConverter/FileConverterContextMenu/FileConverterContextMenu.vcxproj new file mode 100644 index 000000000000..d5ef8202ee8a --- /dev/null +++ b/src/modules/FileConverter/FileConverterContextMenu/FileConverterContextMenu.vcxproj @@ -0,0 +1,65 @@ + + + + + Win32Proj + {f3610647-6f9f-45ee-987f-c9a89c23f7f0} + FileConverterContextMenu + FileConverterContextMenu + PowerToys.FileConverterContextMenu + + + DynamicLibrary + + + + + + + + + + $(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\ + $(SolutionDir)$(Platform)\$(Configuration)\TemporaryBuild\obj\$(ProjectName)\ + + + + _WINDOWS;_USRDLL;%(PreprocessorDefinitions) + ..\FileConverterLib;$(RepoRoot)src\;%(AdditionalIncludeDirectories) + + + Windows + false + Source.def + runtimeobject.lib;shell32.lib;shlwapi.lib;windowscodecs.lib;ole32.lib;%(AdditionalDependencies) + + + + + + + + + + Create + + + + + {2d6e2c29-43ce-4be9-b0fd-2f6240d04ef4} + + + + + + + + + + + + + + + + diff --git a/src/modules/FileConverter/FileConverterContextMenu/Source.def b/src/modules/FileConverter/FileConverterContextMenu/Source.def new file mode 100644 index 000000000000..49a8989d39a5 --- /dev/null +++ b/src/modules/FileConverter/FileConverterContextMenu/Source.def @@ -0,0 +1,4 @@ +EXPORTS + DllGetActivationFactory PRIVATE + DllCanUnloadNow PRIVATE + DllGetClassObject PRIVATE diff --git a/src/modules/FileConverter/FileConverterContextMenu/dllmain.cpp b/src/modules/FileConverter/FileConverterContextMenu/dllmain.cpp new file mode 100644 index 000000000000..dbf7fcae583f --- /dev/null +++ b/src/modules/FileConverter/FileConverterContextMenu/dllmain.cpp @@ -0,0 +1,721 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +#include "pch.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace Microsoft::WRL; +namespace json = winrt::Windows::Data::Json; +namespace fc_constants = winrt::PowerToys::FileConverter::Constants; + +namespace +{ + constexpr DWORD PIPE_CONNECT_TIMEOUT_MS = 1000; + + enum class FormatGroup + { + Png, + Jpeg, + Bmp, + Tiff, + Heif, + Webp, + Unknown, + }; + + struct TargetFormatSpec + { + const wchar_t* label_key; + const wchar_t* label_fallback; + const wchar_t* destination; + FormatGroup destination_group; + GUID canonical_name; + }; + + constexpr std::array TARGET_FORMATS = { + TargetFormatSpec{ L"FileConverter_ContextMenu_Format_Png", L"PNG", fc_constants::FormatPng, FormatGroup::Png, { 0x0a4200f1, 0x74e5, 0x4f59, { 0xbb, 0x5d, 0x79, 0x8a, 0xfa, 0xf8, 0x01, 0x10 } } }, + TargetFormatSpec{ L"FileConverter_ContextMenu_Format_Jpg", L"JPG", fc_constants::FormatJpg, FormatGroup::Jpeg, { 0x9f0adf10, 0x3fcb, 0x4a22, { 0x9e, 0x4a, 0x9c, 0x9c, 0x5e, 0xc1, 0x16, 0x4a } } }, + TargetFormatSpec{ L"FileConverter_ContextMenu_Format_Jpeg", L"JPEG", fc_constants::FormatJpeg, FormatGroup::Jpeg, { 0x6d94f15d, 0xa2ba, 0x4912, { 0xa8, 0xf6, 0xe3, 0x89, 0xe0, 0xf8, 0x50, 0x76 } } }, + TargetFormatSpec{ L"FileConverter_ContextMenu_Format_Bmp", L"BMP", fc_constants::FormatBmp, FormatGroup::Bmp, { 0x922d3030, 0x7fdb, 0x4de7, { 0x99, 0x39, 0x15, 0x95, 0x38, 0x0e, 0x81, 0x88 } } }, + TargetFormatSpec{ L"FileConverter_ContextMenu_Format_Tiff", L"TIFF", fc_constants::FormatTiff, FormatGroup::Tiff, { 0x91fc7a8a, 0x34b9, 0x4ddf, { 0x86, 0xe8, 0x9f, 0xbb, 0x84, 0xf3, 0x55, 0x65 } } }, + TargetFormatSpec{ L"FileConverter_ContextMenu_Format_Heic", L"HEIC", fc_constants::FormatHeic, FormatGroup::Heif, { 0xd10be4f8, 0x6e5f, 0x4c6d, { 0xa1, 0x45, 0xbe, 0x57, 0x9f, 0x42, 0x75, 0x69 } } }, + TargetFormatSpec{ L"FileConverter_ContextMenu_Format_Heif", L"HEIF", fc_constants::FormatHeif, FormatGroup::Heif, { 0x7fce9037, 0x12fe, 0x40af, { 0x88, 0x95, 0x6e, 0x7f, 0xe6, 0x29, 0x2b, 0x45 } } }, + TargetFormatSpec{ L"FileConverter_ContextMenu_Format_Webp", L"WebP", fc_constants::FormatWebp, FormatGroup::Webp, { 0x5fce9315, 0x3d7b, 0x4372, { 0xac, 0x17, 0x35, 0x57, 0x91, 0xcd, 0x17, 0x61 } } }, + }; + + std::wstring LoadLocalizedString(std::wstring_view key, std::wstring_view fallback) + { + try + { + static const auto loader = winrt::Windows::ApplicationModel::Resources::ResourceLoader::GetForViewIndependentUse(L"Resources"); + const auto value = loader.GetString(winrt::hstring{ key }); + if (!value.empty()) + { + return value.c_str(); + } + } + catch (...) + { + } + + return std::wstring{ fallback }; + } + + std::wstring GetContextMenuParentLabel() + { + static const std::wstring label = LoadLocalizedString(L"FileConverter_ContextMenu_Entry", L"Convert to..."); + return label; + } + + std::wstring GetTargetFormatLabel(const TargetFormatSpec& spec) + { + return LoadLocalizedString(spec.label_key, spec.label_fallback); + } + + std::wstring GetPipeNameForCurrentSession() + { + DWORD session_id = 0; + if (!ProcessIdToSessionId(GetCurrentProcessId(), &session_id)) + { + session_id = 0; + } + + return std::wstring(fc_constants::PipeNamePrefix) + std::to_wstring(session_id); + } + + HRESULT GetSelectedPaths(IShellItemArray* selection, std::vector& paths) + { + if (selection == nullptr) + { + return E_INVALIDARG; + } + + paths.clear(); + + DWORD count = 0; + const HRESULT count_hr = selection->GetCount(&count); + if (FAILED(count_hr)) + { + return count_hr; + } + + for (DWORD i = 0; i < count; ++i) + { + ComPtr item; + const HRESULT item_hr = selection->GetItemAt(i, &item); + if (FAILED(item_hr) || item == nullptr) + { + continue; + } + + PWSTR path_value = nullptr; + const HRESULT path_hr = item->GetDisplayName(SIGDN_FILESYSPATH, &path_value); + if (FAILED(path_hr) || path_value == nullptr || path_value[0] == L'\0') + { + if (path_value != nullptr) + { + CoTaskMemFree(path_value); + } + + continue; + } + + paths.emplace_back(path_value); + CoTaskMemFree(path_value); + } + + return paths.empty() ? E_FAIL : S_OK; + } + + HRESULT GetSelectedPaths(IDataObject* data_object, std::vector& paths) + { + if (data_object == nullptr) + { + return E_INVALIDARG; + } + + ComPtr shell_item_array; + const HRESULT hr = SHCreateShellItemArrayFromDataObject(data_object, IID_PPV_ARGS(&shell_item_array)); + if (FAILED(hr)) + { + return hr; + } + + return GetSelectedPaths(shell_item_array.Get(), paths); + } + + std::wstring ToLower(std::wstring value) + { + std::transform(value.begin(), value.end(), value.begin(), [](wchar_t ch) { + return static_cast(std::towlower(ch)); + }); + + return value; + } + + FormatGroup ExtensionToGroup(const std::wstring& extension) + { + const std::wstring lower = ToLower(extension); + if (lower == fc_constants::ExtensionPng) + { + return FormatGroup::Png; + } + + if (lower == fc_constants::ExtensionJpg || lower == fc_constants::ExtensionJpeg) + { + return FormatGroup::Jpeg; + } + + if (lower == fc_constants::ExtensionBmp) + { + return FormatGroup::Bmp; + } + + if (lower == fc_constants::ExtensionTif || lower == fc_constants::ExtensionTiff) + { + return FormatGroup::Tiff; + } + + if (lower == fc_constants::ExtensionHeic || lower == fc_constants::ExtensionHeif) + { + return FormatGroup::Heif; + } + + if (lower == fc_constants::ExtensionWebp) + { + return FormatGroup::Webp; + } + + return FormatGroup::Unknown; + } + + bool IsPathEligibleSource(const std::wstring& path, FormatGroup& group) + { + const wchar_t* extension = PathFindExtension(path.c_str()); + if (extension == nullptr || extension[0] == L'\0') + { + return false; + } + + group = ExtensionToGroup(extension); + if (group == FormatGroup::Unknown) + { + return false; + } + +#pragma warning(suppress : 26812) + PERCEIVED perceived_type = PERCEIVED_TYPE_UNSPECIFIED; + PERCEIVEDFLAG perceived_flags = PERCEIVEDFLAG_UNDEFINED; + AssocGetPerceivedType(extension, &perceived_type, &perceived_flags, nullptr); + return perceived_type == PERCEIVED_TYPE_IMAGE; + } + + bool CanConvertPaths(const std::vector& paths, std::optional destination_group) + { + if (paths.empty()) + { + return false; + } + + for (const auto& path : paths) + { + FormatGroup source_group = FormatGroup::Unknown; + if (!IsPathEligibleSource(path, source_group)) + { + return false; + } + + if (destination_group.has_value() && source_group == destination_group.value()) + { + return false; + } + } + + return true; + } + + bool HasAnyAvailableDestination(const std::vector& paths) + { + for (const auto& spec : TARGET_FORMATS) + { + if (CanConvertPaths(paths, spec.destination_group)) + { + return true; + } + } + + return false; + } + + const TargetFormatSpec* FindTargetFormat(std::wstring_view destination) + { + const std::wstring lower_destination = ToLower(std::wstring(destination)); + for (const auto& spec : TARGET_FORMATS) + { + if (lower_destination == spec.destination) + { + return &spec; + } + } + + return nullptr; + } + + std::string BuildFormatConvertPayload(const std::vector& paths, std::wstring_view destination) + { + json::JsonObject payload; + payload.Insert(fc_constants::JsonActionKey, json::JsonValue::CreateStringValue(fc_constants::ActionFormatConvert)); + payload.Insert(fc_constants::JsonDestinationKey, json::JsonValue::CreateStringValue(destination.data())); + + json::JsonArray files; + for (const auto& path : paths) + { + files.Append(json::JsonValue::CreateStringValue(path)); + } + + payload.Insert(fc_constants::JsonFilesKey, files); + return winrt::to_string(payload.Stringify()); + } + + HRESULT SendFormatConvertRequest(const std::vector& paths, std::wstring_view destination) + { + const TargetFormatSpec* target = FindTargetFormat(destination); + if (target == nullptr || !CanConvertPaths(paths, target->destination_group)) + { + return E_INVALIDARG; + } + + const std::wstring pipe_name = GetPipeNameForCurrentSession(); + if (!WaitNamedPipeW(pipe_name.c_str(), PIPE_CONNECT_TIMEOUT_MS)) + { + return HRESULT_FROM_WIN32(GetLastError()); + } + + HANDLE pipe_handle = CreateFileW( + pipe_name.c_str(), + GENERIC_WRITE, + 0, + nullptr, + OPEN_EXISTING, + 0, + nullptr); + + if (pipe_handle == INVALID_HANDLE_VALUE) + { + return HRESULT_FROM_WIN32(GetLastError()); + } + + const std::string payload = BuildFormatConvertPayload(paths, target->destination); + + DWORD bytes_written = 0; + const BOOL write_result = WriteFile( + pipe_handle, + payload.data(), + static_cast(payload.size()), + &bytes_written, + nullptr); + + const DWORD write_error = write_result ? ERROR_SUCCESS : GetLastError(); + CloseHandle(pipe_handle); + + if (!write_result || bytes_written != payload.size()) + { + return HRESULT_FROM_WIN32(write_result ? ERROR_WRITE_FAULT : write_error); + } + + return S_OK; + } + + class FileConverterSubCommand final : public RuntimeClass, IExplorerCommand> + { + public: + explicit FileConverterSubCommand(const TargetFormatSpec& spec) + : m_spec(spec) + { + } + + IFACEMETHODIMP GetTitle(_In_opt_ IShellItemArray*, _Outptr_result_nullonfailure_ PWSTR* name) + { + const auto label = GetTargetFormatLabel(m_spec); + return SHStrDup(label.c_str(), name); + } + + IFACEMETHODIMP GetIcon(_In_opt_ IShellItemArray*, _Outptr_result_nullonfailure_ PWSTR* icon) + { + *icon = nullptr; + return E_NOTIMPL; + } + + IFACEMETHODIMP GetToolTip(_In_opt_ IShellItemArray*, _Outptr_result_nullonfailure_ PWSTR* info_tip) + { + *info_tip = nullptr; + return E_NOTIMPL; + } + + IFACEMETHODIMP GetCanonicalName(_Out_ GUID* guid_command_name) + { + *guid_command_name = m_spec.canonical_name; + return S_OK; + } + + IFACEMETHODIMP GetState(_In_opt_ IShellItemArray* selection, _In_ BOOL, _Out_ EXPCMDSTATE* cmd_state) + { + *cmd_state = ECS_HIDDEN; + + if (selection == nullptr) + { + return S_OK; + } + + std::vector paths; + if (FAILED(GetSelectedPaths(selection, paths))) + { + return S_OK; + } + + if (CanConvertPaths(paths, m_spec.destination_group)) + { + *cmd_state = ECS_ENABLED; + } + + return S_OK; + } + + IFACEMETHODIMP Invoke(_In_opt_ IShellItemArray* selection, _In_opt_ IBindCtx*) + { + if (selection == nullptr) + { + return S_OK; + } + + std::vector paths; + if (SUCCEEDED(GetSelectedPaths(selection, paths))) + { + (void)SendFormatConvertRequest(paths, m_spec.destination); + } + + return S_OK; + } + + IFACEMETHODIMP GetFlags(_Out_ EXPCMDFLAGS* flags) + { + *flags = ECF_DEFAULT; + return S_OK; + } + + IFACEMETHODIMP EnumSubCommands(_COM_Outptr_ IEnumExplorerCommand** enum_commands) + { + *enum_commands = nullptr; + return E_NOTIMPL; + } + + private: + TargetFormatSpec m_spec; + }; + + class FileConverterSubCommandEnumerator final : public RuntimeClass, IEnumExplorerCommand> + { + public: + FileConverterSubCommandEnumerator() + { + for (const auto& spec : TARGET_FORMATS) + { + m_commands.push_back(Make(spec)); + } + } + + IFACEMETHODIMP Next(ULONG celt, __out_ecount_part(celt, *pceltFetched) IExplorerCommand** ap_ui_command, __out_opt ULONG* pcelt_fetched) + { + if (ap_ui_command == nullptr) + { + return E_POINTER; + } + + ULONG fetched = 0; + if (pcelt_fetched != nullptr) + { + *pcelt_fetched = 0; + } + + while (fetched < celt && m_current_index < m_commands.size()) + { + m_commands[m_current_index].CopyTo(&ap_ui_command[fetched]); + ++m_current_index; + ++fetched; + } + + if (pcelt_fetched != nullptr) + { + *pcelt_fetched = fetched; + } + + return fetched == celt ? S_OK : S_FALSE; + } + + IFACEMETHODIMP Skip(ULONG celt) + { + m_current_index = (std::min)(m_current_index + static_cast(celt), m_commands.size()); + return m_current_index < m_commands.size() ? S_OK : S_FALSE; + } + + IFACEMETHODIMP Reset() + { + m_current_index = 0; + return S_OK; + } + + IFACEMETHODIMP Clone(__deref_out IEnumExplorerCommand** ppenum) + { + *ppenum = nullptr; + return E_NOTIMPL; + } + + private: + std::vector> m_commands; + size_t m_current_index = 0; + }; +} + +HINSTANCE g_module_instance = 0; + +BOOL APIENTRY DllMain(HMODULE module_handle, DWORD reason, LPVOID) +{ + if (reason == DLL_PROCESS_ATTACH) + { + g_module_instance = module_handle; + } + + return TRUE; +} + +class __declspec(uuid("57EC18F5-24D5-4DC6-AE2E-9D0F7A39F8BA")) FileConverterContextMenuCommand final : + public RuntimeClass, IExplorerCommand, IObjectWithSite, IShellExtInit, IContextMenu> +{ +public: + IFACEMETHODIMP GetTitle(_In_opt_ IShellItemArray*, _Outptr_result_nullonfailure_ PWSTR* name) + { + const auto label = GetContextMenuParentLabel(); + return SHStrDup(label.c_str(), name); + } + + IFACEMETHODIMP GetIcon(_In_opt_ IShellItemArray*, _Outptr_result_nullonfailure_ PWSTR* icon) + { + *icon = nullptr; + return E_NOTIMPL; + } + + IFACEMETHODIMP GetToolTip(_In_opt_ IShellItemArray*, _Outptr_result_nullonfailure_ PWSTR* info_tip) + { + *info_tip = nullptr; + return E_NOTIMPL; + } + + IFACEMETHODIMP GetCanonicalName(_Out_ GUID* guid_command_name) + { + *guid_command_name = __uuidof(this); + return S_OK; + } + + IFACEMETHODIMP GetState(_In_opt_ IShellItemArray* selection, _In_ BOOL, _Out_ EXPCMDSTATE* cmd_state) + { + *cmd_state = ECS_HIDDEN; + + if (selection == nullptr) + { + return S_OK; + } + + std::vector paths; + if (FAILED(GetSelectedPaths(selection, paths))) + { + return S_OK; + } + + if (HasAnyAvailableDestination(paths)) + { + *cmd_state = ECS_ENABLED; + } + + return S_OK; + } + + IFACEMETHODIMP Invoke(_In_opt_ IShellItemArray* selection, _In_opt_ IBindCtx*) + { + UNREFERENCED_PARAMETER(selection); + return E_NOTIMPL; + } + + IFACEMETHODIMP GetFlags(_Out_ EXPCMDFLAGS* flags) + { + *flags = ECF_HASSUBCOMMANDS; + return S_OK; + } + + IFACEMETHODIMP EnumSubCommands(_COM_Outptr_ IEnumExplorerCommand** enum_commands) + { + auto enumerator = Make(); + return enumerator->QueryInterface(IID_PPV_ARGS(enum_commands)); + } + + IFACEMETHODIMP SetSite(_In_ IUnknown* site) + { + m_site = site; + return S_OK; + } + + IFACEMETHODIMP GetSite(_In_ REFIID riid, _COM_Outptr_ void** site) + { + return m_site.CopyTo(riid, site); + } + + IFACEMETHODIMP Initialize(_In_opt_ PCIDLIST_ABSOLUTE, _In_opt_ IDataObject* data_object, _In_opt_ HKEY) + { + m_data_object = data_object; + return S_OK; + } + + IFACEMETHODIMP QueryContextMenu(HMENU menu, UINT index_menu, UINT id_cmd_first, UINT, UINT flags) + { + if (menu == nullptr) + { + return E_INVALIDARG; + } + + if ((flags & CMF_DEFAULTONLY) != 0 || m_data_object == nullptr) + { + return MAKE_HRESULT(SEVERITY_SUCCESS, FACILITY_NULL, 0); + } + + std::vector paths; + if (FAILED(GetSelectedPaths(m_data_object.Get(), paths)) || !HasAnyAvailableDestination(paths)) + { + return MAKE_HRESULT(SEVERITY_SUCCESS, FACILITY_NULL, 0); + } + + HMENU sub_menu = CreatePopupMenu(); + if (sub_menu == nullptr) + { + return HRESULT_FROM_WIN32(GetLastError()); + } + + m_context_menu_target_indexes.clear(); + UINT command_id = id_cmd_first; + UINT sub_menu_index = 0; + for (size_t i = 0; i < TARGET_FORMATS.size(); ++i) + { + const auto& format = TARGET_FORMATS[i]; + if (!CanConvertPaths(paths, format.destination_group)) + { + continue; + } + + const auto target_label = GetTargetFormatLabel(format); + + if (!InsertMenuW(sub_menu, sub_menu_index, MF_BYPOSITION | MF_STRING, command_id, target_label.c_str())) + { + const HRESULT hr = HRESULT_FROM_WIN32(GetLastError()); + DestroyMenu(sub_menu); + m_context_menu_target_indexes.clear(); + return hr; + } + + m_context_menu_target_indexes.push_back(i); + ++command_id; + ++sub_menu_index; + } + + if (m_context_menu_target_indexes.empty()) + { + DestroyMenu(sub_menu); + return MAKE_HRESULT(SEVERITY_SUCCESS, FACILITY_NULL, 0); + } + + const auto parent_label = GetContextMenuParentLabel(); + if (!InsertMenuW(menu, index_menu, MF_BYPOSITION | MF_POPUP | MF_STRING, reinterpret_cast(sub_menu), parent_label.c_str())) + { + const HRESULT hr = HRESULT_FROM_WIN32(GetLastError()); + DestroyMenu(sub_menu); + m_context_menu_target_indexes.clear(); + return hr; + } + + return MAKE_HRESULT(SEVERITY_SUCCESS, FACILITY_NULL, static_cast(m_context_menu_target_indexes.size())); + } + + IFACEMETHODIMP InvokeCommand(CMINVOKECOMMANDINFO* invoke_info) + { + if (invoke_info == nullptr || m_data_object == nullptr) + { + return S_OK; + } + + if (!IS_INTRESOURCE(invoke_info->lpVerb)) + { + return S_OK; + } + + const UINT command_index = LOWORD(invoke_info->lpVerb); + if (command_index >= m_context_menu_target_indexes.size()) + { + return S_OK; + } + + const size_t target_index = m_context_menu_target_indexes[command_index]; + if (target_index >= TARGET_FORMATS.size()) + { + return S_OK; + } + + const auto& target = TARGET_FORMATS[target_index]; + + std::vector paths; + if (FAILED(GetSelectedPaths(m_data_object.Get(), paths)) || !CanConvertPaths(paths, target.destination_group)) + { + return S_OK; + } + + (void)SendFormatConvertRequest(paths, target.destination); + return S_OK; + } + + IFACEMETHODIMP GetCommandString(UINT_PTR, UINT, UINT*, LPSTR, UINT) + { + return E_NOTIMPL; + } + +private: + ComPtr m_site; + ComPtr m_data_object; + std::vector m_context_menu_target_indexes; +}; + +CoCreatableClass(FileConverterContextMenuCommand) +CoCreatableClassWrlCreatorMapInclude(FileConverterContextMenuCommand) + +STDAPI DllGetActivationFactory(_In_ HSTRING activatableClassId, _COM_Outptr_ IActivationFactory** factory) +{ + return Module::GetModule().GetActivationFactory(activatableClassId, factory); +} + +STDAPI DllCanUnloadNow() +{ + return Module::GetModule().GetObjectCount() == 0 ? S_OK : S_FALSE; +} + +STDAPI DllGetClassObject(_In_ REFCLSID rclsid, _In_ REFIID riid, _COM_Outptr_ void** instance) +{ + return Module::GetModule().GetClassObject(rclsid, riid, instance); +} diff --git a/src/modules/FileConverter/FileConverterContextMenu/framework.h b/src/modules/FileConverter/FileConverterContextMenu/framework.h new file mode 100644 index 000000000000..7da6dbae3f4c --- /dev/null +++ b/src/modules/FileConverter/FileConverterContextMenu/framework.h @@ -0,0 +1,8 @@ +#pragma once + +#include +#include +#include + +#include +#include diff --git a/src/modules/FileConverter/FileConverterContextMenu/pack-contextmenu-msix.ps1 b/src/modules/FileConverter/FileConverterContextMenu/pack-contextmenu-msix.ps1 new file mode 100644 index 000000000000..d9792ba3887a --- /dev/null +++ b/src/modules/FileConverter/FileConverterContextMenu/pack-contextmenu-msix.ps1 @@ -0,0 +1,312 @@ +param( + [string]$Platform = "x64", + [string]$Configuration = "Debug", + [int]$KeepRecent = 5, + [switch]$UseDevIdentity, + [string]$DevPublisher = "CN=PowerToys-Dev", + [string]$DevIdentityName, + [switch]$CreateDevCertificate, + [switch]$SignPackage, + [switch]$RegisterPackage, + [switch]$UseLooseRegister +) + +$ErrorActionPreference = "Stop" + +function Get-MakeAppxPath { + $command = Get-Command MakeAppx.exe -ErrorAction SilentlyContinue + if ($null -ne $command) { + return $command.Source + } + + $kitsRoot = "C:\Program Files (x86)\Windows Kits\10\bin" + if (-not (Test-Path $kitsRoot)) { + throw "MakeAppx.exe was not found in PATH and Windows Kits bin folder does not exist." + } + + $candidates = Get-ChildItem -Path $kitsRoot -Recurse -Filter MakeAppx.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "\\x64\\MakeAppx\.exe$" } | + Sort-Object FullName -Descending + + if ($null -eq $candidates -or $candidates.Count -eq 0) { + throw "MakeAppx.exe was not found under $kitsRoot." + } + + return $candidates[0].FullName +} + +function Get-SignToolPath { + $command = Get-Command signtool.exe -ErrorAction SilentlyContinue + if ($null -ne $command) { + return $command.Source + } + + $kitsRoot = "C:\Program Files (x86)\Windows Kits\10\bin" + if (-not (Test-Path $kitsRoot)) { + throw "signtool.exe was not found in PATH and Windows Kits bin folder does not exist." + } + + $candidates = Get-ChildItem -Path $kitsRoot -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "\\x64\\signtool\.exe$" } | + Sort-Object FullName -Descending + + if ($null -eq $candidates -or $candidates.Count -eq 0) { + throw "signtool.exe was not found under $kitsRoot." + } + + return $candidates[0].FullName +} + +function Get-OrCreateDevCertificate { + param( + [string]$Subject, + [bool]$CreateIfMissing + ) + + $cert = Get-ChildItem -Path Cert:\CurrentUser\My | + Where-Object { $_.Subject -eq $Subject } | + Sort-Object NotAfter -Descending | + Select-Object -First 1 + + if ($null -ne $cert) { + return $cert + } + + if (-not $CreateIfMissing) { + throw "No certificate with subject '$Subject' was found in Cert:\CurrentUser\My. Re-run with -CreateDevCertificate." + } + + Write-Host "Creating new dev code-signing certificate:" $Subject + $cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject $Subject -CertStoreLocation "Cert:\CurrentUser\My" + return $cert +} + +function Ensure-CertificateTrustedForCurrentUser { + param([System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate) + + $trustedPeople = Get-ChildItem -Path Cert:\CurrentUser\TrustedPeople | + Where-Object { $_.Thumbprint -eq $Certificate.Thumbprint } | + Select-Object -First 1 + $trustedRoot = Get-ChildItem -Path Cert:\CurrentUser\Root | + Where-Object { $_.Thumbprint -eq $Certificate.Thumbprint } | + Select-Object -First 1 + + if (($null -ne $trustedPeople) -and ($null -ne $trustedRoot)) { + return + } + + $tempCer = Join-Path ([System.IO.Path]::GetTempPath()) ("powertoys-dev-" + [System.Guid]::NewGuid().ToString("N") + ".cer") + try { + Export-Certificate -Cert $Certificate -FilePath $tempCer -Type CERT | Out-Null + if ($null -eq $trustedPeople) { + Import-Certificate -FilePath $tempCer -CertStoreLocation "Cert:\CurrentUser\TrustedPeople" | Out-Null + Write-Host "Imported certificate into CurrentUser\\TrustedPeople:" $Certificate.Thumbprint + } + + if ($null -eq $trustedRoot) { + Import-Certificate -FilePath $tempCer -CertStoreLocation "Cert:\CurrentUser\Root" | Out-Null + Write-Host "Imported certificate into CurrentUser\\Root:" $Certificate.Thumbprint + } + } + finally { + Remove-Item -Path $tempCer -Force -ErrorAction SilentlyContinue + } +} + +function Set-ManifestDevIdentity { + param( + [string]$SourceManifestPath, + [string]$DestinationManifestPath, + [string]$Publisher, + [string]$IdentityName + ) + + [xml]$manifest = Get-Content -Path $SourceManifestPath + $ns = New-Object System.Xml.XmlNamespaceManager($manifest.NameTable) + $ns.AddNamespace("appx", "http://schemas.microsoft.com/appx/manifest/foundation/windows10") + $identityNode = $manifest.SelectSingleNode("/appx:Package/appx:Identity", $ns) + + if ($null -eq $identityNode) { + throw "Manifest Identity node was not found in $SourceManifestPath." + } + + $identityNode.Publisher = $Publisher + if (-not [string]::IsNullOrWhiteSpace($IdentityName)) { + $identityNode.Name = $IdentityName + } + + $manifest.Save($DestinationManifestPath) +} + +function Get-ManifestIdentity { + param([string]$ManifestPath) + + [xml]$manifest = Get-Content -Path $ManifestPath + $ns = New-Object System.Xml.XmlNamespaceManager($manifest.NameTable) + $ns.AddNamespace("appx", "http://schemas.microsoft.com/appx/manifest/foundation/windows10") + $identityNode = $manifest.SelectSingleNode("/appx:Package/appx:Identity", $ns) + if ($null -eq $identityNode) { + throw "Manifest Identity node was not found in $ManifestPath." + } + + return @{ + Name = [string]$identityNode.Name + Publisher = [string]$identityNode.Publisher + } +} + +function Get-ManifestComDllPath { + param([string]$ManifestPath) + + [xml]$manifest = Get-Content -Path $ManifestPath + $ns = New-Object System.Xml.XmlNamespaceManager($manifest.NameTable) + $ns.AddNamespace("com", "http://schemas.microsoft.com/appx/manifest/com/windows10") + $classNode = $manifest.SelectSingleNode("//com:Class", $ns) + + if ($null -eq $classNode) { + throw "Manifest com:Class node was not found in $ManifestPath." + } + + return [string]$classNode.Path +} + +function Invoke-LooseRegistration { + param( + [string]$OutDir, + [string]$ManifestPath, + [string]$BuiltContextMenuDllPath, + [string]$AssetsSourcePath + ) + + $registerRoot = Join-Path $OutDir "FileConverterDevRegister" + New-Item -Path $registerRoot -ItemType Directory -Force | Out-Null + New-Item -Path (Join-Path $registerRoot "Assets") -ItemType Directory -Force | Out-Null + + Copy-Item -Path $ManifestPath -Destination (Join-Path $registerRoot "AppxManifest.xml") -Force + Copy-Item -Path $BuiltContextMenuDllPath -Destination (Join-Path $registerRoot "PowerToys.FileConverterContextMenu.dll") -Force + Copy-Item -Path $AssetsSourcePath -Destination (Join-Path $registerRoot "Assets\\FileConverter") -Recurse -Force + + Add-AppxPackage -Register -Path (Join-Path $registerRoot "AppxManifest.xml") -ExternalLocation $registerRoot + return $registerRoot +} + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = (Resolve-Path (Join-Path $scriptDir "..\..\..\..")).Path +$outDir = Join-Path $repoRoot "$Platform\$Configuration\WinUI3Apps" + +New-Item -Path $outDir -ItemType Directory -Force | Out-Null + +$contextMenuDll = Join-Path $outDir "PowerToys.FileConverterContextMenu.dll" +if (-not (Test-Path $contextMenuDll)) { + throw "Context menu DLL was not found at $contextMenuDll. Build FileConverterContextMenu first." +} + +$stagingRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("FileConverterContextMenuPackage_" + [System.Guid]::NewGuid().ToString("N")) +New-Item -Path $stagingRoot -ItemType Directory -Force | Out-Null + +try { + $sourceManifest = Join-Path $scriptDir "AppxManifest.xml" + $stagedManifest = Join-Path $stagingRoot "AppxManifest.xml" + + if ($UseDevIdentity) { + Set-ManifestDevIdentity -SourceManifestPath $sourceManifest -DestinationManifestPath $stagedManifest -Publisher $DevPublisher -IdentityName $DevIdentityName + } + else { + Copy-Item -Path $sourceManifest -Destination $stagedManifest -Force + } + + Copy-Item -Path (Join-Path $scriptDir "Assets") -Destination (Join-Path $stagingRoot "Assets") -Recurse -Force + + $manifestComDllPath = Get-ManifestComDllPath -ManifestPath $stagedManifest + if ([System.IO.Path]::GetFileName($manifestComDllPath) -ne "PowerToys.FileConverterContextMenu.dll") { + throw "Manifest com:Class Path must point to PowerToys.FileConverterContextMenu.dll. Current value: $manifestComDllPath" + } + + $stagedComDll = Join-Path $stagingRoot "PowerToys.FileConverterContextMenu.dll" + Copy-Item -Path $contextMenuDll -Destination $stagedComDll -Force + + $makeAppx = Get-MakeAppxPath + $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" + $timestampedPackage = Join-Path $outDir "FileConverterContextMenuPackage.$timestamp.msix" + $stablePackage = Join-Path $outDir "FileConverterContextMenuPackage.msix" + + Write-Host "Using MakeAppx:" $makeAppx + Write-Host "Packaging to:" $timestampedPackage + + & $makeAppx pack /d $stagingRoot /p $timestampedPackage /nv + if ($LASTEXITCODE -ne 0) { + throw "MakeAppx packaging failed with exit code $LASTEXITCODE." + } + + $manifestIdentity = Get-ManifestIdentity -ManifestPath $stagedManifest + Write-Host "Manifest Identity Name:" $manifestIdentity.Name + Write-Host "Manifest Publisher:" $manifestIdentity.Publisher + + if ($SignPackage -or $RegisterPackage) { + $cert = Get-OrCreateDevCertificate -Subject $manifestIdentity.Publisher -CreateIfMissing:$CreateDevCertificate + Ensure-CertificateTrustedForCurrentUser -Certificate $cert + + $signTool = Get-SignToolPath + Write-Host "Using SignTool:" $signTool + & $signTool sign /fd SHA256 /sha1 $cert.Thumbprint /s My $timestampedPackage + if ($LASTEXITCODE -ne 0) { + throw "SignTool failed for $timestampedPackage with exit code $LASTEXITCODE." + } + } + + if ($RegisterPackage) { + $registerSucceeded = $false + if (-not $UseLooseRegister) { + try { + Write-Host "Registering sparse package from MSIX:" $timestampedPackage + Add-AppxPackage -Path $timestampedPackage -ExternalLocation $outDir -ForceUpdateFromAnyVersion + $registerSucceeded = $true + } + catch { + Write-Warning "MSIX registration failed. Falling back to loose registration with manifest + external location." + } + } + + if (-not $registerSucceeded) { + $looseRoot = Invoke-LooseRegistration -OutDir $outDir -ManifestPath $stagedManifest -BuiltContextMenuDllPath $contextMenuDll -AssetsSourcePath (Join-Path $scriptDir "Assets\\FileConverter") + Write-Host "Loose registration root:" $looseRoot + } + } + + try { + Copy-Item -Path $timestampedPackage -Destination $stablePackage -Force -ErrorAction Stop + Write-Host "Updated stable package:" $stablePackage + + if ($SignPackage -or $RegisterPackage) { + & $signTool sign /fd SHA256 /sha1 $cert.Thumbprint /s My $stablePackage + if ($LASTEXITCODE -ne 0) { + Write-Warning "Stable package signing failed with exit code $LASTEXITCODE." + } + } + } + catch { + Write-Warning "Stable package copy skipped because destination appears locked." + } + + $allPackages = Get-ChildItem -Path $outDir -Filter "FileConverterContextMenuPackage.*.msix" | + Sort-Object LastWriteTime -Descending + + if ($allPackages.Count -gt $KeepRecent) { + $allPackages | Select-Object -Skip $KeepRecent | Remove-Item -Force -ErrorAction SilentlyContinue + } + + if ($RegisterPackage) { + $installed = Get-AppxPackage | Where-Object { $_.Name -eq $manifestIdentity.Name } + if ($null -eq $installed) { + throw "Package registration did not produce an installed package for identity '$($manifestIdentity.Name)'." + } + + Write-Host "Registered package(s):" + $installed | Select-Object Name, PackageFullName, Publisher, Version, InstallLocation | Format-Table -AutoSize + } + + Write-Host "Timestamped package ready:" $timestampedPackage +} +finally { + Remove-Item -Path $stagingRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/src/modules/FileConverter/FileConverterContextMenu/pch.cpp b/src/modules/FileConverter/FileConverterContextMenu/pch.cpp new file mode 100644 index 000000000000..1d9f38c57d63 --- /dev/null +++ b/src/modules/FileConverter/FileConverterContextMenu/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/src/modules/FileConverter/FileConverterContextMenu/pch.h b/src/modules/FileConverter/FileConverterContextMenu/pch.h new file mode 100644 index 000000000000..8909fe245ea9 --- /dev/null +++ b/src/modules/FileConverter/FileConverterContextMenu/pch.h @@ -0,0 +1,3 @@ +#pragma once + +#include "framework.h" diff --git a/src/modules/FileConverter/FileConverterContextMenu/run-shell-verb-smoke.ps1 b/src/modules/FileConverter/FileConverterContextMenu/run-shell-verb-smoke.ps1 new file mode 100644 index 000000000000..4190242a8a55 --- /dev/null +++ b/src/modules/FileConverter/FileConverterContextMenu/run-shell-verb-smoke.ps1 @@ -0,0 +1,329 @@ +param( + [string]$TestDirectory = "x64\Debug\WinUI3Apps\FileConverterSmokeTest", + [string]$InputFileName = "sample.bmp", + [string]$ExpectedOutputFileName = "sample_converted.png", + [string]$VerbName = "Convert to...", + [int]$InvokeTimeoutMs = 20000, + [int]$OutputWaitTimeoutMs = 10000 +) + +$ErrorActionPreference = "Stop" + +$resolvedTestDir = (Resolve-Path $TestDirectory).Path +$outputPath = Join-Path $resolvedTestDir $ExpectedOutputFileName +if (Test-Path $outputPath) +{ + Remove-Item $outputPath -Force +} + +$code = @" +using System; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; + +public static class ShellVerbRunner +{ + public static string Invoke(string directoryPath, string fileName, string targetVerb, int timeoutMs) + { + string result = "Unknown"; + Exception error = null; + bool completed = false; + + Thread thread = new Thread(() => + { + try + { + Type shellType = Type.GetTypeFromProgID("Shell.Application"); + object shell = Activator.CreateInstance(shellType); + object folder = shellType.InvokeMember("NameSpace", BindingFlags.InvokeMethod, null, shell, new object[] { directoryPath }); + if (folder == null) + { + result = "Folder not found"; + return; + } + + Type folderType = folder.GetType(); + object item = folderType.InvokeMember("ParseName", BindingFlags.InvokeMethod, null, folder, new object[] { fileName }); + if (item == null) + { + result = "Item not found"; + return; + } + + Type itemType = item.GetType(); + object verbs = itemType.InvokeMember("Verbs", BindingFlags.InvokeMethod, null, item, null); + Type verbsType = verbs.GetType(); + int count = (int)verbsType.InvokeMember("Count", BindingFlags.GetProperty, null, verbs, null); + + for (int index = 0; index < count; index++) + { + object verb = verbsType.InvokeMember("Item", BindingFlags.InvokeMethod, null, verbs, new object[] { index }); + if (verb == null) + { + continue; + } + + Type verbType = verb.GetType(); + string name = (verbType.InvokeMember("Name", BindingFlags.GetProperty, null, verb, null) as string ?? string.Empty) + .Replace("&", string.Empty) + .Trim(); + + if (!string.Equals(name, targetVerb, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + verbType.InvokeMember("DoIt", BindingFlags.InvokeMethod, null, verb, null); + result = "Invoked"; + return; + } + + result = "Verb not found"; + } + catch (Exception ex) + { + Exception current = ex; + string details = string.Empty; + while (current != null) + { + details += current.GetType().FullName + ": " + current.Message + Environment.NewLine; + current = current.InnerException; + } + + error = new Exception(details.Trim()); + } + finally + { + completed = true; + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(timeoutMs); + + if (!completed) + { + return "Timeout"; + } + + if (error != null) + { + return "Error: " + error.Message; + } + + return result; + } +} + +[ComImport, Guid("A08CE4D0-FA25-44AB-B57C-C7B1C323E0B9"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +interface IExplorerCommand +{ + int GetTitle(IShellItemArray psiItemArray, out IntPtr ppszName); + int GetIcon(IShellItemArray psiItemArray, out IntPtr ppszIcon); + int GetToolTip(IShellItemArray psiItemArray, out IntPtr ppszInfotip); + int GetCanonicalName(out Guid pguidCommandName); + int GetState(IShellItemArray psiItemArray, int fOkToBeSlow, out uint pCmdState); + int Invoke(IShellItemArray psiItemArray, [MarshalAs(UnmanagedType.Interface)] object pbc); + int GetFlags(out uint pFlags); + int EnumSubCommands(out IEnumExplorerCommand ppEnum); +} + +[ComImport, Guid("A88826F8-186F-4987-AADE-EA0CEF8FBFE8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +interface IEnumExplorerCommand +{ + int Next(uint celt, out IExplorerCommand pUICommand, out uint pceltFetched); + int Skip(uint celt); + int Reset(); + int Clone(out IEnumExplorerCommand ppenum); +} + +[ComImport, Guid("B63EA76D-1F85-456F-A19C-48159EFA858B"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +interface IShellItemArray +{ +} + +[ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +interface IShellItem +{ +} + +public static class FileConverterExplorerCommandRunner +{ + [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)] + private static extern int SHCreateItemFromParsingName(string pszPath, IntPtr pbc, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out IShellItem ppv); + + [DllImport("shell32.dll", PreserveSig = true)] + private static extern int SHCreateShellItemArrayFromShellItem(IShellItem psi, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out IShellItemArray ppv); + + [DllImport("ole32.dll")] + private static extern void CoTaskMemFree(IntPtr pv); + + private static string NormalizeLabel(string value) + { + return (value ?? string.Empty).Replace("&", string.Empty).Trim(); + } + + public static string InvokeBySubCommand(string inputFilePath, string targetSubCommandLabel, int timeoutMs) + { + string result = "Unknown"; + Exception error = null; + bool completed = false; + + Thread thread = new Thread(() => + { + try + { + Guid shellItemGuid = new Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"); + int hr = SHCreateItemFromParsingName(inputFilePath, IntPtr.Zero, ref shellItemGuid, out IShellItem shellItem); + if (hr < 0) + { + result = "SHCreateItemFromParsingName failed: 0x" + hr.ToString("X8"); + return; + } + + Guid shellArrayGuid = new Guid("B63EA76D-1F85-456F-A19C-48159EFA858B"); + hr = SHCreateShellItemArrayFromShellItem(shellItem, ref shellArrayGuid, out IShellItemArray selection); + if (hr < 0) + { + result = "SHCreateShellItemArrayFromShellItem failed: 0x" + hr.ToString("X8"); + return; + } + + Type commandType = Type.GetTypeFromCLSID(new Guid("57EC18F5-24D5-4DC6-AE2E-9D0F7A39F8BA"), true); + IExplorerCommand root = (IExplorerCommand)Activator.CreateInstance(commandType); + + hr = root.EnumSubCommands(out IEnumExplorerCommand enumCommands); + if (hr < 0 || enumCommands == null) + { + result = "EnumSubCommands failed: 0x" + hr.ToString("X8"); + return; + } + + string expected = NormalizeLabel(targetSubCommandLabel); + bool requireMatch = !string.IsNullOrWhiteSpace(expected); + + while (true) + { + hr = enumCommands.Next(1, out IExplorerCommand command, out uint fetched); + if (fetched == 0 || command == null) + { + result = "Subcommand not found"; + return; + } + + IntPtr titlePtr = IntPtr.Zero; + string title = string.Empty; + int titleHr = command.GetTitle(selection, out titlePtr); + if (titleHr >= 0 && titlePtr != IntPtr.Zero) + { + title = Marshal.PtrToStringUni(titlePtr) ?? string.Empty; + CoTaskMemFree(titlePtr); + } + + string normalizedTitle = NormalizeLabel(title); + if (requireMatch && !string.Equals(normalizedTitle, expected, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + hr = command.Invoke(selection, null); + result = hr < 0 ? ("Invoke failed: 0x" + hr.ToString("X8")) : "Invoked"; + return; + } + } + catch (Exception ex) + { + Exception current = ex; + string details = string.Empty; + while (current != null) + { + details += current.GetType().FullName + ": " + current.Message + Environment.NewLine; + current = current.InnerException; + } + + error = new Exception(details.Trim()); + } + finally + { + completed = true; + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(timeoutMs); + + if (!completed) + { + return "Timeout"; + } + + if (error != null) + { + return "Error: " + error.Message; + } + + return result; + } +} +"@ + +Add-Type -TypeDefinition $code -Language CSharp +function Resolve-TargetSubCommandLabel([string]$ExpectedOutputName, [string]$RequestedVerb) +{ + if (-not [string]::IsNullOrWhiteSpace($RequestedVerb) -and $RequestedVerb -ne "Convert to...") + { + return $RequestedVerb + } + + $extension = [System.IO.Path]::GetExtension($ExpectedOutputName).ToLowerInvariant() + switch ($extension) + { + ".png" { return "PNG" } + ".jpg" { return "JPG" } + ".jpeg" { return "JPEG" } + ".bmp" { return "BMP" } + ".tif" { return "TIFF" } + ".tiff" { return "TIFF" } + ".heic" { return "HEIC" } + ".heif" { return "HEIF" } + ".webp" { return "WebP" } + default { return "PNG" } + } +} + +$invokeResult = [ShellVerbRunner]::Invoke($resolvedTestDir, $InputFileName, $VerbName, $InvokeTimeoutMs) +Write-Host "Invoke result: $invokeResult" + +if ($invokeResult -eq "Verb not found") +{ + $inputPath = Join-Path $resolvedTestDir $InputFileName + $subCommandLabel = Resolve-TargetSubCommandLabel -ExpectedOutputName $ExpectedOutputFileName -RequestedVerb $VerbName + Write-Host "Shell verb fallback: trying IExplorerCommand subcommand '$subCommandLabel'" + $invokeResult = [FileConverterExplorerCommandRunner]::InvokeBySubCommand($inputPath, $subCommandLabel, $InvokeTimeoutMs) + Write-Host "Fallback invoke result: $invokeResult" +} + +if ($invokeResult -ne "Invoked") +{ + throw "Verb invocation failed: $invokeResult" +} + +$waited = 0 +$step = 250 +while ($waited -lt $OutputWaitTimeoutMs -and -not (Test-Path $outputPath)) +{ + Start-Sleep -Milliseconds $step + $waited += $step +} + +if (-not (Test-Path $outputPath)) +{ + throw "Output file was not created: $outputPath" +} + +$item = Get-Item $outputPath +Write-Host "Created: $($item.FullName)" +Write-Host "Size: $($item.Length)" diff --git a/src/modules/FileConverter/FileConverterLib/Constants.h b/src/modules/FileConverter/FileConverterLib/Constants.h new file mode 100644 index 000000000000..2e18c0f2f211 --- /dev/null +++ b/src/modules/FileConverter/FileConverterLib/Constants.h @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +#pragma once + +namespace winrt::PowerToys::FileConverter::Constants +{ + inline constexpr wchar_t PipeNamePrefix[] = L"\\\\.\\pipe\\powertoys_fileconverter_"; + + inline constexpr wchar_t ActionFormatConvert[] = L"FormatConvert"; + + inline constexpr wchar_t JsonActionKey[] = L"action"; + inline constexpr wchar_t JsonDestinationKey[] = L"destination"; + inline constexpr wchar_t JsonFilesKey[] = L"files"; + + inline constexpr wchar_t FormatPng[] = L"png"; + inline constexpr wchar_t FormatJpg[] = L"jpg"; + inline constexpr wchar_t FormatJpeg[] = L"jpeg"; + inline constexpr wchar_t FormatBmp[] = L"bmp"; + inline constexpr wchar_t FormatTif[] = L"tif"; + inline constexpr wchar_t FormatTiff[] = L"tiff"; + inline constexpr wchar_t FormatHeic[] = L"heic"; + inline constexpr wchar_t FormatHeif[] = L"heif"; + inline constexpr wchar_t FormatWebp[] = L"webp"; + + inline constexpr wchar_t ExtensionPng[] = L".png"; + inline constexpr wchar_t ExtensionJpg[] = L".jpg"; + inline constexpr wchar_t ExtensionJpeg[] = L".jpeg"; + inline constexpr wchar_t ExtensionBmp[] = L".bmp"; + inline constexpr wchar_t ExtensionTif[] = L".tif"; + inline constexpr wchar_t ExtensionTiff[] = L".tiff"; + inline constexpr wchar_t ExtensionHeic[] = L".heic"; + inline constexpr wchar_t ExtensionHeif[] = L".heif"; + inline constexpr wchar_t ExtensionWebp[] = L".webp"; +} diff --git a/src/modules/FileConverter/FileConverterLib/FileConversionEngine.cpp b/src/modules/FileConverter/FileConverterLib/FileConversionEngine.cpp new file mode 100644 index 000000000000..ba352669ab21 --- /dev/null +++ b/src/modules/FileConverter/FileConverterLib/FileConversionEngine.cpp @@ -0,0 +1,329 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +#include "pch.h" + +#include "FileConversionEngine.h" + +#include +#include + +#include + +namespace +{ + std::wstring LoadLocalizedString(std::wstring_view key, std::wstring_view fallback) + { + try + { + static const auto loader = winrt::Windows::ApplicationModel::Resources::ResourceLoader::GetForViewIndependentUse(L"Resources"); + const auto value = loader.GetString(winrt::hstring{ key }); + if (!value.empty()) + { + return value.c_str(); + } + } + catch (...) + { + } + + return std::wstring{ fallback }; + } + + GUID ContainerFormatFor(file_converter::ImageFormat format) + { + switch (format) + { + case file_converter::ImageFormat::Jpeg: + return GUID_ContainerFormatJpeg; + case file_converter::ImageFormat::Bmp: + return GUID_ContainerFormatBmp; + case file_converter::ImageFormat::Tiff: + return GUID_ContainerFormatTiff; + case file_converter::ImageFormat::Heif: + return GUID_ContainerFormatHeif; + case file_converter::ImageFormat::Webp: + return GUID_ContainerFormatWebp; + case file_converter::ImageFormat::Png: + default: + return GUID_ContainerFormatPng; + } + } + + const wchar_t* ExtensionFor(file_converter::ImageFormat format) + { + switch (format) + { + case file_converter::ImageFormat::Jpeg: + return L".jpg"; + case file_converter::ImageFormat::Bmp: + return L".bmp"; + case file_converter::ImageFormat::Tiff: + return L".tiff"; + case file_converter::ImageFormat::Heif: + return L".heic"; + case file_converter::ImageFormat::Webp: + return L".webp"; + case file_converter::ImageFormat::Png: + default: + return L".png"; + } + } + + constexpr bool IsMissingCodecHresult(HRESULT hr) noexcept + { + return hr == WINCODEC_ERR_COMPONENTNOTFOUND || + hr == HRESULT_FROM_WIN32(ERROR_NOT_SUPPORTED); + } + + std::wstring HrMessage(std::wstring_view prefix, HRESULT hr) + { + std::wstringstream stream; + stream << prefix << L" HRESULT=0x" << std::hex << std::uppercase << static_cast(hr); + return stream.str(); + } + + struct ScopedCom + { + HRESULT hr; + bool uninitialize; + + ScopedCom() + : hr(E_FAIL), uninitialize(false) + { + // Prefer MTA, but gracefully handle callers that already initialized + // COM in a different apartment (e.g. Explorer STA threads). + hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + if (hr == RPC_E_CHANGED_MODE) + { + hr = S_OK; + return; + } + + if (SUCCEEDED(hr)) + { + uninitialize = true; + } + } + + ~ScopedCom() + { + if (uninitialize) + { + CoUninitialize(); + } + } + }; + + HRESULT CreateWicFactory(Microsoft::WRL::ComPtr& factory) + { + HRESULT hr = CoCreateInstance(CLSID_WICImagingFactory2, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&factory)); + if (FAILED(hr)) + { + hr = CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&factory)); + } + + return hr; + } + + file_converter::ConversionResult EnsureOutputEncoderAvailable(IWICImagingFactory* factory, file_converter::ImageFormat format) + { + if (factory == nullptr) + { + return { E_POINTER, LoadLocalizedString(L"FileConverter_Engine_WicFactoryNull", L"WIC factory is null.") }; + } + + Microsoft::WRL::ComPtr encoder_probe; + const HRESULT hr = factory->CreateEncoder(ContainerFormatFor(format), nullptr, &encoder_probe); + if (FAILED(hr)) + { + if (IsMissingCodecHresult(hr)) + { + const std::wstring error = LoadLocalizedString(L"FileConverter_Engine_NoEncoderInstalled", L"No WIC encoder is installed for destination format '") + ExtensionFor(format) + L"'."; + return { HRESULT_FROM_WIN32(ERROR_NOT_SUPPORTED), error }; + } + + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_CreateEncoderFailed", L"Failed creating image encoder."), hr) }; + } + + return { S_OK, L"" }; + } +} + +namespace file_converter +{ + ConversionResult IsOutputFormatSupported(ImageFormat format) + { + ScopedCom com; + if (FAILED(com.hr)) + { + return { com.hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_CoInitializeFailed", L"CoInitializeEx failed."), com.hr) }; + } + + Microsoft::WRL::ComPtr factory; + const HRESULT hr = CreateWicFactory(factory); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_CreateWicFactoryFailed", L"Failed creating WIC factory."), hr) }; + } + + return EnsureOutputEncoderAvailable(factory.Get(), format); + } + + ConversionResult ConvertImageFile(const std::wstring& input_path, const std::wstring& output_path, ImageFormat format) + { + ScopedCom com; + if (FAILED(com.hr)) + { + return { com.hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_CoInitializeFailed", L"CoInitializeEx failed."), com.hr) }; + } + + Microsoft::WRL::ComPtr factory; + HRESULT hr = CreateWicFactory(factory); + + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_CreateWicFactoryFailed", L"Failed creating WIC factory."), hr) }; + } + + const auto output_support = EnsureOutputEncoderAvailable(factory.Get(), format); + if (FAILED(output_support.hr)) + { + return output_support; + } + + Microsoft::WRL::ComPtr decoder; + hr = factory->CreateDecoderFromFilename(input_path.c_str(), nullptr, GENERIC_READ, WICDecodeMetadataCacheOnLoad, &decoder); + if (FAILED(hr)) + { + if (hr == WINCODEC_ERR_UNKNOWNIMAGEFORMAT || IsMissingCodecHresult(hr)) + { + return { hr, LoadLocalizedString(L"FileConverter_Engine_InputUnsupported", L"Input image format is not supported by installed WIC decoders.") }; + } + + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_OpenInputFailed", L"Failed opening input image."), hr) }; + } + + Microsoft::WRL::ComPtr source_frame; + hr = decoder->GetFrame(0, &source_frame); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_ReadFirstFrameFailed", L"Failed reading first image frame."), hr) }; + } + + UINT width = 0; + UINT height = 0; + hr = source_frame->GetSize(&width, &height); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_ReadImageSizeFailed", L"Failed reading image size."), hr) }; + } + + WICPixelFormatGUID pixel_format = {}; + hr = source_frame->GetPixelFormat(&pixel_format); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_ReadPixelFormatFailed", L"Failed reading source pixel format."), hr) }; + } + + Microsoft::WRL::ComPtr output_stream; + hr = factory->CreateStream(&output_stream); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_CreateStreamFailed", L"Failed creating WIC stream."), hr) }; + } + + hr = output_stream->InitializeFromFilename(output_path.c_str(), GENERIC_WRITE); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_OpenOutputFailed", L"Failed opening output path."), hr) }; + } + + Microsoft::WRL::ComPtr encoder; + hr = factory->CreateEncoder(ContainerFormatFor(format), nullptr, &encoder); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_CreateEncoderFailed", L"Failed creating image encoder."), hr) }; + } + + hr = encoder->Initialize(output_stream.Get(), WICBitmapEncoderNoCache); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_InitEncoderFailed", L"Failed initializing encoder."), hr) }; + } + + Microsoft::WRL::ComPtr target_frame; + Microsoft::WRL::ComPtr frame_properties; + hr = encoder->CreateNewFrame(&target_frame, &frame_properties); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_CreateTargetFrameFailed", L"Failed creating target frame."), hr) }; + } + + hr = target_frame->Initialize(frame_properties.Get()); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_InitTargetFrameFailed", L"Failed initializing target frame."), hr) }; + } + + hr = target_frame->SetSize(width, height); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_SetTargetSizeFailed", L"Failed setting target size."), hr) }; + } + + WICPixelFormatGUID target_pixel_format = pixel_format; + hr = target_frame->SetPixelFormat(&target_pixel_format); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_SetTargetPixelFormatFailed", L"Failed setting target pixel format."), hr) }; + } + + Microsoft::WRL::ComPtr source_for_write = source_frame; + Microsoft::WRL::ComPtr format_converter; + + if (!InlineIsEqualGUID(pixel_format, target_pixel_format)) + { + hr = factory->CreateFormatConverter(&format_converter); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_CreateFormatConverterFailed", L"Failed creating format converter."), hr) }; + } + + BOOL can_convert = FALSE; + hr = format_converter->CanConvert(pixel_format, target_pixel_format, &can_convert); + if (FAILED(hr) || !can_convert) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_UnsupportedPixelConversion", L"Source pixel format cannot be converted to target pixel format."), FAILED(hr) ? hr : E_FAIL) }; + } + + hr = format_converter->Initialize(source_frame.Get(), target_pixel_format, WICBitmapDitherTypeNone, nullptr, 0.0f, WICBitmapPaletteTypeCustom); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_InitFormatConverterFailed", L"Failed initializing format converter."), hr) }; + } + + source_for_write = format_converter; + } + + hr = target_frame->WriteSource(source_for_write.Get(), nullptr); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_WriteTargetFrameFailed", L"Failed writing target frame."), hr) }; + } + + hr = target_frame->Commit(); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_CommitTargetFrameFailed", L"Failed committing target frame."), hr) }; + } + + hr = encoder->Commit(); + if (FAILED(hr)) + { + return { hr, HrMessage(LoadLocalizedString(L"FileConverter_Engine_CommitEncoderFailed", L"Failed committing encoder."), hr) }; + } + + return { S_OK, L"" }; + } +} diff --git a/src/modules/FileConverter/FileConverterLib/FileConversionEngine.h b/src/modules/FileConverter/FileConverterLib/FileConversionEngine.h new file mode 100644 index 000000000000..ede84698fcee --- /dev/null +++ b/src/modules/FileConverter/FileConverterLib/FileConversionEngine.h @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +#pragma once + +#include + +namespace file_converter +{ + enum class ImageFormat + { + Png, + Jpeg, + Bmp, + Tiff, + Heif, + Webp, + }; + + struct ConversionResult + { + HRESULT hr = E_FAIL; + std::wstring error_message; + + [[nodiscard]] bool succeeded() const + { + return SUCCEEDED(hr); + } + }; + + ConversionResult ConvertImageFile(const std::wstring& input_path, const std::wstring& output_path, ImageFormat format); + ConversionResult IsOutputFormatSupported(ImageFormat format); +} diff --git a/src/modules/FileConverter/FileConverterLib/FileConverterLib.vcxproj b/src/modules/FileConverter/FileConverterLib/FileConverterLib.vcxproj new file mode 100644 index 000000000000..12c2024700f2 --- /dev/null +++ b/src/modules/FileConverter/FileConverterLib/FileConverterLib.vcxproj @@ -0,0 +1,46 @@ + + + + + 17.0 + Win32Proj + {2d6e2c29-43ce-4be9-b0fd-2f6240d04ef4} + FileConverterLib + FileConverterLib + PowerToys.FileConverterLib + + + StaticLibrary + + + + + + + + + + $(RepoRoot)$(Platform)\$(Configuration)\ + + + + _WINDOWS;_LIB;%(PreprocessorDefinitions) + $(RepoRoot)src\;%(AdditionalIncludeDirectories) + + + + + + + + + + + + + Create + + + + + diff --git a/src/modules/FileConverter/FileConverterLib/pch.cpp b/src/modules/FileConverter/FileConverterLib/pch.cpp new file mode 100644 index 000000000000..1d9f38c57d63 --- /dev/null +++ b/src/modules/FileConverter/FileConverterLib/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/src/modules/FileConverter/FileConverterLib/pch.h b/src/modules/FileConverter/FileConverterLib/pch.h new file mode 100644 index 000000000000..e45632296818 --- /dev/null +++ b/src/modules/FileConverter/FileConverterLib/pch.h @@ -0,0 +1,7 @@ +#pragma once + +#define WIN32_LEAN_AND_MEAN +#include +#include + +#include diff --git a/src/modules/FileConverter/FileConverterModuleInterface/FileConverterModuleInterface.vcxproj b/src/modules/FileConverter/FileConverterModuleInterface/FileConverterModuleInterface.vcxproj new file mode 100644 index 000000000000..62fc32510501 --- /dev/null +++ b/src/modules/FileConverter/FileConverterModuleInterface/FileConverterModuleInterface.vcxproj @@ -0,0 +1,64 @@ + + + + + + 17.0 + Win32Proj + {7f2a1e2c-3b84-4c84-9e8d-29ef6abf1d11} + FileConverterModuleInterface + FileConverterModuleInterface + PowerToys.FileConverterModuleInterface + + + DynamicLibrary + + + + + + + + + + $(RepoRoot)$(Platform)\$(Configuration)\ + + + + _WINDOWS;_USRDLL;%(PreprocessorDefinitions) + ..\FileConverterLib;$(RepoRoot)src\common\inc;$(RepoRoot)src\modules;$(RepoRoot)src\;%(AdditionalIncludeDirectories) + + + windowscodecs.lib;ole32.lib;shlwapi.lib;%(AdditionalDependencies) + $(OutDir)$(TargetName)$(TargetExt) + + + + + + + + + + + + Create + + + + + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + {6955446d-23f7-4023-9bb3-8657f904af99} + + + {2d6e2c29-43ce-4be9-b0fd-2f6240d04ef4} + + + + + + + + diff --git a/src/modules/FileConverter/FileConverterModuleInterface/dllmain.cpp b/src/modules/FileConverter/FileConverterModuleInterface/dllmain.cpp new file mode 100644 index 000000000000..e97864c7b44a --- /dev/null +++ b/src/modules/FileConverter/FileConverterModuleInterface/dllmain.cpp @@ -0,0 +1,805 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +#include "pch.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" IMAGE_DOS_HEADER __ImageBase; +namespace winrt_json = winrt::Windows::Data::Json; +namespace fc_constants = winrt::PowerToys::FileConverter::Constants; + +namespace +{ + constexpr wchar_t MODULE_NAME_FALLBACK[] = L"File Converter"; + constexpr wchar_t MODULE_KEY[] = L"FileConverter"; + constexpr wchar_t CONTEXT_MENU_PACKAGE_DISPLAY_NAME[] = L"FileConverterContextMenu"; + constexpr wchar_t CONTEXT_MENU_PACKAGE_FILE_NAME[] = L"FileConverterContextMenuPackage.msix"; + constexpr wchar_t CONTEXT_MENU_PACKAGE_FILE_PREFIX[] = L"FileConverterContextMenuPackage"; + constexpr wchar_t CONTEXT_MENU_HANDLER_CLSID[] = L"{57EC18F5-24D5-4DC6-AE2E-9D0F7A39F8BA}"; + std::wstring LoadLocalizedString(std::wstring_view key, std::wstring_view fallback) + { + try + { + static const auto loader = winrt::Windows::ApplicationModel::Resources::ResourceLoader::GetForViewIndependentUse(L"Resources"); + const auto value = loader.GetString(winrt::hstring{ key }); + if (!value.empty()) + { + return value.c_str(); + } + } + catch (...) + { + } + + return std::wstring{ fallback }; + } + + struct ConversionRequest + { + file_converter::ImageFormat format = file_converter::ImageFormat::Png; + std::vector files; + size_t skipped_entries = 0; + }; + + struct ConversionSummary + { + size_t succeeded = 0; + size_t missing_inputs = 0; + size_t failed = 0; + std::wstring first_failed_path; + std::wstring first_failed_error; + }; + + runtime_shell_ext::Spec BuildWin10ContextMenuSpec() + { + runtime_shell_ext::Spec spec; + spec.clsid = CONTEXT_MENU_HANDLER_CLSID; + spec.sentinelKey = L"Software\\Microsoft\\PowerToys\\FileConverter"; + spec.sentinelValue = L"ContextMenuRegisteredWin10"; + spec.dllFileCandidates = { + L"WinUI3Apps\\PowerToys.FileConverterContextMenu.dll", + L"PowerToys.FileConverterContextMenu.dll", + }; + spec.friendlyName = L"File Converter Context Menu"; + spec.systemFileAssocHandlerName = L"FileConverterContextMenu"; + spec.representativeSystemExt = L".bmp"; + spec.systemFileAssocExtensions = { + L".bmp", + L".dib", + L".gif", + L".jfif", + L".jpe", + L".jpeg", + L".jpg", + L".jxr", + L".png", + L".tif", + L".tiff", + L".wdp", + L".heic", + L".heif", + L".webp", + }; + return spec; + } + + std::optional FindLatestContextMenuPackage(const std::filesystem::path& context_menu_path) + { + const std::filesystem::path stable_package_path = context_menu_path / CONTEXT_MENU_PACKAGE_FILE_NAME; + if (std::filesystem::exists(stable_package_path)) + { + return stable_package_path; + } + + std::vector candidate_packages; + std::error_code ec; + for (std::filesystem::directory_iterator it(context_menu_path, ec); !ec && it != std::filesystem::directory_iterator(); it.increment(ec)) + { + if (!it->is_regular_file(ec)) + { + continue; + } + + const auto file_name = it->path().filename().wstring(); + const auto extension = it->path().extension().wstring(); + if (_wcsicmp(extension.c_str(), L".msix") != 0) + { + continue; + } + + if (file_name.rfind(CONTEXT_MENU_PACKAGE_FILE_PREFIX, 0) == 0) + { + candidate_packages.push_back(it->path()); + } + } + + if (candidate_packages.empty()) + { + return std::nullopt; + } + + std::sort(candidate_packages.begin(), candidate_packages.end(), [](const auto& lhs, const auto& rhs) { + std::error_code lhs_ec; + std::error_code rhs_ec; + const auto lhs_time = std::filesystem::last_write_time(lhs, lhs_ec); + const auto rhs_time = std::filesystem::last_write_time(rhs, rhs_ec); + + if (lhs_ec && rhs_ec) + { + return lhs.wstring() < rhs.wstring(); + } + + if (lhs_ec) + { + return true; + } + + if (rhs_ec) + { + return false; + } + + return lhs_time < rhs_time; + }); + + return candidate_packages.back(); + } + + std::wstring ToLower(std::wstring value) + { + std::transform(value.begin(), value.end(), value.begin(), [](wchar_t ch) { + return static_cast(towlower(ch)); + }); + + return value; + } + + std::optional ParseFormat(const std::wstring& value) + { + const std::wstring lower = ToLower(value); + + if (lower == fc_constants::FormatPng) + { + return file_converter::ImageFormat::Png; + } + + if (lower == fc_constants::FormatJpeg || lower == fc_constants::FormatJpg) + { + return file_converter::ImageFormat::Jpeg; + } + + if (lower == fc_constants::FormatBmp) + { + return file_converter::ImageFormat::Bmp; + } + + if (lower == fc_constants::FormatTiff || lower == fc_constants::FormatTif) + { + return file_converter::ImageFormat::Tiff; + } + + if (lower == fc_constants::FormatHeic || lower == fc_constants::FormatHeif) + { + return file_converter::ImageFormat::Heif; + } + + if (lower == fc_constants::FormatWebp) + { + return file_converter::ImageFormat::Webp; + } + + return std::nullopt; + } + + std::wstring ExtensionForFormat(file_converter::ImageFormat format) + { + switch (format) + { + case file_converter::ImageFormat::Jpeg: + return fc_constants::ExtensionJpg; + case file_converter::ImageFormat::Bmp: + return fc_constants::ExtensionBmp; + case file_converter::ImageFormat::Tiff: + return fc_constants::ExtensionTiff; + case file_converter::ImageFormat::Heif: + return fc_constants::ExtensionHeic; + case file_converter::ImageFormat::Webp: + return fc_constants::ExtensionWebp; + case file_converter::ImageFormat::Png: + default: + return fc_constants::ExtensionPng; + } + } + + std::wstring GetPipeNameForCurrentSession() + { + DWORD session_id = 0; + if (!ProcessIdToSessionId(GetCurrentProcessId(), &session_id)) + { + session_id = 0; + } + + return std::wstring(fc_constants::PipeNamePrefix) + std::to_wstring(session_id); + } + + std::string ReadPipeMessage(HANDLE pipe_handle) + { + constexpr DWORD BUFFER_SIZE = 4096; + char buffer[BUFFER_SIZE] = {}; + std::string payload; + + while (true) + { + DWORD bytes_read = 0; + const BOOL read_ok = ReadFile(pipe_handle, buffer, BUFFER_SIZE, &bytes_read, nullptr); + if (bytes_read > 0) + { + payload.append(buffer, bytes_read); + } + + if (read_ok) + { + break; + } + + const DWORD read_error = GetLastError(); + if (read_error == ERROR_MORE_DATA) + { + continue; + } + + if (read_error == ERROR_BROKEN_PIPE || read_error == ERROR_PIPE_NOT_CONNECTED) + { + break; + } + + Logger::warn(L"File Converter pipe read failed. Error={}", read_error); + payload.clear(); + break; + } + + return payload; + } + + bool TryParseFormatConvertRequest( + const std::string& payload, + ConversionRequest& request, + std::wstring& rejection_reason) + { + request = {}; + rejection_reason.clear(); + + if (payload.empty()) + { + rejection_reason = LoadLocalizedString(L"FileConverter_Error_EmptyPayload", L"empty payload"); + return false; + } + + winrt_json::JsonObject json_payload; + if (!winrt_json::JsonObject::TryParse(winrt::to_hstring(payload), json_payload)) + { + rejection_reason = LoadLocalizedString(L"FileConverter_Error_InvalidJson", L"invalid JSON"); + return false; + } + + if (!json_payload.HasKey(fc_constants::JsonActionKey)) + { + rejection_reason = LoadLocalizedString(L"FileConverter_Error_MissingAction", L"missing action"); + return false; + } + + const auto action_value = json_payload.GetNamedValue(fc_constants::JsonActionKey); + if (action_value.ValueType() != winrt_json::JsonValueType::String) + { + rejection_reason = LoadLocalizedString(L"FileConverter_Error_ActionNotString", L"action is not a string"); + return false; + } + + const auto action = json_payload.GetNamedString(fc_constants::JsonActionKey); + if (_wcsicmp(action.c_str(), fc_constants::ActionFormatConvert) != 0) + { + rejection_reason = LoadLocalizedString(L"FileConverter_Error_UnsupportedAction", L"unsupported action"); + return false; + } + + std::wstring destination = fc_constants::FormatPng; + if (json_payload.HasKey(fc_constants::JsonDestinationKey)) + { + const auto destination_value = json_payload.GetNamedValue(fc_constants::JsonDestinationKey); + if (destination_value.ValueType() == winrt_json::JsonValueType::String) + { + destination = json_payload.GetNamedString(fc_constants::JsonDestinationKey).c_str(); + } + } + + if (!json_payload.HasKey(fc_constants::JsonFilesKey)) + { + rejection_reason = LoadLocalizedString(L"FileConverter_Error_MissingFilesArray", L"missing files array"); + return false; + } + + const auto files_value = json_payload.GetNamedValue(fc_constants::JsonFilesKey); + if (files_value.ValueType() != winrt_json::JsonValueType::Array) + { + rejection_reason = LoadLocalizedString(L"FileConverter_Error_FilesNotArray", L"files is not an array"); + return false; + } + + const auto files_array = json_payload.GetNamedArray(fc_constants::JsonFilesKey); + for (const auto& file_value : files_array) + { + if (file_value.ValueType() != winrt_json::JsonValueType::String) + { + ++request.skipped_entries; + continue; + } + + const auto file_path = file_value.GetString(); + if (file_path.empty()) + { + ++request.skipped_entries; + continue; + } + + request.files.push_back(file_path.c_str()); + } + + if (request.files.empty()) + { + rejection_reason = LoadLocalizedString(L"FileConverter_Error_NoValidPaths", L"no valid file paths"); + return false; + } + + const auto parsed_format = ParseFormat(destination); + if (!parsed_format.has_value()) + { + rejection_reason = LoadLocalizedString(L"FileConverter_Error_UnsupportedDestination", L"unsupported destination format"); + return false; + } + + const auto support = file_converter::IsOutputFormatSupported(parsed_format.value()); + if (FAILED(support.hr)) + { + rejection_reason = support.error_message.empty() ? LoadLocalizedString(L"FileConverter_Error_DestinationUnavailable", L"requested destination format is unavailable") : support.error_message; + return false; + } + + request.format = parsed_format.value(); + return true; + } + + ConversionSummary ProcessFormatConvertRequest(const ConversionRequest& request) + { + ConversionSummary summary; + const std::wstring output_extension = ExtensionForFormat(request.format); + std::unordered_set seen_files; + + for (const auto& file : request.files) + { + if (!seen_files.insert(file).second) + { + continue; + } + + const std::filesystem::path input_path(file); + std::error_code ec; + if (input_path.empty() || !std::filesystem::exists(input_path, ec) || ec) + { + ++summary.missing_inputs; + continue; + } + + ec.clear(); + if (!std::filesystem::is_regular_file(input_path, ec) || ec) + { + ++summary.missing_inputs; + continue; + } + + std::filesystem::path output_path = input_path.parent_path() / input_path.stem(); + output_path += L"_converted"; + output_path += output_extension; + + const auto conversion = file_converter::ConvertImageFile(input_path.wstring(), output_path.wstring(), request.format); + if (conversion.succeeded()) + { + ++summary.succeeded; + continue; + } + + ++summary.failed; + if (summary.first_failed_path.empty()) + { + summary.first_failed_path = input_path.wstring(); + summary.first_failed_error = conversion.error_message; + } + } + + return summary; + } + + void EnsureContextMenuPackageRegistered() + { + if (!package::IsWin11OrGreater()) + { + return; + } + + const std::filesystem::path module_path = get_module_folderpath(reinterpret_cast(&__ImageBase)); + const std::filesystem::path context_menu_path = module_path / L"WinUI3Apps"; + if (!std::filesystem::exists(context_menu_path)) + { + return; + } + + const auto package_path = FindLatestContextMenuPackage(context_menu_path); + if (!package_path.has_value()) + { + return; + } + + if (!package::IsPackageRegisteredWithPowerToysVersion(CONTEXT_MENU_PACKAGE_DISPLAY_NAME)) + { + (void)package::RegisterSparsePackage(context_menu_path.wstring(), package_path->wstring()); + } + } + + void EnsureContextMenuRuntimeRegistered() + { + if (package::IsWin11OrGreater()) + { + return; + } + + (void)runtime_shell_ext::EnsureRegistered(BuildWin10ContextMenuSpec(), reinterpret_cast(&__ImageBase)); + } + + void UnregisterContextMenuRuntime() + { + if (package::IsWin11OrGreater()) + { + return; + } + + runtime_shell_ext::Unregister(BuildWin10ContextMenuSpec()); + } + + class FileConverterPipeOrchestrator + { + public: + void Start(const std::wstring& pipe_name) + { + if (m_running.exchange(true)) + { + return; + } + + m_pipe_name = pipe_name; + m_listener_thread = std::thread(&FileConverterPipeOrchestrator::ListenerLoop, this); + m_worker_thread = std::thread(&FileConverterPipeOrchestrator::WorkerLoop, this); + } + + void Stop() + { + if (!m_running.exchange(false)) + { + return; + } + + WakeListener(); + m_queue_cv.notify_all(); + + if (m_listener_thread.joinable()) + { + m_listener_thread.join(); + } + + if (m_worker_thread.joinable()) + { + m_worker_thread.join(); + } + + std::queue empty; + { + std::scoped_lock lock(m_queue_mutex); + std::swap(m_pending_payloads, empty); + } + } + + void EnqueueActionPayload(std::string payload) + { + if (!m_running.load()) + { + return; + } + + EnqueuePayload(std::move(payload)); + } + + ~FileConverterPipeOrchestrator() + { + Stop(); + } + + private: + void WakeListener() const + { + if (m_pipe_name.empty()) + { + return; + } + + HANDLE wake_handle = CreateFileW( + m_pipe_name.c_str(), + GENERIC_WRITE, + 0, + nullptr, + OPEN_EXISTING, + 0, + nullptr); + + if (wake_handle != INVALID_HANDLE_VALUE) + { + CloseHandle(wake_handle); + } + } + + void EnqueuePayload(std::string payload) + { + { + std::scoped_lock lock(m_queue_mutex); + m_pending_payloads.push(std::move(payload)); + } + + m_queue_cv.notify_one(); + } + + void ProcessPayload(const std::string& payload) + { + ConversionRequest request; + std::wstring rejection_reason; + if (!TryParseFormatConvertRequest(payload, request, rejection_reason)) + { + if (!rejection_reason.empty()) + { + Logger::warn(L"File Converter ignored malformed request: {}", rejection_reason); + } + + return; + } + + const auto summary = ProcessFormatConvertRequest(request); + + if (request.skipped_entries > 0) + { + Logger::warn(L"File Converter request skipped {} invalid file entries.", request.skipped_entries); + } + + if (summary.missing_inputs > 0) + { + Logger::warn(L"File Converter request skipped {} missing input files.", summary.missing_inputs); + } + + if (summary.failed > 0) + { + Logger::warn(L"File Converter conversion failed for {} file(s).", summary.failed); + if (!summary.first_failed_path.empty()) + { + Logger::warn(L"First conversion failure: path='{}' reason='{}'", summary.first_failed_path, summary.first_failed_error); + } + } + } + + void WorkerLoop() + { + while (true) + { + std::string payload; + { + std::unique_lock lock(m_queue_mutex); + m_queue_cv.wait(lock, [this] { + return !m_running.load() || !m_pending_payloads.empty(); + }); + + if (m_pending_payloads.empty()) + { + if (!m_running.load()) + { + break; + } + + continue; + } + + payload = std::move(m_pending_payloads.front()); + m_pending_payloads.pop(); + } + + ProcessPayload(payload); + } + } + + void ListenerLoop() + { + while (m_running.load()) + { + HANDLE pipe_handle = CreateNamedPipeW( + m_pipe_name.c_str(), + PIPE_ACCESS_INBOUND, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, + PIPE_UNLIMITED_INSTANCES, + 0, + 4096, + 0, + nullptr); + + if (pipe_handle == INVALID_HANDLE_VALUE) + { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + + const BOOL connected = ConnectNamedPipe(pipe_handle, nullptr) ? TRUE : (GetLastError() == ERROR_PIPE_CONNECTED); + if (!connected) + { + CloseHandle(pipe_handle); + if (m_running.load()) + { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + continue; + } + + const std::string payload = ReadPipeMessage(pipe_handle); + + // Inbound-only server pipes have no outbound data to flush. + // Skipping FlushFileBuffers avoids reconnect stalls on malformed-request sequences. + DisconnectNamedPipe(pipe_handle); + CloseHandle(pipe_handle); + + if (!m_running.load()) + { + break; + } + + if (!payload.empty()) + { + EnqueuePayload(payload); + } + } + } + + std::atomic m_running = false; + std::wstring m_pipe_name; + std::thread m_listener_thread; + std::thread m_worker_thread; + std::mutex m_queue_mutex; + std::condition_variable m_queue_cv; + std::queue m_pending_payloads; + }; +} + +class FileConverterModule : public PowertoyModuleIface +{ +public: + FileConverterModule() + { + // Avoid WinRT resource activation during module construction. + // The runner loads modules very early, and constructor failures can terminate startup. + m_name = MODULE_NAME_FALLBACK; + LoggerHelpers::init_logger(m_key, L"ModuleInterface", "fileconverter"); + } + + ~FileConverterModule() + { + disable(); + } + + void destroy() override + { + delete this; + } + + const wchar_t* get_name() override + { + return m_name.c_str(); + } + + const wchar_t* get_key() override + { + return m_key.c_str(); + } + + bool get_config(wchar_t* buffer, int* buffer_size) override + { + HINSTANCE hinstance = reinterpret_cast(&__ImageBase); + PowerToysSettings::Settings settings(hinstance, get_name()); + settings.set_description(LoadLocalizedString(L"FileConverter_Settings_Description", L"Convert image files to common formats.")); + settings.set_overview_link(L"https://aka.ms/PowerToysOverview_FileConverter"); + settings.set_icon_key(L"pt-file-converter"); + return settings.serialize_to_buffer(buffer, buffer_size); + } + + void set_config(const wchar_t* /*config*/) override + { + } + + void call_custom_action(const wchar_t* action) override + { + if (action == nullptr) + { + return; + } + + m_pipe_orchestrator.EnqueueActionPayload(winrt::to_string(action)); + } + + void enable() override + { + if (m_enabled) + { + return; + } + + EnsureContextMenuPackageRegistered(); + EnsureContextMenuRuntimeRegistered(); + m_pipe_orchestrator.Start(GetPipeNameForCurrentSession()); + m_enabled = true; + } + + void disable() override + { + if (!m_enabled) + { + return; + } + + m_pipe_orchestrator.Stop(); + UnregisterContextMenuRuntime(); + m_enabled = false; + } + + bool is_enabled() override + { + return m_enabled; + } + +private: + bool m_enabled = false; + std::wstring m_name; + std::wstring m_key = MODULE_KEY; + FileConverterPipeOrchestrator m_pipe_orchestrator; +}; + +extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() +{ + return new FileConverterModule(); +} diff --git a/src/modules/FileConverter/FileConverterModuleInterface/pch.cpp b/src/modules/FileConverter/FileConverterModuleInterface/pch.cpp new file mode 100644 index 000000000000..1d9f38c57d63 --- /dev/null +++ b/src/modules/FileConverter/FileConverterModuleInterface/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/src/modules/FileConverter/FileConverterModuleInterface/pch.h b/src/modules/FileConverter/FileConverterModuleInterface/pch.h new file mode 100644 index 000000000000..9b49d619e23a --- /dev/null +++ b/src/modules/FileConverter/FileConverterModuleInterface/pch.h @@ -0,0 +1,2 @@ +#define WIN32_LEAN_AND_MEAN +#include diff --git a/src/modules/FileConverter/Strings/en-us/Resources.resw b/src/modules/FileConverter/Strings/en-us/Resources.resw new file mode 100644 index 000000000000..83b81663cff4 --- /dev/null +++ b/src/modules/FileConverter/Strings/en-us/Resources.resw @@ -0,0 +1,151 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + File Converter + + + Convert image files to common formats. + + + + Convert to... + + + PNG + + + JPG + + + JPEG + + + BMP + + + TIFF + + + HEIC + + + HEIF + + + WebP + + + + empty payload + + + invalid JSON + + + missing action + + + action is not a string + + + unsupported action + + + missing files array + + + files is not an array + + + no valid file paths + + + unsupported destination format + + + requested destination format is unavailable + + + + WIC factory is null. + + + No WIC encoder is installed for destination format ' + + + CoInitializeEx failed. + + + Failed creating WIC factory. + + + Input image format is not supported by installed WIC decoders. + + + Failed opening input image. + + + Failed reading first image frame. + + + Failed reading image size. + + + Failed reading source pixel format. + + + Failed creating WIC stream. + + + Failed opening output path. + + + Failed creating image encoder. + + + Failed initializing encoder. + + + Failed creating target frame. + + + Failed initializing target frame. + + + Failed setting target size. + + + Failed setting target pixel format. + + + Failed creating format converter. + + + Source pixel format cannot be converted to target pixel format. + + + Failed initializing format converter. + + + Failed writing target frame. + + + Failed committing target frame. + + + Failed committing encoder. + + diff --git a/src/runner/main.cpp b/src/runner/main.cpp index 626bddc47f51..9fb08416915f 100644 --- a/src/runner/main.cpp +++ b/src/runner/main.cpp @@ -256,6 +256,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow std::vector knownModules = { L"PowerToys.FancyZonesModuleInterface.dll", L"PowerToys.powerpreview.dll", + L"PowerToys.FileConverterModuleInterface.dll", L"WinUI3Apps/PowerToys.ImageResizerExt.dll", L"PowerToys.KeyboardManager.dll", L"PowerToys.Launcher.dll", diff --git a/src/runner/powertoy_module.cpp b/src/runner/powertoy_module.cpp index eb1f7c4fd7ab..6c39882ae70a 100644 --- a/src/runner/powertoy_module.cpp +++ b/src/runner/powertoy_module.cpp @@ -5,6 +5,8 @@ #include #include +#include + std::map& modules() { static std::map modules; @@ -13,7 +15,22 @@ std::map& modules() PowertoyModule load_powertoy(const std::wstring_view filename) { - auto handle = winrt::check_pointer(LoadLibraryW(filename.data())); + const std::wstring module_name(filename); + HMODULE handle = LoadLibraryW(module_name.c_str()); + + // In local debug workflows, current directory may differ from the runner folder. + // Retry with an executable-relative full path to make module loading deterministic. + if (!handle) + { + wchar_t executable_path[MAX_PATH]{}; + if (GetModuleFileNameW(nullptr, executable_path, MAX_PATH) > 0) + { + const std::filesystem::path module_path = std::filesystem::path(executable_path).parent_path() / std::filesystem::path(module_name); + handle = LoadLibraryExW(module_path.c_str(), nullptr, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS | LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR); + } + } + + handle = winrt::check_pointer(handle); auto create = reinterpret_cast(GetProcAddress(handle, "powertoy_create")); if (!create) { diff --git a/src/runner/settings_window.cpp b/src/runner/settings_window.cpp index 022ad9d76ce9..7422f34d5744 100644 --- a/src/runner/settings_window.cpp +++ b/src/runner/settings_window.cpp @@ -807,6 +807,8 @@ std::string ESettingsWindowNames_to_string(ESettingsWindowNames value) return "ZoomIt"; case ESettingsWindowNames::PowerDisplay: return "PowerDisplay"; + case ESettingsWindowNames::FileConverter: + return "FileConverter"; default: { Logger::error(L"Can't convert ESettingsWindowNames value={} to string", static_cast(value)); @@ -950,6 +952,10 @@ ESettingsWindowNames ESettingsWindowNames_from_string(std::string value) { return ESettingsWindowNames::PowerDisplay; } + else if (value == "FileConverter") + { + return ESettingsWindowNames::FileConverter; + } else { Logger::error(L"Can't convert string value={} to ESettingsWindowNames", winrt::to_hstring(value)); diff --git a/src/runner/settings_window.h b/src/runner/settings_window.h index 4da4d70a7a36..ed69265223b6 100644 --- a/src/runner/settings_window.h +++ b/src/runner/settings_window.h @@ -37,6 +37,7 @@ enum class ESettingsWindowNames CmdPal, ZoomIt, PowerDisplay, + FileConverter, }; std::string ESettingsWindowNames_to_string(ESettingsWindowNames value); diff --git a/src/settings-ui/Settings.UI.Library/EnabledModules.cs b/src/settings-ui/Settings.UI.Library/EnabledModules.cs index f56176a1f0ce..61d749ebb8fa 100644 --- a/src/settings-ui/Settings.UI.Library/EnabledModules.cs +++ b/src/settings-ui/Settings.UI.Library/EnabledModules.cs @@ -382,6 +382,23 @@ public bool FileLocksmith } } + private bool fileConverter; // defaulting to off + + [JsonPropertyName("FileConverter")] + public bool FileConverter + { + get => fileConverter; + set + { + if (fileConverter != value) + { + LogTelemetryEvent(value); + fileConverter = value; + NotifyChange(); + } + } + } + private bool peek = true; [JsonPropertyName("Peek")] diff --git a/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs b/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs index 9b4581957c98..ea8a608d4f67 100644 --- a/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs +++ b/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs @@ -33,6 +33,7 @@ public static string GetModuleTypeFluentIconName(ModuleType moduleType) ModuleType.Workspaces => "ms-appx:///Assets/Settings/Icons/Workspaces.png", ModuleType.PowerOCR => "ms-appx:///Assets/Settings/Icons/TextExtractor.png", ModuleType.PowerAccent => "ms-appx:///Assets/Settings/Icons/QuickAccent.png", + ModuleType.FileConverter => "ms-appx:///Assets/Settings/Icons/FileManagement.png", ModuleType.MousePointerCrosshairs => "ms-appx:///Assets/Settings/Icons/MouseCrosshairs.png", ModuleType.MeasureTool => "ms-appx:///Assets/Settings/Icons/ScreenRuler.png", ModuleType.PowerLauncher => "ms-appx:///Assets/Settings/Icons/PowerToysRun.png", @@ -54,6 +55,7 @@ public static bool GetIsModuleEnabled(GeneralSettings generalSettingsConfig, Mod ModuleType.CursorWrap => generalSettingsConfig.Enabled.CursorWrap, ModuleType.EnvironmentVariables => generalSettingsConfig.Enabled.EnvironmentVariables, ModuleType.FancyZones => generalSettingsConfig.Enabled.FancyZones, + ModuleType.FileConverter => generalSettingsConfig.Enabled.FileConverter, ModuleType.FileLocksmith => generalSettingsConfig.Enabled.FileLocksmith, ModuleType.FindMyMouse => generalSettingsConfig.Enabled.FindMyMouse, ModuleType.Hosts => generalSettingsConfig.Enabled.Hosts, @@ -94,6 +96,7 @@ public static void SetIsModuleEnabled(GeneralSettings generalSettingsConfig, Mod case ModuleType.CursorWrap: generalSettingsConfig.Enabled.CursorWrap = isEnabled; break; case ModuleType.EnvironmentVariables: generalSettingsConfig.Enabled.EnvironmentVariables = isEnabled; break; case ModuleType.FancyZones: generalSettingsConfig.Enabled.FancyZones = isEnabled; break; + case ModuleType.FileConverter: generalSettingsConfig.Enabled.FileConverter = isEnabled; break; case ModuleType.FileLocksmith: generalSettingsConfig.Enabled.FileLocksmith = isEnabled; break; case ModuleType.FindMyMouse: generalSettingsConfig.Enabled.FindMyMouse = isEnabled; break; case ModuleType.Hosts: generalSettingsConfig.Enabled.Hosts = isEnabled; break; @@ -137,6 +140,7 @@ public static string GetModuleKey(ModuleType moduleType) ModuleType.CursorWrap => CursorWrapSettings.ModuleName, ModuleType.EnvironmentVariables => EnvironmentVariablesSettings.ModuleName, ModuleType.FancyZones => FancyZonesSettings.ModuleName, + ModuleType.FileConverter => "FileConverter", ModuleType.FileLocksmith => FileLocksmithSettings.ModuleName, ModuleType.FindMyMouse => FindMyMouseSettings.ModuleName, ModuleType.Hosts => HostsSettings.ModuleName, diff --git a/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs b/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs index 74c044db26dd..a51326f9ca12 100644 --- a/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs +++ b/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs @@ -25,6 +25,7 @@ public static GpoRuleConfigured GetModuleGpoConfiguration(ModuleType moduleType) case ModuleType.CursorWrap: return GPOWrapper.GetConfiguredCursorWrapEnabledValue(); case ModuleType.EnvironmentVariables: return GPOWrapper.GetConfiguredEnvironmentVariablesEnabledValue(); case ModuleType.FancyZones: return GPOWrapper.GetConfiguredFancyZonesEnabledValue(); + case ModuleType.FileConverter: return GpoRuleConfigured.Unavailable; case ModuleType.FileLocksmith: return GPOWrapper.GetConfiguredFileLocksmithEnabledValue(); case ModuleType.FindMyMouse: return GPOWrapper.GetConfiguredFindMyMouseEnabledValue(); case ModuleType.Hosts: return GPOWrapper.GetConfiguredHostsFileEditorEnabledValue(); @@ -65,6 +66,7 @@ public static System.Type GetModulePageType(ModuleType moduleType) ModuleType.LightSwitch => typeof(LightSwitchPage), ModuleType.EnvironmentVariables => typeof(EnvironmentVariablesPage), ModuleType.FancyZones => typeof(FancyZonesPage), + ModuleType.FileConverter => typeof(FileConverterPage), ModuleType.FileLocksmith => typeof(FileLocksmithPage), ModuleType.FindMyMouse => typeof(MouseUtilsPage), ModuleType.GeneralSettings => typeof(GeneralPage), diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs index 151f8f3bdc0d..d332256fe12c 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs @@ -447,6 +447,7 @@ public static Type GetPage(string settingWindow) case "Workspaces": return typeof(WorkspacesPage); case "CmdPal": return typeof(CmdPalPage); case "ZoomIt": return typeof(ZoomItPage); + case "FileConverter": return typeof(FileConverterPage); default: // Fallback to Dashboard Debug.Assert(false, "Unexpected SettingsWindow argument value"); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/FileConverterPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/FileConverterPage.xaml new file mode 100644 index 000000000000..7f75b9cedcbb --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/FileConverterPage.xaml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/FileConverterPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/FileConverterPage.xaml.cs new file mode 100644 index 000000000000..46d0d0c28d95 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/FileConverterPage.xaml.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.ViewModels; + +namespace Microsoft.PowerToys.Settings.UI.Views +{ + public sealed partial class FileConverterPage : NavigablePage, IRefreshablePage + { + private FileConverterViewModel ViewModel { get; } + + public FileConverterPage() + { + var settingsUtils = SettingsUtils.Default; + ViewModel = new FileConverterViewModel(settingsUtils, SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); + DataContext = ViewModel; + InitializeComponent(); + } + + public void RefreshEnabledState() + { + ViewModel.RefreshEnabledState(); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml index 0cfb826fcf51..39786eb651ea 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml @@ -358,6 +358,12 @@ helpers:NavHelper.NavigateTo="views:ImageResizerPage" AutomationProperties.AutomationId="ImageResizerNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ImageResizer.png}" /> + File Locksmith + + File Converter + + + A Windows shell extension to convert selected files to another image format. + + + Enable File Converter + + + Turn on File Converter in PowerToys Runner. + + + File Converter + Product name: Navigation view item name for FileConverter + File Locksmith Product name: Navigation view item name for FileLocksmith diff --git a/src/settings-ui/Settings.UI/ViewModels/FileConverterViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/FileConverterViewModel.cs new file mode 100644 index 000000000000..97f403979702 --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/FileConverterViewModel.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace Microsoft.PowerToys.Settings.UI.ViewModels +{ + public partial class FileConverterViewModel : Observable + { + private readonly GeneralSettings _generalSettingsConfig; + + private readonly Func _sendConfigMessage; + + private bool _isEnabled; + + public FileConverterViewModel( + SettingsUtils settingsUtils, + ISettingsRepository settingsRepository, + Func ipcMessageCallback) + { + ArgumentNullException.ThrowIfNull(settingsUtils); + ArgumentNullException.ThrowIfNull(settingsRepository); + ArgumentNullException.ThrowIfNull(ipcMessageCallback); + + _generalSettingsConfig = settingsRepository.SettingsConfig; + _sendConfigMessage = ipcMessageCallback; + + _isEnabled = _generalSettingsConfig.Enabled.FileConverter; + } + + public bool IsEnabled + { + get => _isEnabled; + set + { + if (_isEnabled != value) + { + _isEnabled = value; + _generalSettingsConfig.Enabled.FileConverter = value; + + OutGoingGeneralSettings outgoing = new(_generalSettingsConfig); + _sendConfigMessage(outgoing.ToString()); + + OnPropertyChanged(nameof(IsEnabled)); + } + } + } + + public void RefreshEnabledState() + { + _isEnabled = _generalSettingsConfig.Enabled.FileConverter; + OnPropertyChanged(nameof(IsEnabled)); + } + } +} diff --git a/tools/Verification scripts/FileConverter/negative-pipe-smoke.ps1 b/tools/Verification scripts/FileConverter/negative-pipe-smoke.ps1 new file mode 100644 index 000000000000..dcf671ed0f89 --- /dev/null +++ b/tools/Verification scripts/FileConverter/negative-pipe-smoke.ps1 @@ -0,0 +1,170 @@ +param( + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path, + [int]$PipeConnectTimeoutMs = 1000, + [int]$SendAttempts = 20, + [switch]$LeavePowerToysRunning +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Stop-PowerToysProcesses { + Get-Process PowerToys, PowerToys.Settings, PowerToys.QuickAccess -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue +} + +function Start-PowerToys { + param( + [string]$ExePath + ) + + if (-not (Test-Path $ExePath)) { + throw "PowerToys executable not found at: $ExePath" + } + + $proc = Start-Process -FilePath $ExePath -PassThru + Wait-Process -Id $proc.Id -Timeout 2 -ErrorAction SilentlyContinue | Out-Null + + $running = Get-Process -Id $proc.Id -ErrorAction SilentlyContinue + if ($null -eq $running) { + throw "PowerToys process exited before pipe checks." + } + + return $running +} + +function Send-PipePayload { + param( + [string]$PipeSimpleName, + [string]$Payload, + [int]$ConnectTimeoutMs, + [int]$Attempts + ) + + for ($i = 0; $i -lt $Attempts; $i++) { + $client = [System.IO.Pipes.NamedPipeClientStream]::new( + ".", + $PipeSimpleName, + [System.IO.Pipes.PipeDirection]::Out + ) + + try { + $client.Connect($ConnectTimeoutMs) + + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Payload) + $client.Write($bytes, 0, $bytes.Length) + return $true + } + catch { + if ($i -lt ($Attempts - 1)) { + Start-Sleep -Milliseconds 100 + } + } + finally { + $client.Dispose() + } + } + + return $false +} + +$powerToysExe = Join-Path $RepoRoot "x64\Debug\PowerToys.exe" +$sampleInput = Join-Path $RepoRoot "x64\Debug\WinUI3Apps\FileConverterSmokeTest\sample.bmp" +$outputFile = Join-Path $RepoRoot "x64\Debug\WinUI3Apps\FileConverterSmokeTest\sample_converted.png" +$runnerLog = Join-Path $RepoRoot "src\runner\x64\Debug\runner.log" + +if (-not (Test-Path $sampleInput)) { + throw "Sample input file not found at: $sampleInput" +} + +$escapedInput = $sampleInput -replace "\\", "\\\\" + +$cases = @( + [pscustomobject]@{ + Name = "invalid-json" + Payload = "not-json" + }, + [pscustomobject]@{ + Name = "missing-files" + Payload = '{"action":"FormatConvert","destination":"png"}' + }, + [pscustomobject]@{ + Name = "wrong-action" + Payload = ('{{"action":"NoOp","destination":"png","files":["{0}"]}}' -f $escapedInput) + }, + [pscustomobject]@{ + Name = "bad-files-array" + Payload = '{"action":"FormatConvert","destination":"png","files":[123,""]}' + } +) + +$results = @() + +for ($caseIndex = 0; $caseIndex -lt $cases.Count; $caseIndex++) { + $case = $cases[$caseIndex] + + Stop-PowerToysProcesses + $pt = Start-PowerToys -ExePath $powerToysExe + $pipeSimpleName = "powertoys_fileconverter_$($pt.SessionId)" + + if (Test-Path $outputFile) { + Remove-Item $outputFile -Force + } + + $sent = Send-PipePayload ` + -PipeSimpleName $pipeSimpleName ` + -Payload $case.Payload ` + -ConnectTimeoutMs $PipeConnectTimeoutMs ` + -Attempts $SendAttempts + + $deadline = [DateTime]::UtcNow.AddSeconds(2) + while ([DateTime]::UtcNow -lt $deadline -and -not (Test-Path $outputFile)) { + Start-Sleep -Milliseconds 50 + } + + $createdOutput = Test-Path $outputFile + if ($createdOutput) { + Remove-Item $outputFile -Force + } + + $results += [pscustomobject]@{ + Case = $case.Name + SentToPipe = $sent + OutputCreated = $createdOutput + Passed = ($sent -and -not $createdOutput) + } + + if (-not $LeavePowerToysRunning -or $caseIndex -lt ($cases.Count - 1)) { + Stop-PowerToysProcesses + } +} + +"Negative FileConverter Pipe Smoke Results" +$results | Format-Table -AutoSize | Out-String + +if (Test-Path $runnerLog) { + $interesting = Select-String -Path $runnerLog -Pattern "File Converter|malformed request|skipped|conversion failed" -CaseSensitive:$false -ErrorAction SilentlyContinue + if ($interesting) { + "Recent listener diagnostics from runner.log" + $interesting | Select-Object -Last 20 | ForEach-Object { $_.Line } + } + else { + "No matching listener diagnostics found in runner.log." + } +} +else { + "runner.log not found; diagnostics may be routed through ETW." +} + +if (-not $LeavePowerToysRunning) { + Stop-PowerToysProcesses +} + +$failed = @($results | Where-Object { -not $_.Passed }) +if ($failed.Count -gt 0) { + Write-Error "One or more negative smoke cases failed." + exit 1 +} + +"All negative smoke cases passed." +exit 0 diff --git a/tools/Verification scripts/FileConverter/phase3-queue-smoke.ps1 b/tools/Verification scripts/FileConverter/phase3-queue-smoke.ps1 new file mode 100644 index 000000000000..55f64b1a0de1 --- /dev/null +++ b/tools/Verification scripts/FileConverter/phase3-queue-smoke.ps1 @@ -0,0 +1,95 @@ +param( + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path, + [int]$PipeConnectTimeoutMs = 2000, + [switch]$LeavePowerToysRunning +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Stop-PowerToysProcesses { + Get-Process PowerToys, PowerToys.Settings, PowerToys.QuickAccess -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue +} + +function Send-PipePayload { + param( + [string]$PipeSimpleName, + [string]$Payload, + [int]$ConnectTimeoutMs + ) + + $client = [System.IO.Pipes.NamedPipeClientStream]::new( + ".", + $PipeSimpleName, + [System.IO.Pipes.PipeDirection]::Out + ) + + try { + $client.Connect($ConnectTimeoutMs) + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Payload) + $client.Write($bytes, 0, $bytes.Length) + $client.Flush() + } + finally { + $client.Dispose() + } +} + +$powerToysExe = Join-Path $RepoRoot "x64\Debug\PowerToys.exe" +$sampleDir = Join-Path $RepoRoot "x64\Debug\WinUI3Apps\FileConverterSmokeTest" +$input1 = Join-Path $sampleDir "sample.bmp" +$input2 = Join-Path $sampleDir "sample2.bmp" +$missing = Join-Path $sampleDir "missing-does-not-exist.bmp" +$output1 = Join-Path $sampleDir "sample_converted.png" +$output2 = Join-Path $sampleDir "sample2_converted.png" + +if (-not (Test-Path $powerToysExe)) { + throw "PowerToys executable not found at: $powerToysExe" +} + +if (-not (Test-Path $input1)) { + throw "Sample input file not found at: $input1" +} + +Copy-Item -LiteralPath $input1 -Destination $input2 -Force +Remove-Item $output1, $output2 -ErrorAction SilentlyContinue + +Stop-PowerToysProcesses +$pt = Start-Process -FilePath $powerToysExe -PassThru +Start-Sleep -Milliseconds 250 +$pt = Get-Process -Id $pt.Id -ErrorAction Stop +$pipeSimpleName = "powertoys_fileconverter_$($pt.SessionId)" + +$escapedInput1 = $input1 -replace "\\", "\\\\" +$escapedInput2 = $input2 -replace "\\", "\\\\" +$escapedMissing = $missing -replace "\\", "\\\\" + +$payload1 = ('{{"action":"FormatConvert","destination":"png","files":["{0}","{1}"]}}' -f $escapedInput1, $escapedMissing) +$payload2 = ('{{"action":"FormatConvert","destination":"png","files":["{0}"]}}' -f $escapedInput2) + +Send-PipePayload -PipeSimpleName $pipeSimpleName -Payload $payload1 -ConnectTimeoutMs $PipeConnectTimeoutMs +Send-PipePayload -PipeSimpleName $pipeSimpleName -Payload $payload2 -ConnectTimeoutMs $PipeConnectTimeoutMs + +$deadline = [DateTime]::UtcNow.AddSeconds(10) +while ([DateTime]::UtcNow -lt $deadline) { + if ((Test-Path $output1) -and (Test-Path $output2)) { + break + } + + Start-Sleep -Milliseconds 100 +} + +$ok1 = Test-Path $output1 +$ok2 = Test-Path $output2 + +if (-not $LeavePowerToysRunning) { + Stop-PowerToysProcesses +} + +if (-not $ok1 -or -not $ok2) { + throw "Phase 3 queue smoke failed. output1=$ok1 output2=$ok2" +} + +"Phase 3 queue smoke passed. output1=$ok1 output2=$ok2" +exit 0 diff --git a/tools/Verification scripts/FileConverter/phase6-format-matrix-smoke.ps1 b/tools/Verification scripts/FileConverter/phase6-format-matrix-smoke.ps1 new file mode 100644 index 000000000000..a785b5ff244d --- /dev/null +++ b/tools/Verification scripts/FileConverter/phase6-format-matrix-smoke.ps1 @@ -0,0 +1,183 @@ +param( + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path, + [int]$PipeConnectTimeoutMs = 2000, + [int]$PerCaseTimeoutMs = 6000, + [switch]$LeavePowerToysRunning +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Stop-PowerToysProcesses { + Get-Process PowerToys, PowerToys.Settings, PowerToys.QuickAccess -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue +} + +function Start-PowerToys { + param( + [string]$ExePath + ) + + if (-not (Test-Path -LiteralPath $ExePath)) { + throw "PowerToys executable not found at: $ExePath" + } + + $proc = Start-Process -FilePath $ExePath -PassThru + Wait-Process -Id $proc.Id -Timeout 2 -ErrorAction SilentlyContinue | Out-Null + + $running = Get-Process -Id $proc.Id -ErrorAction SilentlyContinue + if ($null -eq $running) { + throw "PowerToys process exited before pipe checks." + } + + return $running +} + +function Send-PipePayload { + param( + [string]$PipeSimpleName, + [string]$Payload, + [int]$ConnectTimeoutMs, + [int]$Attempts = 30, + [int]$RetryDelayMs = 100 + ) + + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + $client = [System.IO.Pipes.NamedPipeClientStream]::new( + ".", + $PipeSimpleName, + [System.IO.Pipes.PipeDirection]::Out + ) + + try { + $client.Connect($ConnectTimeoutMs) + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Payload) + $client.Write($bytes, 0, $bytes.Length) + $client.Flush() + return + } + catch { + if ($attempt -eq $Attempts) { + throw "Failed to send payload to pipe '$PipeSimpleName' after $Attempts attempts: $($_.Exception.Message)" + } + + Start-Sleep -Milliseconds $RetryDelayMs + } + finally { + $client.Dispose() + } + } +} + +function Wait-ForFile { + param( + [string]$Path, + [int]$TimeoutMs + ) + + $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMs) + while ([DateTime]::UtcNow -lt $deadline) { + if (Test-Path -LiteralPath $Path) { + return $true + } + + Start-Sleep -Milliseconds 100 + } + + return (Test-Path -LiteralPath $Path) +} + +$powerToysExe = Join-Path $RepoRoot "x64\Debug\PowerToys.exe" +$sampleDir = Join-Path $RepoRoot "x64\Debug\WinUI3Apps\FileConverterSmokeTest" +$sourcePath = Join-Path $sampleDir "sample.bmp" +$baseName = "sample_converted" + +if (-not (Test-Path -LiteralPath $sourcePath)) { + throw "Sample input file not found at: $sourcePath" +} + +$escapedInput = $sourcePath -replace "\\", "\\\\" + +$cases = @( + @{ Name = "png"; Destination = "png"; Extension = ".png"; Required = $true }, + @{ Name = "jpg"; Destination = "jpg"; Extension = ".jpg"; Required = $true }, + @{ Name = "jpeg"; Destination = "jpeg"; Extension = ".jpg"; Required = $true }, + @{ Name = "bmp"; Destination = "bmp"; Extension = ".bmp"; Required = $true }, + @{ Name = "tif"; Destination = "tif"; Extension = ".tiff"; Required = $true }, + @{ Name = "tiff"; Destination = "tiff"; Extension = ".tiff"; Required = $true }, + @{ Name = "webp"; Destination = "webp"; Extension = ".webp"; Required = $false }, + @{ Name = "heic"; Destination = "heic"; Extension = ".heic"; Required = $false }, + @{ Name = "heif"; Destination = "heif"; Extension = ".heic"; Required = $false } +) + +$results = @() + +foreach ($case in $cases) { + Stop-PowerToysProcesses + $pt = Start-PowerToys -ExePath $powerToysExe + $pipeSimpleName = "powertoys_fileconverter_$($pt.SessionId)" + + $outputPath = Join-Path $sampleDir ($baseName + $case.Extension) + Remove-Item -LiteralPath $outputPath -ErrorAction SilentlyContinue + + $payload = ('{{"action":"FormatConvert","destination":"{0}","files":["{1}"]}}' -f $case.Destination, $escapedInput) + Send-PipePayload -PipeSimpleName $pipeSimpleName -Payload $payload -ConnectTimeoutMs $PipeConnectTimeoutMs + + $created = Wait-ForFile -Path $outputPath -TimeoutMs $PerCaseTimeoutMs + $results += [PSCustomObject]@{ + Name = $case.Name + Destination = $case.Destination + Output = $outputPath + Created = $created + Required = $case.Required + } + + if ($case.Required -and -not $created) { + if (-not $LeavePowerToysRunning) { + Stop-PowerToysProcesses + } + + throw "Phase 6 matrix smoke failed for required destination '$($case.Destination)'. Expected output '$outputPath'." + } + + if (-not $LeavePowerToysRunning) { + Stop-PowerToysProcesses + } +} + +Stop-PowerToysProcesses +$pt = Start-PowerToys -ExePath $powerToysExe +$pipeSimpleName = "powertoys_fileconverter_$($pt.SessionId)" + +$preUnsupportedFiles = @( + Get-ChildItem -LiteralPath $sampleDir -Filter ($baseName + ".*") -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty Name +) +$unsupportedPayload = ('{{"action":"FormatConvert","destination":"gif","files":["{0}"]}}' -f $escapedInput) +Send-PipePayload -PipeSimpleName $pipeSimpleName -Payload $unsupportedPayload -ConnectTimeoutMs $PipeConnectTimeoutMs +Start-Sleep -Milliseconds 1500 +$postUnsupportedFiles = @( + Get-ChildItem -LiteralPath $sampleDir -Filter ($baseName + ".*") -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty Name +) +$newUnsupportedFiles = @($postUnsupportedFiles | Where-Object { $_ -notin $preUnsupportedFiles }) + +if (-not $LeavePowerToysRunning) { + Stop-PowerToysProcesses +} + +if ($newUnsupportedFiles.Count -gt 0) { + throw "Phase 6 matrix smoke failed. Unsupported destination 'gif' unexpectedly created output: $($newUnsupportedFiles -join ', ')." +} + +$requiredPassed = ($results | Where-Object { $_.Required -and $_.Created }).Count +$requiredTotal = ($results | Where-Object { $_.Required }).Count +$optionalPassed = ($results | Where-Object { -not $_.Required -and $_.Created }).Count +$optionalTotal = ($results | Where-Object { -not $_.Required }).Count + +"Phase 6 matrix smoke passed. Required=$requiredPassed/$requiredTotal Optional=$optionalPassed/$optionalTotal" +$results | ForEach-Object { + " - $($_.Name): created=$($_.Created) output=$($_.Output)" +} + +exit 0