<!-- Enter a brief description/summary of your PR here. What does it fix/what does it change/how was it tested (even manually, if necessary)? --> ## Summary of the Pull Request With this skills, we can easily enable AI to complete most of the tasks involved in migrating from WPF to WinUI3. <!-- Please review the items on the PR checklist before submitting--> ## PR Checklist - [x] Closes: #46464 <!-- - [ ] Closes: #yyy (add separate lines for additional resolved issues) --> - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx <!-- Provide a more detailed description of the PR, other things fixed, or any additional comments/features here --> ## Detailed Description of the Pull Request / Additional comments <!-- Describe how you validated the behavior. Add automated tests wherever possible, but list manual validation steps taken as well --> ## Validation Steps Performed --------- Co-authored-by: Yu Leng <yuleng@microsoft.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
17 KiB
PowerToys-Specific Migration Patterns
Patterns and conventions specific to the PowerToys codebase, based on the ImageResizer migration.
Project Structure
Before (WPF Module)
src/modules/<module>/
├── <Module>UI/
│ ├── <Module>UI.csproj # OutputType=WinExe, UseWPF=true
│ ├── App.xaml / App.xaml.cs
│ ├── MainWindow.xaml / .cs
│ ├── Views/
│ ├── ViewModels/
│ ├── Helpers/
│ │ ├── Observable.cs # Custom INotifyPropertyChanged
│ │ └── RelayCommand.cs # Custom ICommand
│ ├── Properties/
│ │ ├── Resources.resx # WPF resource strings
│ │ ├── Resources.Designer.cs
│ │ └── InternalsVisibleTo.cs
│ └── Telemetry/
├── <Module>CLI/
│ └── <Module>CLI.csproj # OutputType=Exe
└── tests/
After (WinUI 3 Module)
src/modules/<module>/
├── <Module>UI/
│ ├── <Module>UI.csproj # OutputType=WinExe, UseWinUI=true
│ ├── Program.cs # Custom entry point (DISABLE_XAML_GENERATED_MAIN)
│ ├── app.manifest # Single manifest file
│ ├── ImageResizerXAML/
│ │ ├── App.xaml / App.xaml.cs # WinUI 3 App class
│ │ ├── MainWindow.xaml / .cs
│ │ └── Views/
│ ├── Converters/ # WinUI 3 IValueConverter (string language)
│ ├── ViewModels/
│ ├── Helpers/
│ │ └── ResourceLoaderInstance.cs # Static ResourceLoader accessor
│ ├── Utilities/
│ │ └── CodecHelper.cs # WPF→WinRT codec ID mapping (if imaging)
│ ├── Models/
│ │ └── ImagingEnums.cs # Custom enums replacing WPF imaging enums
│ ├── Strings/
│ │ └── en-us/
│ │ └── Resources.resw # WinUI 3 resource strings
│ └── Assets/
│ └── <Module>/
│ └── <Module>.ico # Moved from Resources/
├── <Module>Common/ # NEW: shared library for CLI
│ └── <Module>Common.csproj # OutputType=Library
├── <Module>CLI/
│ └── <Module>CLI.csproj # References Common, NOT UI
└── tests/
Critical: CLI Dependency Pattern
Do NOT create ProjectReference from Exe to WinExe. This causes phantom build artifacts (.exe, .deps.json, .runtimeconfig.json) in the root output directory.
WRONG: ImageResizerCLI (Exe) → ImageResizerUI (WinExe) ← phantom artifacts
CORRECT: ImageResizerCLI (Exe) → ImageResizerCommon (Library)
ImageResizerUI (WinExe) → ImageResizerCommon (Library)
Follow the FancyZonesCLI → FancyZonesEditorCommon pattern.
Files to Delete
| File | Reason |
|---|---|
Properties/Resources.resx |
Replaced by Strings/en-us/Resources.resw |
Properties/Resources.Designer.cs |
Auto-generated; no longer needed |
Properties/InternalsVisibleTo.cs |
Moved to .csproj <InternalsVisibleTo> |
Helpers/Observable.cs |
Replaced by CommunityToolkit.Mvvm.ObservableObject |
Helpers/RelayCommand.cs |
Replaced by CommunityToolkit.Mvvm.Input |
Resources/*.ico / Resources/*.png |
Moved to Assets/<Module>/ |
WPF .dev.manifest / .prod.manifest |
Replaced by single app.manifest |
| WPF-specific converters | Replaced by WinUI 3 converters with string language |
MVVM Migration: Custom → CommunityToolkit.Mvvm Source Generators
Observable Base Class → ObservableObject + [ObservableProperty]
Before (custom Observable):
public class ResizeSize : Observable
{
private int _id;
public int Id { get => _id; set => Set(ref _id, value); }
private ResizeFit _fit;
public ResizeFit Fit
{
get => _fit;
set
{
Set(ref _fit, value);
UpdateShowHeight();
}
}
private bool _showHeight = true;
public bool ShowHeight { get => _showHeight; set => Set(ref _showHeight, value); }
private void UpdateShowHeight() { ShowHeight = Fit == ResizeFit.Stretch || Unit != ResizeUnit.Percent; }
}
After (CommunityToolkit.Mvvm source generators):
public partial class ResizeSize : ObservableObject // MUST be partial
{
[ObservableProperty]
[JsonPropertyName("Id")]
private int _id;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowHeight))] // Replaces manual UpdateShowHeight()
private ResizeFit _fit;
// Computed property — no backing field, no manual update method
public bool ShowHeight => Fit == ResizeFit.Stretch || Unit != ResizeUnit.Percent;
}
Key changes:
- Class must be
partialfor source generators Observable→ObservableObject(from CommunityToolkit.Mvvm)- Manual
Set(ref _field, value)→[ObservableProperty]attribute PropertyChangeddependencies →[NotifyPropertyChangedFor(nameof(...))]- Computed properties with manual
UpdateXxx()→ direct expression body
Custom Name Setter with Transform
For properties that transform the value before storing:
// Cannot use [ObservableProperty] because of value transformation
private string _name;
public string Name
{
get => _name;
set => SetProperty(ref _name, ReplaceTokens(value)); // SetProperty from ObservableObject
}
RelayCommand → [RelayCommand] Source Generator
// DELETE: Helpers/RelayCommand.cs (custom ICommand)
// Before
public ICommand ResizeCommand { get; } = new RelayCommand(Execute);
// After
[RelayCommand]
private void Resize() { /* ... */ }
// Source generator creates ResizeCommand property automatically
Resource String Migration (.resx → .resw)
ResourceLoaderInstance Helper
internal static class ResourceLoaderInstance
{
internal static ResourceLoader ResourceLoader { get; private set; }
static ResourceLoaderInstance()
{
ResourceLoader = new ResourceLoader("PowerToys.ImageResizer.pri");
}
}
Note: Use the single-argument ResourceLoader constructor. The two-argument version (ResourceLoader("file.pri", "path/Resources")) may fail if the resource map path doesn't match the actual PRI structure.
Usage
// WPF
using ImageResizer.Properties;
string text = Resources.MyStringKey;
// WinUI 3
string text = ResourceLoaderInstance.ResourceLoader.GetString("MyStringKey");
Lazy Initialization for Resource-Dependent Statics
ResourceLoader is not available at class-load time in all contexts (CLI mode, test harness). Use lazy initialization:
Before (crashes at class load):
private static readonly CompositeFormat _format =
CompositeFormat.Parse(Resources.Error_Format);
private static readonly Dictionary<string, string> _tokens = new()
{
["$small$"] = Resources.Small,
["$medium$"] = Resources.Medium,
};
After (lazy, safe):
private static CompositeFormat _format;
private static CompositeFormat Format => _format ??=
CompositeFormat.Parse(ResourceLoaderInstance.ResourceLoader.GetString("Error_Format"));
private static readonly Lazy<Dictionary<string, string>> _tokens = new(() =>
new Dictionary<string, string>
{
["$small$"] = ResourceLoaderInstance.ResourceLoader.GetString("Small"),
["$medium$"] = ResourceLoaderInstance.ResourceLoader.GetString("Medium"),
});
// Usage: _tokens.Value.TryGetValue(...)
XAML: x:Static → x:Uid
<!-- WPF -->
<Button Content="{x:Static p:Resources.Cancel}" />
<!-- WinUI 3 -->
<Button x:Uid="Cancel" />
In .resw, use property-suffixed keys: Cancel.Content, Header.Text, etc.
CLI Options Migration
System.CommandLine.Option<T> constructor signature changed:
// WPF era — string[] aliases
public DestinationOption()
: base(_aliases, Properties.Resources.CLI_Option_Destination)
// WinUI 3 — single string name
public DestinationOption()
: base(_aliases[0], ResourceLoaderInstance.ResourceLoader.GetString("CLI_Option_Destination"))
Installer Updates
WiX Changes
1. Remove Satellite Assembly References
Remove from installer/PowerToysSetupVNext/Resources.wxs:
<Component>entries for<Module>.resources.dll<RemoveFolder>entries for locale directories- Module from
WinUI3AppsInstallFolderParentDirectoryloop
2. Update File Component Generation
Run generateAllFileComponents.ps1 after migration. For Exe→WinExe dependency issues, add cleanup logic:
# Strip phantom ImageResizer files from BaseApplications.wxs
$content = $content -replace 'PowerToys\.ImageResizer\.exe', ''
$content = $content -replace 'PowerToys\.ImageResizer\.deps\.json', ''
$content = $content -replace 'PowerToys\.ImageResizer\.runtimeconfig\.json', ''
3. Output Directory
WinUI 3 modules output to WinUI3Apps/:
<OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutputPath>
ESRP Signing
Update .pipelines/ESRPSigning_core.json — all module binaries must use WinUI3Apps\\ paths:
{
"FileList": [
"WinUI3Apps\\PowerToys.ImageResizer.exe",
"WinUI3Apps\\PowerToys.ImageResizerExt.dll",
"WinUI3Apps\\PowerToys.ImageResizerContextMenu.dll"
]
}
Build Pipeline Fixes
$(SolutionDir) → $(MSBuildThisFileDirectory)
$(SolutionDir) is empty when building individual projects outside the solution. Replace with relative paths from the project file:
<!-- Before (breaks on standalone project build) -->
<Exec Command="powershell $(SolutionDir)tools\build\convert-resx-to-rc.ps1" />
<!-- After (works always) -->
<Exec Command="powershell $(MSBuildThisFileDirectory)..\..\..\..\tools\build\convert-resx-to-rc.ps1" />
MSIX Packaging: PreBuild → PostBuild
MSIX packaging must happen AFTER the build (artifacts not ready at PreBuild):
<!-- Before -->
<PreBuildEvent>MakeAppx.exe pack /d . /p "$(OutDir)Package.msix" /o</PreBuildEvent>
<!-- After -->
<PostBuildEvent>
if exist "$(OutDir)Package.msix" del "$(OutDir)Package.msix"
MakeAppx.exe pack /d "$(MSBuildThisFileDirectory)." /p "$(OutDir)Package.msix" /o
</PostBuildEvent>
RC File Icon Path Escaping
Windows Resource Compiler requires double-backslash paths:
// Before (breaks)
IDI_ICON1 ICON "..\\ui\Assets\ImageResizer\ImageResizer.ico"
// After
IDI_ICON1 ICON "..\\ui\\Assets\\ImageResizer\\ImageResizer.ico"
BOM/Encoding Normalization
Migration may strip UTF-8 BOM from C# files (// Copyright → // Copyright). This is cosmetic and safe, but be aware it will show as changes in diff.
Test Adaptation
Tests Requiring WPF Runtime
If tests still need WPF types (e.g., comparing old vs new output), temporarily add:
<UseWPF>true</UseWPF>
Remove this after fully migrating all test code to WinRT APIs.
Tests Using ResourceLoader
Unit tests cannot easily initialize WinUI 3 ResourceLoader. Options:
- Hardcode expected strings in tests:
"Value must be between '{0}' and '{1}'." - Delete tests that only verify resource string lookup
- Avoid creating
Appinstances in test harness (WinUI App cannot be instantiated in tests)
Async Test Methods
All imaging tests become async:
// Before
[TestMethod]
public void ResizesImage() { ... }
// After
[TestMethod]
public async Task ResizesImageAsync() { ... }
uint Assertions
// Before
Assert.AreEqual(96, image.Frames[0].PixelWidth);
// After
Assert.AreEqual(96u, decoder.PixelWidth);
Pixel Data Access in Tests
// Before (WPF)
public static Color GetFirstPixel(this BitmapSource source)
{
var pixel = new byte[4];
new FormatConvertedBitmap(
new CroppedBitmap(source, new Int32Rect(0, 0, 1, 1)),
PixelFormats.Bgra32, null, 0).CopyPixels(pixel, 4, 0);
return Color.FromArgb(pixel[3], pixel[2], pixel[1], pixel[0]);
}
// After (WinRT)
public static async Task<(byte R, byte G, byte B, byte A)> GetFirstPixelAsync(
this BitmapDecoder decoder)
{
using var bitmap = await decoder.GetSoftwareBitmapAsync(
BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied);
var buffer = new Windows.Storage.Streams.Buffer(
(uint)(bitmap.PixelWidth * bitmap.PixelHeight * 4));
bitmap.CopyToBuffer(buffer);
using var reader = DataReader.FromBuffer(buffer);
byte b = reader.ReadByte(), g = reader.ReadByte(),
r = reader.ReadByte(), a = reader.ReadByte();
return (r, g, b, a);
}
Metadata Assertions
// Before
Assert.AreEqual("Test", ((BitmapMetadata)image.Frames[0].Metadata).Comment);
// After
var props = await decoder.BitmapProperties.GetPropertiesAsync(
new[] { "System.Photo.DateTaken" });
Assert.IsTrue(props.ContainsKey("System.Photo.DateTaken"),
"Metadata should be preserved during transcode");
AllowUnsafeBlocks for SoftwareBitmap Tests
If tests access pixel data via IMemoryBufferByteAccess, add:
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
Settings JSON Backward Compatibility
- Settings are stored in
%LOCALAPPDATA%\Microsoft\PowerToys\<ModuleName>\ - Schema must remain backward-compatible across upgrades
- Add new fields with defaults; never remove or rename existing fields
- Create custom enums matching WPF enum integer values for deserialization (e.g.,
ImagingEnums.cs) - See:
src/settings-ui/Settings.UI.Library/
IPC Contract
If the module communicates with the runner or settings UI:
- Update BOTH sides of the IPC contract
- Test settings changes are received by the module
- Test module state changes are reflected in settings UI
- Reference:
doc/devdocs/core/settings/runner-ipc.md
Checklist for PowerToys Module Migration
Project & Dependencies
- Update
.csproj:UseWPF→UseWinUI, TFM →net8.0-windows10.0.19041.0 - Add
WindowsPackageType=None,SelfContained=true,WindowsAppSDKSelfContained=true - Add
DISABLE_XAML_GENERATED_MAINif using customProgram.cs - Replace NuGet packages (WPF-UI → remove, add WindowsAppSDK, etc.)
- Update project references (GPOWrapperProjection → GPOWrapper + CsWinRT)
- Move
InternalsVisibleTofrom code to.csproj - Extract CLI shared logic to Library project (avoid Exe→WinExe dependency)
MVVM & Resources
- Replace custom
Observable/RelayCommandwith CommunityToolkit.Mvvm source generators - Migrate
.resx→.resw(Properties/Resources.resx→Strings/en-us/Resources.resw) - Create
ResourceLoaderInstancehelper - Wrap resource-dependent statics in
Lazy<T>or null-coalescing properties - Delete
Properties/Resources.Designer.cs,Observable.cs,RelayCommand.cs
XAML
- Replace
clr-namespace:→using:in all xmlns declarations - Remove WPF-UI (Lepo) xmlns and controls — use native WinUI 3
- Replace
{x:Static p:Resources.Key}→x:Uidwith.reswkeys - Replace
{DynamicResource}→{ThemeResource} - Replace
DataType="{x:Type ...}"→x:DataType="..." - Replace
<Style.Triggers>→VisualStateManager - Add
<XamlControlsResources/>toApp.xamlmerged dictionaries - Move
Window.Resourcesto root container'sResources - Run XamlStyler:
.\.pipelines\applyXamlStyling.ps1 -Main
Code-Behind & APIs
- Replace all
System.Windows.*namespaces withMicrosoft.UI.Xaml.* - Replace
DispatcherwithDispatcherQueue - Store
DispatcherQueuereference explicitly (noApplication.Current.Dispatcher) - Implement
SizeToContent()via AppWindow if needed - Update
ContentDialogcalls to setXamlRoot - Update
FilePickercalls with HWND initialization - Migrate imaging code to
Windows.Graphics.Imaging(async,SoftwareBitmap) - Create
CodecHelperfor legacy GUID → WinRT codec ID mapping (if imaging) - Create custom imaging enums for JSON backward compatibility (if imaging)
- Update all
IValueConvertersignatures (CultureInfo→string)
Build & Installer
- Update WiX installer: remove satellite assembly refs from
Resources.wxs - Run
generateAllFileComponents.ps1; handle phantom artifacts - Update ESRP signing paths to
WinUI3Apps\\ - Fix
$(SolutionDir)→$(MSBuildThisFileDirectory)in build events - Move MSIX packaging from PreBuild to PostBuild
- Fix RC file path escaping (double-backslash)
- Verify output dir is
WinUI3Apps/
Testing & Validation
- Update test project: async methods,
uintassertions - Handle ResourceLoader unavailability in tests (hardcode strings or skip)
- Build clean:
cdto project folder,tools/build/build.cmd, exit code 0 - Run tests for affected module
- Verify settings JSON backward compatibility
- Test IPC contracts (runner ↔ settings UI)