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