I created ts-npm-monorepo as a bare-bones example for how to set up a monorepo with Typescript using only NPM Workspaces.
This is working well: I only have one copy of node_modules at the root and all the packages share it. The packages are set up with Typescript project references, so everything builds in the proper order and changes are immediately reflected. I can also add any shared external dependencies in the root package.json and they’ll be available in any package in the repo.
(Edit) After reading up a bit more on Yarn it’s pretty great and I’ve switched to using Yarn workspaces.
The AWS Cognito authentication service as of this writing does not officially support the Electron platform. But there is a Javascript SDK for Cognito, as part of AWS Amplify. Others have tried using it on Electron but have run into issues. I ran into several more than what are described in that thread, so I’ll go over the stumbling blocks and how I managed to get around each one.
In my case I’m using Federated authentication (via Google) through Cognito User Pools. I’m calling Auth.federatedSignIn() from the preload section, but I think this would still work if you ran it with the other page code.
Making the auth happen in a different window
AWS sends you off to another webpage to log in, via window.open(<url>, "_self"). Depending on your electron configuration, this might actually open a new window, or it might try to open the auth page in the same window.
Opening in the same window is problematic. You can be redirected back to your electron page, but then you might be running a bunch of page/app initialization again. To solve this I monkey-patched window.open:
// Strips off the "_self" that was added by the AWS Amplify SDK, to open auth in a new window.
const oldWindowOpen = window.open;
window.open = <any>((url: string) => {
return oldWindowOpen(url);
});
You might need to refine this to only mess with auth URLs if you have other bits of code that you expect to call window.open().
But after this change, the auth page gets opened in a new window.
Allowing the auth window to open
At this point it’s trying to open a new window, but my boilerplate Electron setup had a setWindowHandler() call that was blocking it. To fix I updated the code to allow when the URL was for my auth service:
Normally when using this authentication library on a website it will redirect back to your website, where you can handle the code it’s given you on the URL. In my case I have redirectSignIn and redirectSignOut set to http://localhost:49391/ . I don’t actually have anything running on port 49391, but in our case we don’t need to! We can instead add an onBeforeRequest listener to intercept it and handle it. In our setup in the main process:
We capture the auth window when it’s created, then when we see a request come in for http://localhost:49391 we intercept it, dispatch a message to our renderer process, close the auth window, then cancel the request. Cancelling the request using the callback makes sure that nobody else can set up a server on port 49391 and listen in for our auth key.
You will also need to update your app client in the AWS Cognito console:
Passing the success URL back to the Amplify library
We need to set up a listener in our render preload script to get the IPC message:
_handleAuthResponse is a private method on the Auth object that we need to invoke and provide the success URL with the returned key. Obviously, there are major caveats with using a private method as it could break at any point, but it works as of aws-amplify 4.3.10. Should work if I don’t upgrade the package until they’ve added proper support, right? 🤞
In my case I’m running all my auth code inside the preload context so I directly call from there, but you can also send it back to the browser context if you’ve been doing auth calls there.
Patching up history.replaceState
The aws-amplify library tries to call history.replaceState at one point in _handleAuthResponse, to attempt to scrub the URL with the key from the browser back history. But this failed in Electron for me. To fix, I replaced that API with a stub:
We don’t need to do this history scrub anyway because the window with the URL in it is getting closed.
I don’t need to call history.replaceState() normally in my app, but I would restore it back to its old value after if it became important.
Now you’re logged in!
At this point the federated authentication is complete and you can proceed as normal as the AWS Amplify docs direct. It should fire the “signIn” event on the Hub under the “auth” channel as normal. In my case for the User Pool I needed to call Auth.currentUserInfo() or Auth.currentUserPoolUser().
Update – October 2022
I tried updating Electron from 16 to 21 and this method stopped working. The AWS SDK now thinks that it’s in a Node environment and starts trying to call Node APIs. Which fail because nodeIntegration is disabled on that page (which it should be for security reasons). If I want to continue the hack route I’ll need some way to fool the AWS SDK into thinking its actually in a web page. Or switch to Auth0 which actually has a promising tutorial for integration with Electron.
Update – December 2022
I also found out that the AWS Amplify SDK doesn’t tree shake well and hauled in 1.64 MB of minified Javascript to my preload script, which was horribly slowing down cold startup time. I switched over to Auth0 and the startup time recovered. It’s a lot more expensive than Cognito but at least they support Electron. The Auth0 electron tutorial is excellent and only requires tiny dependencies like jwt-decode, so you can keep your code lean and mean.
There are a lot of different ways to show an SVG on a webpage: <img>, <embed>, <object>, <iframe>, <canvas> and <svg> among them. I think for any halfway modern browser there are really only two serious contenders here. Referencing an SVG file:
<img src="image.svg" />
And embedding the SVG directly into the DOM.
<svg><circle cx="50" cy="50" r="40" /></svg>
There are some advantages and drawbacks to each. <img> tags will keep your layouts small and only download the image as it’s needed. It can be cached by the browser and doesn’t need to be re-downloaded when your JS bundles update. But you can’t change their fill with CSS rules, which makes them not ideal for icons, which often need to switch color in dark vs light mode or for high contrast mode.
Conversely, <svg> tags are malleable to CSS rules, but they are harder to bring in, cache separately and lazy-load.
In our case we support dark and light modes, so we went with <svg> tags for our icons.
Should we delay-load?
Now the question is, how do we include them from our Web Component templates? In React we have SVGR, to turn SVGs into React components that can do fancy stuff like tell Webpack to make a separate bundle with the SVG data and go load that in on-demand.
There is not yet a tool that can do exactly that for Web Components. I was preparing to sit down and write one, but after thinking about it for a bit and consulting with some members of the FAST Element team; I don’t think it’s necessary to load them separately.
Properly optimized icon SVGs are tiny, since they are just vector data. Not a lot is being gained by pushing that off to a separate web request and running it through a bunch of code to bring it in.
The icon data is slim enough that it will represent a small fraction of the template size.
Bundled icons never have late pop-in and are never missing. They are treated like just another part of your view template.
How to include them in your template
Putting them straight into the template is one way:
Then import the string and include in your template:
<button>${circleIcon}</button>
That was what we tried at first. It worked, but it had some drawbacks. Our central icons file soon got quite large, up to 220kb of icons from various things that might eventually be shown at some point in interacting with the app. But we only need ~10kb of that for the first page load. The initial assumption was that Webpack would save us, but it just ended up putting the whole giant module in a critical JS bundle. That means we’d need to front load every single icon before we could display anything at all. Oops.
The solution
We ended up using a combination of raw-loader and svgo-loader webpack modules. In our webpack config:
module: {
rules: [
{ test: /\.svg$/, use: [
{ loader: "raw-loader" },
{
loader: "svgo-loader",
options: {
configFile: false,
floatPrecision: 2,
plugins: extendDefaultPlugins([
"removeXMLNS", // We can safely remove the XMLNS attribute because we are inlining our SVGs
{
name: "removeViewBox",
active: false
}
])
}
}
]},
...
Raw-loader means it’s going to bring the string directly into the module instead of reference it somewhere else.
Except a bit smaller and more efficient than what was in Circle.svg.
De-duplication
A concern here is the size penalty from including the same icon multiple times. One thing to note is that Webpack will, even with the raw-loader re-use a variable if you’ve used the same SVG more than one time in a single module.
Another technique you can use is that if you have icons that are re-used and that show up together, is to re-export them from a shared icons module, and import them where needed:
import circleIcon from "./Circle.svg";
import squareIcon from "./Square.svg";
export { circleIcon, squareIcon };
Then you can import and use them as normal strings. Just remember that if you put everything in the same file it won’t scale very well as Webpack can’t break the module up.
TypeScript ANGRY
If you’re using TypeScript (which of course you should be) it’s going to be cross with you. Cannot find module './Circle.svg' or its corresponding type declarations. In other words “What are you doing importing an .svg file? That doesn’t look like an ES6 module.”
We need to tell it “Shhh, shhh, it’s OK. My buddy Webpack is going to come by and fix everything. When he’s done, it will look like you’re importing a string, so don’t worry about it.” Translated into TypeScript:
Then it says “OHHHH got it, When I see a module ending with .svg, I can assume it’s going to have a default export with a type of string.”
Put this type declaration in a package that all the icon users will import from.
Icon package
I also made an icon package with all of the .svg files in it:
In package.json you can tell it to include the icons folder:
Then you can import from “package-name/icons/Circle.svg”
The result
After we implemented this icon system, we dropped our critical bundle size by ~210KB, which improved our PLT1 by 100ms.
It’s also easy to add SVGs as you just check them into the repository and import them as strings, which have been automatically streamlined in the Webpack step.
Windows SmartScreen has for a while been trying to keep malware at bay. One of the ways of doing that is putting up a big scary warning when you try to run anything they haven’t validated as safe:
You have to click “More info” then “Run anyway” to actually run it. Eventually an executable will gain enough installs for it to be deemed “safe”, but the first users to run your program will hit this hurdle. It’s not a huge problem if you have a lot of overall downloads but it can be troublesome for beta builds where releases are more frequent and the audience is smaller. Every time you release, you start all over again from zero trust.
The solution: Code signing
To solve this, you can digitally sign your executables, which allows you to benefit from the trust you earned from your other releases. That means if your certificate is trusted, any releases done with it are trusted. The downside is that code signing certificates are pricey. For a business, it’s not a huge issue to pay $500 a year to get a code signing certificate in the company’s name. But it’s a significant burden to open source developers who are just doing it in their spare time, giving software away for free.
But what about cheaper code signing?
I looked for alternatives. SignPath looked somewhat promising, but as far as I can tell it’s just some alternate software validation system and doesn’t inform SmartScreen decisions. Certum has a fairly cheap open source code signing cert, but requires special hardware, and I had heard horror stories about Certum support never getting back to you.
Basically the system is: you buy the cert, you send them a selfie with your driver’s license to prove you are who you say you are, then they issue a certificate not to a company, but to you, personally. That means your real name must show up on the certificate! They will put your street address on the cert by default, but you can (and probably should) open a ticket on Sectigo support to leave off that part while the validation phase is happening. Your zip code has to be on the cert though.
After validation, they send you en email with instructions on how to pick up the cert from Firefox or IE 11. In my case I installed via IE11 and the certificate ended up in certmgr.msc under Personal/Certificates. The certificate name is your full name. You can right click -> export -> include private key -> .pfx format -> choose a password, and choose AES256-SHA256.
Now you’ve got your .pfx file. (Don’t check it into source control.) You can use signtool.exe (comes with Visual Studio or the Windows 10 SDK) to sign executables. I used a command like this:
The /p argument is the password you exported the .pfx with. /fd SHA256 tells it to use the recommended, more secure file digest algorithm. /tr gives it a server that can timestamp when the file was signed. That would come in handy if your certificate expires, then everything signed by it when it was valid will still be valid, since it knows when the signing took place. /td SHA256 tells it to use the recommended, more secure timestamp digest algorithm.
After running the command, your .exe should be updated with the signed version.
Success!
After a while, my new releases stopped triggering the “this software may kill you” warnings. The cert is now trusted by SmartScreen.
I was initially unsure if it would work and was worried that maybe only the more expensive or super-expensive “Enhanced Validation” certs would be necessary. But thankfully not!
I maintain a Visual Studio extension called Unit Test Boilerplate Generator. Recently I went to release a small fix for it but found that I could no longer build it since reformatting my PC. AppVeyor was also unable to build it after I upgraded it to a VS 2019 build agent.
It was complaining about not being able to find Microsoft.VisualStudio.ComponentModelHost.dll v 14.0.0.0 . I had it referenced like this:
Many times the answer to these old-style direct references is to change them into NuGet <PackageReference> . When I did that it defaulted to 16.9.227, but that made it incompatible with VS 2017. I downgraded it to 14.0.25424 , and then it built on VS 2019 but still ran on VS 2017. Success!
There is apparently magic binding redirect voodoo built into VS to point all of these references to old versions to the proper modern versions on later VS versions.
In this guide I will walk through how to get the .NET 5 runtime to download and install on-the-fly in an Inno Setup installer.
It works in 3 steps:
Detect if the desired .NET runtime is installed
Download the .NET Runtime bootstrap installer with Inno Download Plugin
Run the bootstrap installer in quiet mode, which will download and install the .NET Framework. This is better than downloading the full installer since it only downloads the files it needs for your platform.
You can download the regular .NET Runtime or the .NET Desktop Runtime, which supports Windows desktop applications. If you install the Desktop Runtime you do not need to also install the normal .NET Runtime.
Here’s the full code I’m using, with the x64 .NET 5 Desktop Runtime:
#include <idp.iss>
// Other parts of installer file go here
[CustomMessages]
IDP_DownloadFailed=Download of .NET 5 failed. .NET 5 Desktop runtime is required to run VidCoder.
IDP_RetryCancel=Click 'Retry' to try downloading the files again, or click 'Cancel' to terminate setup.
InstallingDotNetRuntime=Installing .NET 5 Desktop Runtime. This might take a few minutes...
DotNetRuntimeFailedToLaunch=Failed to launch .NET Runtime Installer with error "%1". Please fix the error then run this installer again.
DotNetRuntimeFailed1602=.NET Runtime installation was cancelled. This installation can continue, but be aware that this application may not run unless the .NET Runtime installation is completed successfully.
DotNetRuntimeFailed1603=A fatal error occurred while installing the .NET Runtime. Please fix the error, then run the installer again.
DotNetRuntimeFailed5100=Your computer does not meet the requirements of the .NET Runtime. Please consult the documentation.
DotNetRuntimeFailedOther=The .NET Runtime installer exited with an unexpected status code "%1". Please review any other messages shown by the installer to determine whether the installation completed successfully, and abort this installation and fix the problem if it did not.
[Code]
var
requiresRestart: boolean;
function CompareVersion(V1, V2: string): Integer;
var
P, N1, N2: Integer;
begin
Result := 0;
while (Result = 0) and ((V1 <> '') or (V2 <> '')) do
begin
P := Pos('.', V1);
if P > 0 then
begin
N1 := StrToInt(Copy(V1, 1, P - 1));
Delete(V1, 1, P);
end
else
if V1 <> '' then
begin
N1 := StrToInt(V1);
V1 := '';
end
else
begin
N1 := 0;
end;
P := Pos('.', V2);
if P > 0 then
begin
N2 := StrToInt(Copy(V2, 1, P - 1));
Delete(V2, 1, P);
end
else
if V2 <> '' then
begin
N2 := StrToInt(V2);
V2 := '';
end
else
begin
N2 := 0;
end;
if N1 < N2 then Result := -1
else
if N1 > N2 then Result := 1;
end;
end;
function NetRuntimeIsMissing(): Boolean;
var
runtimes: TArrayOfString;
registryKey: string;
I: Integer;
meetsMinimumVersion: Boolean;
meetsMaximumVersion: Boolean;
minimumVersion: string;
maximumExclusiveVersion: string;
begin
Result := True;
minimumVersion := '5.0.0';
maximumExclusiveVersion := '5.1.0';
registryKey := 'SOFTWARE\WOW6432Node\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App';
if RegGetValueNames(HKLM, registryKey, runtimes) then
begin
for I := 0 to GetArrayLength(runtimes)-1 do
begin
meetsMinimumVersion := not (CompareVersion(runtimes[I], minimumVersion) = -1);
meetsMaximumVersion := CompareVersion(runtimes[I], maximumExclusiveVersion) = -1;
if meetsMinimumVersion and meetsMaximumVersion then
begin
Log(Format('[.NET] Selecting %s', [runtimes[I]]));
Result := False;
Exit;
end;
end;
end;
end;
procedure InitializeWizard;
begin
if NetRuntimeIsMissing() then
begin
idpAddFile('http://go.microsoft.com/fwlink/?linkid=2155258', ExpandConstant('{tmp}\NetRuntimeInstaller.exe'));
idpDownloadAfter(wpReady);
end;
end;
function InstallDotNetRuntime(): String;
var
StatusText: string;
ResultCode: Integer;
begin
StatusText := WizardForm.StatusLabel.Caption;
WizardForm.StatusLabel.Caption := CustomMessage('InstallingDotNetRuntime');
WizardForm.ProgressGauge.Style := npbstMarquee;
try
if not Exec(ExpandConstant('{tmp}\NetRuntimeInstaller.exe'), '/passive /norestart /showrmui /showfinalerror', '', SW_SHOW, ewWaitUntilTerminated, ResultCode) then
begin
Result := FmtMessage(CustomMessage('DotNetRuntimeFailedToLaunch'), [SysErrorMessage(resultCode)]);
end
else
begin
// See https://msdn.microsoft.com/en-us/library/ee942965(v=vs.110).aspx#return_codes
case resultCode of
0: begin
// Successful
end;
1602 : begin
MsgBox(CustomMessage('DotNetRuntimeFailed1602'), mbInformation, MB_OK);
end;
1603: begin
Result := CustomMessage('DotNetRuntimeFailed1603');
end;
1641: begin
requiresRestart := True;
end;
3010: begin
requiresRestart := True;
end;
5100: begin
Result := CustomMessage('DotNetRuntimeFailed5100');
end;
else begin
MsgBox(FmtMessage(CustomMessage('DotNetRuntimeFailedOther'), [IntToStr(resultCode)]), mbError, MB_OK);
end;
end;
end;
finally
WizardForm.StatusLabel.Caption := StatusText;
WizardForm.ProgressGauge.Style := npbstNormal;
DeleteFile(ExpandConstant('{tmp}\NetRuntimeInstaller.exe'));
end;
end;
function PrepareToInstall(var NeedsRestart: Boolean): String;
begin
// 'NeedsRestart' only has an effect if we return a non-empty string, thus aborting the installation.
// If the installers indicate that they want a restart, this should be done at the end of installation.
// Therefore we set the global 'restartRequired' if a restart is needed, and return this from NeedRestart()
if NetRuntimeIsMissing() then
begin
Result := InstallDotNetRuntime();
end;
end;
function NeedRestart(): Boolean;
begin
Result := requiresRestart;
end;
Detecting if the desired .NET Runtime is installed
Check SOFTWARE\dotnet\Setup\InstalledVersions\x86\sharedfx on x86 or SOFTWARE\WOW6432Node\dotnet\Setup\InstalledVersions\x64\sharedfx on x64. Use Microsoft.WindowsDesktop.App for the Desktop Runtime and Microsoft.NETCore.App for the regular runtime.
I am checking SOFTWARE\WOW6432Node\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App .
We are checking for a version installed >= 5.0.0 and < 5.1.0. Having .NET 6 installed would not run apps built for the .NET 5 Runtime.
Downloading the bootstrapper
First you need to locate the correct download link for the installer. This will depend on if you want the x86 or x64 version, what version you’re installing, and whether you want the normal or Desktop runtime. The Inno Dependency Installer source code has a good listing of stable links; I haven’t been able to find an official list yet.
You could also use the Inno Dependency Installer instead, though I like having slimmer installer code for just the framework I want to install, and I think checking the registry is more efficient than including another executable to run and check the version.
Now install Inno Download Plugin. This will make the idpAddFile and idpDownloadAfter calls inside InitializeWizard work. Remember that you need #include <idp.iss> at the top of your file to bring this in.
You will also need to add a line to the end of C:\Program Files (x86)\Inno Setup 6\ISPPBuiltins.iss . The Inno Download Plugin installer tries to do it but hasn’t been updated in a while and is still trying to update Inno Setup 5.
This “InitializeWizard” method is a special one that InnoSetup calls when first setting up its wizard pages. We call our helper function to detect if the framework is installed, then schedule a download step after the “Ready to install” page. We include our direct download link determined earlier, and save it in a temp folder, with a file name of “NetRuntimeInstaller.exe”. This name I picked arbitrarily; we just need to refer to it later when we’re installing and cleaning up.
Installing the bootstrapper
Our code is activated on PrepareToInstall. When this function returns a string, that string is shown as an error message that stops the install from happening. We call into InstallDotNetRuntime and return its result.
Inside InstallDotNetRuntime we’re running the bootstrapper we downloaded earlier, with a flag to make the install passive (non interactive). The /showrmui option prompts the user to close applications to avoid a system restart. /showfinalerror tells the installer to show an error message if the install fails.
The .NET installer UI will show on top of the first window:
After running the installer, the bootstrapper is deleted (whether or not the install succeeded).
And now your installer is done!
Testing the installer
I would recommend setting up a Windows 10 VM in Hyper-V to test it out. You can go to “Apps and Features” and sort by install date to uninstall your app and the .NET Runtime to repeat the testing.
Thanks to Antony Male for suggesting updates to the error handling code.
There is an oddity in the behavior of ReactDom.render() and ReactDom.hydrate(). Both have similar looking function signatures, so you’d expect them to handle errors in about the same way. And while they both will trigger error boundaries correctly, hydrate() will throw an exception if there was a problem rendering, while render() will not.
When you write your own Resolvers for AWS AppSync, you are given a $context object that contains a lot of helpful things. In my case I needed to grab a unique ID associated with the user from the Cognito User Pool I set up. But the $ctx.identity.cognitoIdentityId just wasn’t there. I needed to find out what I could use there.
Then I went to Queries and ran it again, this time getting the whole context spat out as the “error”. A trip through a JSON unescape tool and formatting with the JSON Viewer Notepad++ plugin gave me a complete picture of what’s available.
Turns out I need to use sub or username, and the cognitoIdentityId field is nowhere to be found.
There are several good reasons for wanting custom window chrome in WPF, such as fitting in additional UI or implementing a Dark theme. However the actual implementation is kind of tricky, since it is now your job to provide a bunch of features that you used to get for free. I’ll walk you through my implementation.
Appearance
I chose to emulate the Windows 10 style.
This will keep my UI consistent with the rest of Windows. I am choosing not to attempt to match the style for old versions of Windows, as I don’t think that would be a great time investment. It’s one little thing you lose when going with a full custom title bar, but it should be worth it by allowing full cohesive theming. Buttons are 46 wide, 32 tall.
Building the UI
First, set WindowStyle=”None” on your Window. That will remove the built-in title bar and allow you to do everything on your own.
Next, allocate space for your title bar in your UI. I like to allocate a Grid row for it.
The CaptionHeight tells the OS to treat the top 32px of your window as if it was a title bar. This means that click to drag works, along with double clicking to maximize/restore, shaking to minimize other windows, etc. The ResizeBorderThickness allows the standard window resize logic to work, so we don’t need to reimplement that either.
I’ve got the app icon. I chose not to implement the special drop-down menu that comes with the standard title bar since it’s not often used and other major apps like Visual Studio Code don’t bother with it. But it’s certainly something you could add.
The title text has a trigger to change its color based on the “Active” state of the window. That allows the user to better tell if the window has focus or not.
The actual buttons use TitleBarButtonStyle and TitleBarCloseButtonStyle:
These are buttons with stripped-down control templates to remove a lot of the extra gunk. They have triggers to change the background color on mouse over (and the foreground color in the case of the Close button). Also they set WindowChrome.IsHitTestVisibleInChrome to True, which allows them to pick up clicks even though they are in the 32px caption area we set up earlier.
The button content itself uses <Path> to draw the icons. The minimize button uses RenderOptions.EdgeMode=”Aliased” to disable anti-aliasing and make sure it renders crisply without blurring over into other pixels. I set the Stroke to pick up the Foreground color from the parent button. The Path data for the maximize/restore buttons are all set on .5 to make sure it renders cleanly at the standard 96 DPI. With whole numbers it ends up drawing on the edge of the pixel and blurring the lines. We can’t use the same “Aliased” trick here as that might cause the pixel count for different lines to change and look off at different zoom levels like 125%/150%.
Responding to button clicks
Now that we have the UI in place, we need to respond to those button clicks. I normally use databinding/MVVM, but in this case I decided to bypass the viewmodel since these are really talking directly to the window.
This will make sure the button displays correctly no matter how the window state change is invoked.
Maximized placement
You thought we were done? Hah. Windows has other plans. You might notice that when you maximize your window, some content is getting cut off and it’s hiding your task bar. The default values it picks for maximized window placement are really weird, where it cuts off 7px of your window content and doesn’t account for task bar placement.
To fix this, we need to listen for the WM_GETMINMAXINFO WndProc message to tell our window it needs to go somewhere different when maximize. Put this in your window codebehind:
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
((HwndSource)PresentationSource.FromVisual(this)).AddHook(HookProc);
}
public static IntPtr HookProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == WM_GETMINMAXINFO)
{
// We need to tell the system what our size should be when maximized. Otherwise it will cover the whole screen,
// including the task bar.
MINMAXINFO mmi = (MINMAXINFO)Marshal.PtrToStructure(lParam, typeof(MINMAXINFO));
// Adjust the maximized size and position to fit the work area of the correct monitor
IntPtr monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
if (monitor != IntPtr.Zero)
{
MONITORINFO monitorInfo = new MONITORINFO();
monitorInfo.cbSize = Marshal.SizeOf(typeof(MONITORINFO));
GetMonitorInfo(monitor, ref monitorInfo);
RECT rcWorkArea = monitorInfo.rcWork;
RECT rcMonitorArea = monitorInfo.rcMonitor;
mmi.ptMaxPosition.X = Math.Abs(rcWorkArea.Left - rcMonitorArea.Left);
mmi.ptMaxPosition.Y = Math.Abs(rcWorkArea.Top - rcMonitorArea.Top);
mmi.ptMaxSize.X = Math.Abs(rcWorkArea.Right - rcWorkArea.Left);
mmi.ptMaxSize.Y = Math.Abs(rcWorkArea.Bottom - rcWorkArea.Top);
}
Marshal.StructureToPtr(mmi, lParam, true);
}
return IntPtr.Zero;
}
private const int WM_GETMINMAXINFO = 0x0024;
private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr handle, uint flags);
[DllImport("user32.dll")]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
[Serializable]
[StructLayout(LayoutKind.Sequential)]
public struct RECT
{
public int Left;
public int Top;
public int Right;
public int Bottom;
public RECT(int left, int top, int right, int bottom)
{
this.Left = left;
this.Top = top;
this.Right = right;
this.Bottom = bottom;
}
}
[StructLayout(LayoutKind.Sequential)]
public struct MONITORINFO
{
public int cbSize;
public RECT rcMonitor;
public RECT rcWork;
public uint dwFlags;
}
[Serializable]
[StructLayout(LayoutKind.Sequential)]
public struct POINT
{
public int X;
public int Y;
public POINT(int x, int y)
{
this.X = x;
this.Y = y;
}
}
[StructLayout(LayoutKind.Sequential)]
public struct MINMAXINFO
{
public POINT ptReserved;
public POINT ptMaxSize;
public POINT ptMaxPosition;
public POINT ptMinTrackSize;
public POINT ptMaxTrackSize;
}
When the system asks the window where it should be when it maximizes, this code will ask what monitor it’s on, then place itself in the work area of the monitor (not overlapping the task bar).
Declare ourselves DPI-aware
In order to get the WM_GETMINMAXINFO handler to behave correctly, we need to declare our app as per-monitor DPI aware to the unmanaged system.
Right click on your project and select Add -> New Item -> Application Manifest File (Windows Only) .
Add these settings to declare us as properly DPI-aware:
Otherwise with multi-monitor setups you can get maximized windows that stretch off the screen. This setting is a little confusing because WPF is by default DPI aware and normally works just fine out of the box on multiple monitors. But this is required to let the unmanaged APIs that we are hooking into know how WPF is handling things.
Window border
Finally, the window can be kind of hard to pick out when it doesn’t have a border, if it’s put against the wrong background. Let’s fix that now. Wrap your window UI in this:
In a recent Windows 10 update a toggle switch was added to allow the user to specify that they wanted “Dark” themes in apps:
I decided to add support for this to VidCoder. But no updates to WPF were made to make this easy. WPF does having theming support where it can pull in different .xaml resource files from a Themes folder, but this is all Luna/Aero/Win10 styling that is automatically applied based on your OS. After digging in the WPF source code I determined that there’s no way to hook in your own theme to this or manually alter which theme is loaded.
Furthermore, the actual dark theme setting is not exposed in a friendly manner to WPF. But we can still do it. Let’s get started.
Detecting Windows Dark Mode setting + High Contrast
The first step is finding out what theme we should be applying. To do that we need to tell what dark mode choice the user has made and detect when it’s changed. We also need to tell if the user has turned on High Contrast and detect when it’s changed.
Dark mode detection
To get the windows theme we need to poke into the registry. I used a WMI query to watch the registry for changes so we can update the app theme when they change the setting.
private const string RegistryKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
private const string RegistryValueName = "AppsUseLightTheme";
private enum WindowsTheme
{
Light,
Dark
}
public void WatchTheme()
{
var currentUser = WindowsIdentity.GetCurrent();
string query = string.Format(
CultureInfo.InvariantCulture,
@"SELECT * FROM RegistryValueChangeEvent WHERE Hive = 'HKEY_USERS' AND KeyPath = '{0}\\{1}' AND ValueName = '{2}'",
currentUser.User.Value,
RegistryKeyPath.Replace(@"\", @"\\"),
RegistryValueName);
try
{
var watcher = new ManagementEventWatcher(query);
watcher.EventArrived += (sender, args) =>
{
WindowsTheme newWindowsTheme = GetWindowsTheme();
// React to new theme
};
// Start listening for events
watcher.Start();
}
catch (Exception)
{
// This can fail on Windows 7
}
WindowsTheme initialTheme = GetWindowsTheme();
}
private static WindowsTheme GetWindowsTheme()
{
using (RegistryKey key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath))
{
object registryValueObject = key?.GetValue(RegistryValueName);
if (registryValueObject == null)
{
return WindowsTheme.Light;
}
int registryValue = (int)registryValueObject;
return registryValue > 0 ? WindowsTheme.Light : WindowsTheme.Dark;
}
}
High Contrast detection
Our app will have 3 “modes”: Light, Dark and High Contrast. In High Contrast we’ll reference a limited set of system colors which will come from the user’s settings:
We’ll need to find if High Contrast is enabled and when it changes:
public enum AppTheme
{
Light,
Dark,
HighContrast
}
You’ll need to use logic to combine light/dark with the High Contrast bool to get the theme and react to changes. I used ReactiveX Observables but you might have something else. Anyhow, if High Contrast is enabled, use that theme. Otherwise, use the Light/Dark as indicated by the registry key setting.
Setting up theme swapping
After we know what theme we want, we now need to apply it. Fortunately WPF has an excellent mechanism to swap out styles: ResourceDictionaries. Make your dictionaries like so:
That will make the designer happy. Though obviously we don’t want to have that theme all the time. We can swap out the theme before opening any windows in App.xaml.cs:
this.Resources.MergedDictionaries[0].Source =
new Uri($"/Themes/{appTheme}.xaml", UriKind.Relative);
That way the app loads up with the correct theme. You can also run this code when the user updates the theme.
Populating the theme dictionaries
The only thing you want to put into the theme dictionaries are Brush resources (and maybe Colors if you need them). Dark and Light should have whatever looks good to you. For High Contrast, we should pick from the official SystemColors. For example, if you had a special window background brush, you’d want to define it as this in HighContrast.xaml:
This causes any standard control that uses the system colors to use the one you’ve supplied.
Referencing theme colors
Now to use a theme color you should always refer to it like so:
{DynamicResource MyBackgroundBrush}
A StaticResource never changes, but a DynamicResource reference can update, which comes in handy when the users switches on/off dark mode or High Contrast.
Theming built-in controls
While the SystemColor overrides I mentioned earlier can help a lot with re-theming built-in controls, unfortunately there are a lot of controls that don’t pay any attention to system colors.
Button, for example is one of them. Unfortunately, there doesn’t appear to be any way to override these colors. The only way to re-skin them is to make a copy of their ControlTemplate and insert references to our own themed colors. Unfortunately that means that the controls will now look the same no matter what version of Windows the user has, but I don’t know of any way around it. I based mine off the styling in Windows 10, just so it would look consistent in the latest version and stay “current” longer.
Anyway, there’s a couple ways to do it: One by copying the Styles and Templates documentation for the control you want to theme. Another is by right clicking on the control in the designer, then Edit Template -> Edit a Copy.
You’ll want to park the style with ControlTemplate override in App.xaml (Or another dictionary referenced by App.xaml):
I ripped out the FocusVisualStyle override since the default one seems to work fine for all themes. I also include both a “Base” style and an implicit style (that has no key). Controls that don’t specify a style will pick up the implicit style, while control that do need to specify a style can base it on the Base style.
I also moved all the “Brush” resources out to the Theme dictionaries where they belong, and changed all the references to {DynamicResource}.
One thing to watch out for is that some overrides like ComboBox will reference a control from PresentationFramework.Aero2 in their ControlTemplate. If you see a namespace brought in that references Aero2, that means your program won’t work on Windows 7. To keep it compatible, delete the “2” to reference PresentationFramework.Aero.
There’s a lot more “grunt work” involved in picking all the colors, but that covers the overall strategy I used to convert all the app UI. Here’s our new, themed UI:
Window title bar
Now that we’ve converted all of our UI, we might see something like this:
All of the content is themed, but the title bar isn’t. It doesn’t look the best. We can fix it, but unfortunately the only way to do that is to implement the title bar from scratch all on our own. That is a whole separate journey that I go over in another post.
Here’s what we get with our custom title bar:
A pain to implement, but at least now it looks less like garbage.