diff --git a/AGENTS.md b/AGENTS.md index de782d5a4807..a3382b6625be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,48 +1,111 @@ -# Repository Instructions +# Files Development Guidelines -This repository contains the Files Windows desktop app, a WinUI-based file manager for Windows. The codebase includes the main app, reusable controls, storage layers, Win32/CsWin32 interop, packaging support, background/server components, and UI/interaction tests. +This project is a C#/.NET WinUI 3 desktop app; an alternative to File Explorer. -## Codebase Overview +- Protect context usage. Any command with unknown or potentially large output must be capped. Prefer targeted commands such as `rg`, `Get-Content -TotalCount`, `Select-Object -First`, or focused `git diff -- `; for example, `COMMAND 2>&1 | Select-Object -First 200`. If a line cap is still too noisy, narrow the query instead of dumping full output. +- Always follow `.editorconfig` +- Keep changed text files in CRLF line endings +- Keep comments concise and useful. Do not add comments that restate obvious code. +- Never read entire generated files in `bin` or `obj` unless the generated source is directly needed. +- Prefer targeted search over full file reads. +- Touch only what you must. Clean up only files you created or changed for the task. +- Treat file operations, shell integration, drag/drop, preview handlers, archive actions, settings persistence, and localization as high-risk areas. +- For Win32, COM, Shell, clipboard, hotkey, and file operation interop, prefer `src/Files.App.CsWin32`, `NativeMethods.txt`, and existing wrappers/helpers. +- Avoid ad hoc P/Invoke declarations when CsWin32 or existing interop code can cover the API. +- Do not edit generated CsWin32 output directly. Update source declarations, wrappers, or generator inputs instead. +- For UI work, use existing XAML resources, controls, converters, commands, and localization patterns. Avoid one-off styles or hard-coded user-visible strings. +- Start by identifying the smallest relevant project, feature area, and files for the task. +- Read nearby code before adding new abstractions. Prefer existing WinUI, MVVM, service, command, and storage patterns. +- Keep implementation scoped to the requested behavior. Avoid opportunistic refactors, formatting churn, dependency updates, and generated file edits. +- Treat tool output as evidence. When behavior changes, run the focused build that can prove it and report anything left unverified. + +## Codebase Structure ```text /src -├── Files.App // Main WinUI desktop app: startup, DI, views, view models, actions, services, dialogs, styles, assets, strings, and app helpers. -├── Files.App.CsWin32 // CsWin32 source-generated Win32 interop. Add APIs to NativeMethods.txt here. -├── Files.App.Controls // Reusable WinUI controls shared by the app. -├── Files.App.Storage // App-facing storage abstractions and storage implementation pieces. -├── Files.App.BackgroundTasks // Background task project. -├── Files.App.Server // App service/server behavior. -├── Files.App.Launcher // Launch-related entry points. -├── Files.App.OpenDialog // File open dialog-specific app project/folder. -├── Files.App.SaveDialog // File save dialog-specific app project/folder. -├── Files.App (Package) // Packaging-related app project assets. -├── Files.Core.Storage // Lower-level storage primitives that should not depend on the main WinUI app. -├── Files.Core.SourceGenerator // Roslyn source generators used by the solution. -└── Files.Shared // Shared models, helpers, and code used by multiple projects. +├── Files.App Main WinUI app +├── Files.App.Controls Shared app controls +├── Files.App.Storage App storage abstractions and implementations +├── Files.App.CsWin32 Generated/native Win32 interop project +├── Files.App.BackgroundTasks Background task project +├── Files.App.Server App service/server project +├── Files.Core.SourceGenerator Roslyn source generators and analyzers +├── Files.Core.Storage Core storage abstractions +└── Files.Shared Shared attributes, extensions, and common code ``` ```text /tests -├── Files.App.UITests // UI test assets and views. -├── Files.InteractionTests // Interaction tests used by CI automation. -└── Files.App.UnitTests // Placeholder/stale in this checkout; verify project files before assuming unit tests exist here. +├── Files.App.UITests +├── Files.App.UnitTests +└── Files.InteractionTests ``` -## When Dealing With Interop Code +## Build + +Prefer explicit platform/configuration builds. +Unless the task is specifically about resolving or inspecting warnings, add `-v:quiet -clp:ErrorsOnly` to `msbuild` commands so the log proves success or shows only actionable errors. + +```powershell +msbuild -restore Files.slnx -p:Configuration=Debug -p:Platform=x64 -v:quiet -clp:ErrorsOnly +``` + +If `msbuild` isn't available in the current shell, run it from Visual Studio Developer PowerShell. Match `-arch`, `-host_arch`, and `-p:Platform` to the platform you're verifying; use `x64` for x64 work and `arm64` for ARM64 work. + +```powershell +pwsh.exe -NoProfile -Command "& { + Import-Module 'C:\Program Files\Microsoft Visual Studio\18\Professional\Common7\Tools\Microsoft.VisualStudio.DevShell.dll' + Enter-VsDevShell 1ba2cc4e -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -host_arch=x64' + msbuild -restore src/Files.App/Files.App.csproj -p:Configuration=Debug -p:Platform=x64 -v:quiet -clp:ErrorsOnly +}" +``` + +For focused C# work, build the affected project first. +Do not run build commands in parallel. + +```powershell +msbuild -restore src/Files.Shared/Files.Shared.csproj -p:Configuration=Debug -p:Platform=x64 -v:quiet -clp:ErrorsOnly +msbuild -restore src/Files.Core.SourceGenerator/Files.Core.SourceGenerator.csproj -p:Configuration=Debug -p:Platform=x64 -v:quiet -clp:ErrorsOnly +msbuild -restore src/Files.App/Files.App.csproj -p:Configuration=Debug -p:Platform=x64 -v:quiet -clp:ErrorsOnly +``` -When the user asks to convert marshaled interop code into unmarshaled interop, or asks to remove trim-unsafe manual P/Invoke definitions, see [docs/interop-unmarshaled-conversion.md](docs/interop-unmarshaled-conversion.md). +## Test -Prefer adding APIs and related generated types to `src/Files.App.CsWin32/NativeMethods.txt`, then update the callees to use CsWin32-generated `Windows.Win32.PInvoke` APIs directly. Do not leave manual `DllImport` definitions in place or replace them with local `LibraryImport` declarations when CsWin32 can generate the API. +We currently don't have a suitable set of tests for AI agents. Just make sure that the builds succeed. -## When Building the App +## Commit & Push -Use `.github/workflows/ci.yml` as the source of truth for building. -For normal local verification, build with MSBuild restore and explicit configuration/platform; packaging is not required. +When asked to commit, run these commands beforehand: ```powershell -msbuild -restore src\Files.App\Files.App.csproj /p:Configuration=Debug /p:Platform=x64 +git status --short +git diff --check ``` -## When Packaging the App +Do not revert unrelated user changes. Stage only files that belong to the requested change. + +Use concise commit messages that describe the behavior change, for example: + +```text +Add source-generated settings storage +``` + +## Open a PR + +When asked to open a PR, use a short PR title that names the behavior, not the implementation mechanics only, and prepend the PR type: + +- "Fix": use this prefix when the linked issue is a bug +- "Feature": use this prefix when the linked issue is a feature request +- "Code Quality": anything else + +The repository maintainers draft release notes based on these PR types: only fixes and feature requests are listed. + +Good examples: + +```text +Fix: Fixed an issue where thumbnails wouldn't refresh when a file was updated +Feature: Add support for previewing AVI files in the Preview Pane +Code Quality: Add source-generated settings serialization +``` -Use `.github/workflows/ci.yml` as the source of truth for packaging. Adjust `Configuration`, `Platform`, and `AppxBundlePlatforms` as needed. +For the PR body, follow `./.github/PULL_REQUEST_TEMPLATE.md`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000000..47dc3e3d863c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/Files.App.CsWin32/ManualGuid.cs b/src/Files.App.CsWin32/ManualGuid.cs index f95973c45048..48728d9b52a3 100644 --- a/src/Files.App.CsWin32/ManualGuid.cs +++ b/src/Files.App.CsWin32/ManualGuid.cs @@ -1,4 +1,4 @@ -// Copyright (c) Files Community +// Copyright (c) Files Community // Licensed under the MIT License. using System; @@ -62,6 +62,9 @@ public static Guid* IID_IStorageProviderStatusUISourceFactory [GuidRVAGen.Guid("000214F4-0000-0000-C000-000000000046")] public static partial Guid* IID_IContextMenu2 { get; } + + [GuidRVAGen.Guid("0000010E-0000-0000-C000-000000000046")] + public static partial Guid* IID_IDataObject { get; } } public static unsafe partial class CLSID @@ -89,6 +92,9 @@ public static unsafe partial class CLSID [GuidRVAGen.Guid("D969A300-E7FF-11d0-A93B-00A0C90F2719")] public static partial Guid* CLSID_NewMenu { get; } + + [GuidRVAGen.Guid("09799AFB-AD67-11D1-ABCD-00C04FC30936")] + public static partial Guid* CLSID_OpenWithMenu { get; } } public static unsafe partial class BHID @@ -98,6 +104,9 @@ public static unsafe partial class BHID [GuidRVAGen.Guid("94F60519-2850-4924-AA5A-D15E84868039")] public static partial Guid* BHID_EnumItems { get; } + + [GuidRVAGen.Guid("B8C0BD9F-ED24-455C-83E6-D5390C4FE8C4")] + public static partial Guid* BHID_DataObject { get; } } public static unsafe partial class FOLDERID diff --git a/src/Files.App.CsWin32/NativeMethods.txt b/src/Files.App.CsWin32/NativeMethods.txt index 228d31d080d3..caeda9006bd8 100644 --- a/src/Files.App.CsWin32/NativeMethods.txt +++ b/src/Files.App.CsWin32/NativeMethods.txt @@ -262,6 +262,7 @@ S_FALSE IExecuteCommand IObjectWithSelection SHCreateShellItemArrayFromShellItem +IDataObject IShellExtInit IContextMenu2 GetSubMenu diff --git a/src/Files.App/Data/Commands/Manager/CommandGroupManager.cs b/src/Files.App/Data/Commands/Manager/CommandGroupManager.cs index e56eda22e007..bd005c00e0b0 100644 --- a/src/Files.App/Data/Commands/Manager/CommandGroupManager.cs +++ b/src/Files.App/Data/Commands/Manager/CommandGroupManager.cs @@ -89,6 +89,31 @@ public override string AutomationId ]; } +internal sealed class OpenWithCommandGroup : CommandGroup +{ + public override string Name => "OpenWith"; + + public override string DisplayName + => Strings.OpenWith.GetLocalizedResource(); + + public override string Description + => Strings.OpenItemWithApplicationPickerDescription.GetLocalizedFormatResource(1); + + public override RichGlyph Glyph + => new(themedIconStyle: "App.ThemedIcons.OpenWith"); + + public override string AccessKey + => "O"; + + public override ActionCategory Category + => ActionCategory.Open; + + public override IReadOnlyList Commands => + [ + CommandCodes.OpenItemWithApplicationPicker, + ]; +} + internal sealed class EditTagsCommandGroup : CommandGroup { public override string Name => "EditTags"; @@ -111,4 +136,4 @@ public override ActionCategory Category // No predefined commands — the flyout contents are built dynamically // from the user's defined file tags via FileTagsContextMenu. public override IReadOnlyList Commands => []; -} \ No newline at end of file +} diff --git a/src/Files.App/Properties/launchSettings.json b/src/Files.App/Properties/launchSettings.json index 7270d2833e73..ef0b4bc72ea4 100644 --- a/src/Files.App/Properties/launchSettings.json +++ b/src/Files.App/Properties/launchSettings.json @@ -1,6 +1,6 @@ { "profiles": { - "MsixPackage": { + "Run": { "commandName": "MsixPackage" } } diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index efd1be450cc9..8bf1b02756f1 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -1376,6 +1376,9 @@ Open with + + Choose another app + File icon diff --git a/src/Files.App/UserControls/Toolbar.xaml.cs b/src/Files.App/UserControls/Toolbar.xaml.cs index 6dd95ee4c8c9..fce5bd2930ac 100644 --- a/src/Files.App/UserControls/Toolbar.xaml.cs +++ b/src/Files.App/UserControls/Toolbar.xaml.cs @@ -9,6 +9,7 @@ using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Media.Imaging; using System.IO; +using Windows.Win32.UI.WindowsAndMessaging; using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer; using FlyoutPlacementMode = Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode; @@ -24,6 +25,8 @@ public sealed partial class Toolbar : UserControl private readonly DispatcherQueueTimer toolbarRefreshTimer; private readonly IContentPageContext PageContext = Ioc.Default.GetRequiredService(); private UserControls.Menus.FileTagsContextMenu? editTagsMenu; + private OpenWithMenu? openWithMenu; + private int openWithFlyoutRequestId; [GeneratedDependencyProperty] public partial NavigationToolbarViewModel? ViewModel { get; set; } @@ -55,6 +58,7 @@ private void Toolbar_Unloaded(object sender, RoutedEventArgs e) UserSettingsService.AppearanceSettingsService.PropertyChanged -= AppearanceSettings_PropertyChanged; if (editTagsMenu is not null) editTagsMenu.TagsChanged -= EditTagsMenu_TagsChanged; + openWithMenu?.Dispose(); } partial void OnViewModelChanged(NavigationToolbarViewModel? newValue) @@ -206,7 +210,17 @@ and not ContentPageTypes.RecycleBin : FlyoutPlacementMode.Bottom }; - ((MenuFlyout)btn.Flyout).Opening += (s, _) => PopulateGroupFlyout((MenuFlyout)s, group); + ((MenuFlyout)btn.Flyout).Opening += async (s, _) => + { + try + { + await PopulateGroupFlyoutAsync((MenuFlyout)s, group); + } + catch (Exception ex) + { + Debug.WriteLine(ex); + } + }; btn.IsEnabled = group.Commands.Any(c => c is not CommandCodes.None && Commands[c].IsExecutable); } @@ -370,9 +384,16 @@ private void UnpinToolbarItem(string contextId, int index, ToolbarItemSettingsEn } } - private void PopulateGroupFlyout(MenuFlyout flyout, CommandGroup group) + private async Task PopulateGroupFlyoutAsync(MenuFlyout flyout, CommandGroup group) { flyout.Items.Clear(); + + if (group is OpenWithCommandGroup) + { + await PopulateOpenWithFlyoutAsync(flyout); + return; + } + foreach (var code in group.Commands) if (Commands[code] is { Code: not CommandCodes.None } cmd) flyout.Items.Add(CreateGroupMenuItem(cmd)); @@ -384,7 +405,7 @@ private void PopulateGroupFlyout(MenuFlyout flyout, CommandGroup group) var fmt = $"D{entries.Count.ToString().Length}"; for (int i = 0; i < entries.Count; i++) { - var item = CreateShellNewEntryMenuItem(entries[i]); + var item = await CreateShellNewEntryMenuItemAsync(entries[i]); item.AccessKey = (i + 1).ToString(fmt); item.Command = ViewModel!.CreateNewFileCommand; item.CommandParameter = entries[i]; @@ -393,13 +414,88 @@ private void PopulateGroupFlyout(MenuFlyout flyout, CommandGroup group) } } - private static MenuFlyoutItem CreateShellNewEntryMenuItem(ShellNewEntry entry) + private async Task PopulateOpenWithFlyoutAsync(MenuFlyout flyout) + { + var requestId = ++openWithFlyoutRequestId; + + flyout.Items.Add(new MenuFlyoutItem + { + Text = Strings.Loading.GetLocalizedResource(), + IsEnabled = false, + }); + + openWithMenu?.Dispose(); + openWithMenu = null; + + OpenWithMenu? loadedOpenWithMenu = null; + if (PageContext.SelectedItems.Count is 1 && PageContext.SelectedItem?.ItemPath is string path) + loadedOpenWithMenu = await OpenWithMenu.GetForFileAsync(path); + + if (requestId != openWithFlyoutRequestId) + { + loadedOpenWithMenu?.Dispose(); + return; + } + + openWithMenu = loadedOpenWithMenu; + + flyout.Items.Clear(); + + if (openWithMenu is not null) + { + foreach (var item in openWithMenu.Items.Where(x => x.Type is MENU_ITEM_TYPE.MFT_STRING && !string.IsNullOrWhiteSpace(x.Label))) + flyout.Items.Add(await CreateOpenWithMenuItemAsync(openWithMenu, item)); + } + + if (flyout.Items.Count == 0) + flyout.Items.Add(CreateChooseAnotherAppMenuItem()); + } + + private static async Task CreateOpenWithMenuItemAsync(OpenWithMenu menu, Win32ContextMenuItem entry) + { + MenuFlyoutItem item; + if (entry.Icon is { Length: > 0 }) + { + using var ms = new MemoryStream(entry.Icon); + var image = new BitmapImage(); + await image.SetSourceAsync(ms.AsRandomAccessStream()); + item = new MenuFlyoutItemWithImage { BitmapIcon = image }; + } + else + { + item = new MenuFlyoutItem(); + } + + item.Text = entry.Label; + item.Command = new AsyncRelayCommand(async () => await menu.InvokeItem(entry.ID)); + + return item; + } + + private MenuFlyoutItem CreateChooseAnotherAppMenuItem() + { + var cmd = Commands[CommandCodes.OpenItemWithApplicationPicker]; + + var item = new MenuFlyoutItem + { + Text = Strings.ChooseAnotherApp.GetLocalizedResource(), + Command = cmd, + Visibility = cmd.IsExecutable ? Visibility.Visible : Visibility.Collapsed, + }; + + if (!string.IsNullOrEmpty(cmd.AutomationId)) + AutomationProperties.SetAutomationId(item, cmd.AutomationId); + + return item; + } + + private static async Task CreateShellNewEntryMenuItemAsync(ShellNewEntry entry) { if (!string.IsNullOrEmpty(entry.IconBase64)) { using var ms = new MemoryStream(Convert.FromBase64String(entry.IconBase64)); var image = new BitmapImage(); - _ = image.SetSourceAsync(ms.AsRandomAccessStream()); + await image.SetSourceAsync(ms.AsRandomAccessStream()); return new MenuFlyoutItemWithImage { Text = entry.Name, BitmapIcon = image }; } return new MenuFlyoutItem { Text = entry.Name, Icon = new FontIcon { Glyph = "\xE7C3" } }; diff --git a/src/Files.App/Utils/Shell/OpenWithMenu.cs b/src/Files.App/Utils/Shell/OpenWithMenu.cs new file mode 100644 index 000000000000..098b7f365b57 --- /dev/null +++ b/src/Files.App/Utils/Shell/OpenWithMenu.cs @@ -0,0 +1,293 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.Data.Items; +using System.Drawing; +using System.Runtime.InteropServices; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Gdi; +using Windows.Win32.System.Com; +using Windows.Win32.System.Memory; +using Windows.Win32.UI.Shell; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Files.App.Utils.Shell +{ + internal sealed class OpenWithMenu : IDisposable + { + private static readonly ImageConverter IconConverter = new(); + + private readonly ThreadWithMessageQueue owningThread; + private unsafe IContextMenu* contextMenu; + private HMENU menu; + private bool disposedValue; + + private unsafe OpenWithMenu(IContextMenu* contextMenu, HMENU menu, ThreadWithMessageQueue owningThread) + { + this.contextMenu = contextMenu; + this.menu = menu; + this.owningThread = owningThread; + Items = []; + } + + public List Items { get; } + + public static async Task GetForFileAsync(string path) + { + var owningThread = new ThreadWithMessageQueue(); + var menu = await owningThread.PostMethod(() => + { + unsafe + { + return Create(path, owningThread); + } + }); + if (menu is null) + owningThread.Dispose(); + + return menu; + } + + public async Task InvokeItem(int itemId) + { + unsafe + { + if (itemId < 0 || contextMenu is null) + return false; + } + + try + { + var currentWindows = Win32Helper.GetDesktopWindows(); + var hr = await owningThread.PostMethod(() => + { + unsafe + { + return InvokeItemCore(itemId); + } + }); + if (hr.Failed) + return false; + + Win32Helper.BringToForeground(currentWindows); + + return true; + } + catch (Exception ex) when (ex is COMException or UnauthorizedAccessException) + { + Debug.WriteLine(ex); + } + + return false; + } + + private unsafe HRESULT InvokeItemCore(int itemId) + { + if (contextMenu is null) + return HRESULT.E_INVALIDARG; + + var commandInfo = new CMINVOKECOMMANDINFO + { + cbSize = (uint)sizeof(CMINVOKECOMMANDINFO), + lpVerb = (PCSTR)(byte*)(nuint)(uint)itemId, + nShow = (int)SHOW_WINDOW_CMD.SW_SHOWNORMAL, + }; + + return contextMenu->InvokeCommand(&commandInfo); + } + + private static unsafe OpenWithMenu? Create(string path, ThreadWithMessageQueue owningThread) + { + IContextMenu* openWithContextMenu = default; + HMENU hMenu = default; + + try + { + using ComPtr openWithContextMenu2 = default; + using ComPtr shellExtInit = default; + using ComPtr shellItem = default; + using ComPtr dataObject = default; + + HRESULT hr = PInvoke.CoCreateInstance(CLSID.CLSID_OpenWithMenu, null, CLSCTX.CLSCTX_INPROC_SERVER, IID.IID_IContextMenu, (void**)&openWithContextMenu); + if (hr.ThrowIfFailedOnDebug().Failed) + return null; + + hr = openWithContextMenu->QueryInterface(IID.IID_IContextMenu2, (void**)openWithContextMenu2.GetAddressOf()); + if (hr.ThrowIfFailedOnDebug().Failed) + return null; + + hr = openWithContextMenu->QueryInterface(IID.IID_IShellExtInit, (void**)shellExtInit.GetAddressOf()); + if (hr.ThrowIfFailedOnDebug().Failed) + return null; + + fixed (char* pathPtr = path) + { + hr = PInvoke.SHCreateItemFromParsingName(pathPtr, null, IID.IID_IShellItem, (void**)shellItem.GetAddressOf()); + } + if (hr.ThrowIfFailedOnDebug().Failed) + return null; + + hr = shellItem.Get()->BindToHandler(null, BHID.BHID_DataObject, IID.IID_IDataObject, (void**)dataObject.GetAddressOf()); + if (hr.ThrowIfFailedOnDebug().Failed) + return null; + + hr = shellExtInit.Get()->Initialize(null, dataObject.Get(), default); + if (hr.ThrowIfFailedOnDebug().Failed) + return null; + + hMenu = PInvoke.CreatePopupMenu(); + hr = openWithContextMenu->QueryContextMenu(hMenu, 0, 1, 256, 0); + if (hr.ThrowIfFailedOnDebug().Failed) + return null; + + HMENU hSubMenu = PInvoke.GetSubMenu(hMenu, 0); + if (hSubMenu.IsNull) + return null; + + hr = openWithContextMenu2.Get()->HandleMenuMsg(PInvoke.WM_INITMENUPOPUP, (WPARAM)(nuint)hSubMenu.Value, 0); + if (hr.ThrowIfFailedOnDebug().Failed) + return null; + + var openWithMenu = new OpenWithMenu(openWithContextMenu, hMenu, owningThread); + openWithContextMenu = default; + hMenu = default; + openWithMenu.EnumMenuItems(hSubMenu); + + return openWithMenu; + } + catch (Exception ex) when (ex is COMException or UnauthorizedAccessException) + { + Debug.WriteLine(ex); + return null; + } + finally + { + if (!hMenu.IsNull) + PInvoke.DestroyMenu(hMenu); + + if (openWithContextMenu is not null) + openWithContextMenu->Release(); + } + } + + private unsafe void EnumMenuItems(HMENU hMenu) + { + uint count = unchecked((uint)PInvoke.GetMenuItemCount(hMenu)); + if (count is unchecked((uint)-1)) + return; + + for (uint index = 0; index < count; index++) + { + const uint bufferLength = 256; + MENUITEMINFOW menuItemInfo = default; + menuItemInfo.cbSize = (uint)sizeof(MENUITEMINFOW); + menuItemInfo.fMask = + MENU_ITEM_MASK.MIIM_BITMAP | + MENU_ITEM_MASK.MIIM_FTYPE | + MENU_ITEM_MASK.MIIM_ID | + MENU_ITEM_MASK.MIIM_STATE | + MENU_ITEM_MASK.MIIM_STRING; + menuItemInfo.dwTypeData = (char*)NativeMemory.Alloc(bufferLength, sizeof(char)); + menuItemInfo.cch = bufferLength; + + try + { + if (!PInvoke.GetMenuItemInfo(hMenu, index, true, &menuItemInfo)) + continue; + + var menuItem = new ContextMenuItem + { + ID = (int)(menuItemInfo.wID - 1), + Label = NormalizeLabel(menuItemInfo.dwTypeData.ToString()), + Type = (MENU_ITEM_TYPE)menuItemInfo.fType, + }; + + if (!menuItemInfo.hbmpItem.IsNull && !Enum.IsDefined(typeof(HBITMAP_HMENU), ((IntPtr)menuItemInfo.hbmpItem).ToInt64())) + { + using Bitmap? bitmap = GetBitmapFromHBitmap(menuItemInfo.hbmpItem); + if (bitmap is not null) + { + bitmap.MakeTransparent(); + menuItem.Icon = (byte[])IconConverter.ConvertTo(bitmap, typeof(byte[])); + } + } + + Items.Add(menuItem); + } + finally + { + NativeMemory.Free(menuItemInfo.dwTypeData); + } + } + } + + private static Bitmap? GetBitmapFromHBitmap(HBITMAP hBitmap) + { + try + { + return Image.FromHbitmap((IntPtr)hBitmap); + } + catch + { + return null; + } + } + + private static string NormalizeLabel(string? rawLabel) + { + if (string.IsNullOrEmpty(rawLabel)) + return string.Empty; + + var labelBuilder = new System.Text.StringBuilder(rawLabel.Length); + + for (int i = 0; i < rawLabel.Length; i++) + { + char current = rawLabel[i]; + if (current != '&') + { + labelBuilder.Append(current); + continue; + } + + if (i + 1 >= rawLabel.Length) + { + labelBuilder.Append('&'); + continue; + } + + char next = rawLabel[++i]; + if (next == '&') + labelBuilder.Append('&'); + else + labelBuilder.Append(next); + } + + return labelBuilder.ToString(); + } + + public void Dispose() + { + if (disposedValue) + return; + + if (!menu.IsNull) + { + PInvoke.DestroyMenu(menu); + menu = default; + } + + unsafe + { + if (contextMenu is not null) + { + contextMenu->Release(); + contextMenu = null; + } + } + + owningThread.Dispose(); + disposedValue = true; + } + } +}