Building a Windows Tray App by combining Microsoft.UI.Reactor and a Worker Project

I wanted a small Windows app that lives in the tray instead of the taskbar, and minimizes/closes to the tray. This can be useful for building small service apps, while having a UI you can occasionally access to control settings etc.

Here is the full process for setting that up step by step.

1. Start with a Worker project

I started with the standard worker template. In a new project folder, create a worker project using the worker template:

dotnet new worker

I liked the Worker template for this because a tray app is really a background app first. The process needs to stay alive even when the window is hidden.

2. Retarget the project for Windows

The next step was changing the target framework in the project file - we need this to be compatible with WinUI/Reactor and gives access to the APIs needed for windowing and tray behavior. Change the target framework in the project file to:

<TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>

3. Add the UI packages

After that I added the packages the app needs:

dotnet package add Microsoft.UI.Reactor --prerelease
dotnet package add WinUIEx

Microsoft.UI.Reactor gave me a clean C# way to define the window UI. WinUIEx helps with tray icon support and some of the window behavior.

4. Add the Windows app properties

Then I added a few properties in the project file needed to compile WinUI and Reactor apps:

<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<Platforms>ARM64;X64</Platforms>

5. Add the tray icon asset

The app needs a real .ico file for the tray. I added trayicon.ico file to the project and marked it as content:

<Content Include="trayicon.ico" />

6. Keep the app entry point simple

Program.cs stays very small:

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();

7. Create the Settings Window:

Create a new SettingsWindow.cs file with the UI. We'll just use the simple Reactor sample window here as a starting point:

using Microsoft.UI.Reactor;
using Microsoft.UI.Reactor.Core;
using Microsoft.UI.Xaml.Controls;
using WinUIEx;
using static Microsoft.UI.Reactor.Factories;

namespace ReactorTrayWorker;

internal sealed class SettingsWindow : Component
{
    public override Element Render()
    {
        var (name, setName) = UseState("World");

        return 
            VStack(
            Heading($"Hello, {name}!"),
            TextBox(name, setName, placeholderText: "Your name")
                .AutomationName("NameInput")
        ).Padding(16);
    }
}

You of course want to change this to whatever content you want here.

8. Create a static UI Window creator for Reactor

Create an initialize method for WinUI/Reactor that takes an action for quitting (we'll use that later):

internal static void InitializeWinUIWindow(Action stopHost)
{
    ReactorApp.Run(_ =>
    {
        ReactorWindow? settingsWindow = null;
        settingsWindow = ReactorApp.OpenWindow(
            new WindowSpec
            {
                Title = "My Worker Settings",
                Width = 560,
                Height = 460,
                ActivateOnOpen = false,
            },
            () => new SettingsWindow(),
            configure: host =>
            {
                WindowManager manager = WindowManager.Get(host.Window);
                manager.WindowStateChanged += (_, state) =>
                {
                    // If the window is minimized, we don't want it to show in the Alt+Tab switcher + taskbar, so we set IsShownInSwitchers to false.
                    bool isVisible = state != WinUIEx.WindowState.Minimized;
                    manager.AppWindow.IsShownInSwitchers = isVisible;
                };
            });
    });
}

The app also watches WindowStateChanged through WindowManager.

When the window is minimized, the app updates IsShownInSwitchers so it does not stay visible in Alt+Tab or the taskbar. When it is restored, that visibility can come back.

This keeps the behavior consistent. Close hides it. Minimize hides it. The tray icon is the main way back in.

Inside Worker.cs, the background service starts the window setup by calling this method:

SettingsWindow.InitializeWinUIWindow(hostApplicationLifetime.StopApplication);

That shutdown callback matters. The tray menu needs a clean way to stop the whole host when the user actually wants to quit, and we'll use it in the Quit context menu further down. Also note that this call is blocking as long as the Reactor app runs, so wrap that call in a Task.Run you don't await.

9. Create the tray icon during window setup

Inside the window host configuration, let's add a TrayIcon that points to trayicon.ico:

string executablePath = new FileInfo(Assembly.GetExecutingAssembly().Location).DirectoryName!;
TrayIcon icon = new(0, Path.Combine(executablePath, @"trayicon.ico"), "My Worker") {
    IsVisible = true
};

At that point the app is available from the tray even if the window is hidden, and we just need to hook clicking it up to opening the window. The tray icon handles the Selected event. When the user clicks the tray icon, the app activates the main window, brings it to the front, and makes sure it can appear in the switcher again. Here I'm declaring a static action that I'll reuse for the context menu later as well.

var showWindow = static() => {
    ReactorApp.PrimaryWindow?.Activate(); // Activate the window
    ReactorApp.PrimaryWindow?.NativeWindow.SetForegroundWindow(); // Bring to front
    ReactorApp.PrimaryWindow?.AppWindow.IsShownInSwitchers = true; //Show in switcher
};
icon.Selected += (_, _) => showWindow();

 

10. Add the tray menu

The tray icon also handles the ContextMenu (right-click) event. We'll add two actions:

  1. Open
  2. Quit

Open brings the settings window back and does the same as left-click. Quit is the only path that fully exits the app and shuts down the worker.

icon.ContextMenu += (_, e) =>
{
    MenuFlyout flyout = new();
    flyout.Items.Add(new MenuFlyoutItem() { Text = "Open" });
    ((MenuFlyoutItem)flyout.Items[0]).Click += (_, _) => showWindow();
    flyout.Items.Add(new MenuFlyoutItem() { Text = "Quit" });
    ((MenuFlyoutItem)flyout.Items[1]).Click += (_, _) =>
    {
        isQuitting = true;
        ReactorApp.PrimaryWindow?.Close();
        icon.Dispose();
        stopHost.Invoke(); // Shuts down the worker thread
    };
    e.Flyout = flyout;
};

This is the core behavior of the whole project. The window is not supposed to control app lifetime like a normal WinUI app does. The tray icon does that. So to prevent uses from exiting the app, we'll handle the closing event as well:

host.Window.AppWindow.Closing += (_, e) =>
{
    // Prevent closing out the window and just hide to tray instead unless we're quitting
    e.Cancel = !isQuitting;
    settingsWindow?.Hide();
};

So now clicking the X button does not shut down the app. It just sends the window back to the tray, unless the quitting flag was set from the Quit context menu.

Here's what the entire initalize method now looks like:

internal static void InitializeWinUIWindow(Action stopHost)
{
    ReactorApp.Run(_ =>
    {
        bool isQuitting = false;
        ReactorWindow? settingsWindow = null;
        settingsWindow = ReactorApp.OpenWindow(
            new WindowSpec
            {
                Title = "My Worker Settings",
                Width = 560, Height = 460,
                ActivateOnOpen = false,
            },
            () => new SettingsWindow(),
            configure: host =>
            {
                // Configure Tray icon
                string executablePath = new FileInfo(System.Reflection.Assembly.GetExecutingAssembly().Location).DirectoryName!;
                TrayIcon icon = new(0, Path.Combine(executablePath, @"trayicon.ico"), "My Worker")
                {
                    IsVisible = true
                };
                var showWindow = static () => {
                    ReactorApp.PrimaryWindow?.Activate();
                    ReactorApp.PrimaryWindow?.NativeWindow.SetForegroundWindow();
                    ReactorApp.PrimaryWindow?.AppWindow.IsShownInSwitchers = true;
                };
                // Left click on the tray icon will show the window:
                icon.Selected += (_, _) => showWindow();

                // Context menu options:
                icon.ContextMenu += (_, e) =>
                {
                    MenuFlyout flyout = new();
                    flyout.Items.Add(new MenuFlyoutItem() { Text = "Open" });
                    ((MenuFlyoutItem)flyout.Items[0]).Click += (_, _) => showWindow();

                    flyout.Items.Add(new MenuFlyoutItem() { Text = "Quit" });
                    ((MenuFlyoutItem)flyout.Items[1]).Click += (_, _) =>
                    {
                        isQuitting = true;
                        ReactorApp.PrimaryWindow?.Close();
                        icon.Dispose();
                        stopHost.Invoke();
                    };
                    e.Flyout = flyout;
                };

                // Prevent closing out the window and just hide to tray instead unless we're quitting
                host.Window.AppWindow.Closing += (_, e) =>
                {
                    e.Cancel = !isQuitting;
                    settingsWindow?.Hide();
                };

                // If the window is minimized, we don't want it to show in the Alt+Tab switcher + taskbar,
                // so we set IsShownInSwitchers to false when minimized.
                WindowManager manager = WindowManager.Get(host.Window);
                manager.WindowStateChanged += (_, state) =>
                {
                    bool isVisible = state != WinUIEx.WindowState.Minimized;
                    manager.AppWindow.IsShownInSwitchers = isVisible;
                };
            });
    });
}

And that's it! We now have the framework for a tray-based WinUI/Reactor application.

You can find the full application code here: https://github.com/dotMorten/ReactorExperiments/tree/main/TrayApp

Add comment