Files
PowerToys/src/runner/tray_icon.cpp
Niels Laute b0ccc2394a Add update-available badge to system tray icon (#47030)
When an update is available (readyToDownload or readyToInstall), the
tray icon switches to a badged variant with an orange dot. Works for
both default mode (color icon.ico) and theme-adaptive mode (light/dark
variants).

Closes: #19222
Closes: #25497

## Changes

### Tray icon update badge
- Add 3 badged icon variants
- Extend get_icon() to select badged variant based on update_available
state
- Add set_tray_icon_update_available() for runtime icon switching
- Hook into UpdateUtils.cpp state transitions via
dispatch_run_on_main_ui_thread
- Check UpdateState at startup to show badge immediately if update
pending
- Add 'Update available' context menu item at top of tray menu when
active

### Fix: update icons not deployed (bug fix)
- Add APPICON_UPDATE icon resource to runner.base.rc (iconUpdate.ico was
missing)
- Add CopyFileToFolders entries for iconUpdate.ico,
PowerToysDarkUpdate.ico, and PowerToysWhiteUpdate.ico in runner.vcxproj
- Add all update icon files to installer Core.wxs so they ship in
releases

### UX improvements
- 'Update available' tray menu item now navigates to General page
(Overview) instead of opening Settings to Dashboard
- Update InfoBar severity changed from Success/Informational to Warning
across GeneralPage, LaunchPage, and CheckUpdateControl
- Dashboard update badge gradient and icon refreshed (orange theme,
exclamation glyph)
- AccentButtonStyle applied to 'Install Now' button
- Fixed casing: 'Update Available' to 'Update available'
- Added UpdateAvailableInfoBar.Title resource string
- Add orange update dot to the General navview item

### Screenshots

Before:
<img width="146" height="78" alt="image"
src="https://github.com/user-attachments/assets/c80b8b5f-da94-4cba-92c9-3fcca685653c"
/>

After:

<img width="184" height="104" alt="image"
src="https://github.com/user-attachments/assets/13fc6b34-6e2a-4060-a2f7-f0b6b0d15363"
/>

<img width="150" height="84" alt="image"
src="https://github.com/user-attachments/assets/2673239c-8ce3-437b-947a-1d66803a87ec"
/>

<img width="150" height="100" alt="image"
src="https://github.com/user-attachments/assets/c321deda-770d-47ff-9600-c395f466d444"
/>

<img width="189" height="104" alt="image"
src="https://github.com/user-attachments/assets/2c56d1b7-6615-4d85-80b9-a1cee6413b75"
/>


<img width="473" height="218" alt="image"
src="https://github.com/user-attachments/assets/b0fb59ed-f8bd-40a0-aefd-816a71fc231f"
/>

<img width="1048" height="288" alt="image"
src="https://github.com/user-attachments/assets/29d34e01-f6a9-46c3-a56e-2c50a07718a1"
/>

<img width="206" height="155" alt="image"
src="https://github.com/user-attachments/assets/80e9f77e-aae5-429a-b6be-f0e9f296e929"
/>

<img width="434" height="163" alt="image"
src="https://github.com/user-attachments/assets/7c9d6cd5-fdaa-4b70-a2c0-cff87f5fcf1c"
/>

<img width="379" height="270" alt="image"
src="https://github.com/user-attachments/assets/03e0f60d-a901-45e7-a03a-18be28ec87ed"
/>


## How to test

Since local dev builds use version `0.0.1` which blocks update checks,
you need to temporarily fake an older version:

1. In `src/Version.props`, change `<Version>0.0.1</Version>` to
`<Version>0.87.0</Version>`
2. Optionally, in `src/runner/UpdateUtils.cpp`, change both interval
constants to `1` (minute) for faster testing:
   ```cpp
   constexpr int64_t UPDATE_CHECK_INTERVAL_MINUTES = 1;
   constexpr int64_t UPDATE_CHECK_AFTER_FAILED_INTERVAL_MINUTES = 1;
   ```
3. Build and run the runner
4. Within ~1 minute (with the interval change) or after clicking 'Check
for updates' in Settings > General, the runner will query GitHub and
find a newer version

### Verify
- [ ] Tray icon changes to the update variant (badged with orange dot)
- [ ] Right-clicking the tray icon shows 'Update available' at the top
of the context menu
- [ ] Clicking 'Update available' opens Settings directly to the General
page
- [ ] Settings General page shows the update InfoBar with Warning
severity
- [ ] Dashboard shows the update badge with orange gradient and
exclamation icon
- [ ] Quick Access flyout shows update InfoBar with Warning severity

**Remember to revert Version.props and UpdateUtils.cpp before
committing!**

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 16:48:03 +08:00

576 lines
20 KiB
C++

#include "pch.h"
#include "Generated files/resource.h"
#include "settings_window.h"
#include "tray_icon.h"
#include "general_settings.h"
#include "centralized_hotkeys.h"
#include "centralized_kb_hook.h"
#include "quick_access_host.h"
#include "hotkey_conflict_detector.h"
#include "trace.h"
#include <Windows.h>
#include <common/utils/resources.h>
#include <common/version/version.h>
#include <common/logger/logger.h>
#include <common/utils/elevation.h>
#include <common/Themes/theme_listener.h>
#include <common/Themes/theme_helpers.h>
#include "bug_report.h"
#include <common/updating/updateState.h>
namespace
{
HWND tray_icon_hwnd = NULL;
enum
{
wm_icon_notify = WM_APP,
wm_run_on_main_ui_thread,
};
// Contains the Windows Message for taskbar creation.
UINT wm_taskbar_restart = 0;
NOTIFYICONDATAW tray_icon_data;
bool tray_icon_created = false;
bool about_box_shown = false;
HMENU h_menu = nullptr;
HMENU h_sub_menu = nullptr;
bool double_click_timer_running = false;
bool double_clicked = false;
POINT tray_icon_click_point;
std::optional<bool> last_quick_access_state; // Track the last known Quick Access state
static ThemeListener theme_listener;
static bool theme_adaptive_enabled = false;
static bool update_available = false;
}
// Struct to fill with callback and the data. The window_proc is responsible for cleaning it.
struct run_on_main_ui_thread_msg
{
main_loop_callback_function _callback;
PVOID data;
};
bool dispatch_run_on_main_ui_thread(main_loop_callback_function _callback, PVOID data)
{
if (tray_icon_hwnd == NULL)
{
return false;
}
struct run_on_main_ui_thread_msg* wnd_msg = new struct run_on_main_ui_thread_msg();
wnd_msg->_callback = _callback;
wnd_msg->data = data;
PostMessage(tray_icon_hwnd, wm_run_on_main_ui_thread, 0, reinterpret_cast<LPARAM>(wnd_msg));
return true;
}
void change_menu_item_text(const UINT item_id, wchar_t* new_text)
{
MENUITEMINFOW menuitem = { .cbSize = sizeof(MENUITEMINFOW), .fMask = MIIM_TYPE | MIIM_DATA };
GetMenuItemInfoW(h_menu, item_id, false, &menuitem);
menuitem.dwTypeData = new_text;
SetMenuItemInfoW(h_menu, item_id, false, &menuitem);
}
void open_quick_access_flyout_window()
{
QuickAccessHost::show();
}
void handle_tray_command(HWND window, const WPARAM command_id, LPARAM lparam)
{
switch (command_id)
{
case ID_SETTINGS_MENU_COMMAND:
{
std::wstring settings_window{ winrt::to_hstring(ESettingsWindowNames_to_string(static_cast<ESettingsWindowNames>(lparam))) };
open_settings_window(settings_window);
}
break;
case ID_CLOSE_MENU_COMMAND:
if (h_menu)
{
DestroyMenu(h_menu);
}
DestroyWindow(window);
break;
case ID_ABOUT_MENU_COMMAND:
if (!about_box_shown)
{
about_box_shown = true;
std::wstring about_msg = L"PowerToys\nVersion " + get_product_version() + L"\n\xa9 2019 Microsoft Corporation";
MessageBoxW(nullptr, about_msg.c_str(), L"About PowerToys", MB_OK);
about_box_shown = false;
}
break;
case ID_REPORT_BUG_COMMAND:
{
launch_bug_report();
break;
}
case ID_DOCUMENTATION_MENU_COMMAND:
{
RunNonElevatedEx(L"https://aka.ms/PowerToysOverview", L"", L"");
break;
}
case ID_QUICK_ACCESS_MENU_COMMAND:
{
open_quick_access_flyout_window();
break;
}
case ID_UPDATE_MENU_COMMAND:
{
open_settings_window(std::wstring{ L"Overview" });
break;
}
}
}
void click_timer_elapsed()
{
double_click_timer_running = false;
if (!double_clicked)
{
// Log telemetry for single click (confirmed it's not a double click)
Trace::TrayIconLeftClick(get_general_settings().enableQuickAccess);
if (get_general_settings().enableQuickAccess)
{
open_quick_access_flyout_window();
}
else
{
open_settings_window(std::nullopt);
}
}
}
LRESULT __stdcall tray_icon_window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam)
{
switch (message)
{
case WM_HOTKEY:
{
// We use the tray icon WndProc to avoid creating a dedicated window just for this message.
const auto modifiersMask = LOWORD(lparam);
const auto vkCode = HIWORD(lparam);
Logger::trace(L"On {} hotkey", CentralizedHotkeys::ToWstring({ modifiersMask, vkCode }));
CentralizedHotkeys::PopulateHotkey({ modifiersMask, vkCode });
break;
}
case WM_CREATE:
if (wm_taskbar_restart == 0)
{
tray_icon_hwnd = window;
wm_taskbar_restart = RegisterWindowMessageW(L"TaskbarCreated");
}
break;
case WM_DESTROY:
if (tray_icon_created)
{
Shell_NotifyIcon(NIM_DELETE, &tray_icon_data);
tray_icon_created = false;
}
close_settings_window();
PostQuitMessage(0);
break;
case WM_CLOSE:
DestroyWindow(window);
break;
case WM_COMMAND:
handle_tray_command(window, wparam, lparam);
break;
// Shell_NotifyIcon can fail when we invoke it during the time explorer.exe isn't present/ready to handle it.
// We'll also never receive wm_taskbar_restart message if the first call to Shell_NotifyIcon failed, so we use
// WM_WINDOWPOSCHANGING which is always received on explorer startup sequence.
case WM_WINDOWPOSCHANGING:
{
if (!tray_icon_created)
{
tray_icon_created = Shell_NotifyIcon(NIM_ADD, &tray_icon_data) == TRUE;
}
break;
}
default:
if (message == wm_icon_notify)
{
switch (lparam)
{
case WM_RBUTTONUP:
case WM_CONTEXTMENU:
{
bool quick_access_enabled = get_general_settings().enableQuickAccess;
// Log telemetry
Trace::TrayIconRightClick(quick_access_enabled);
// Reload menu if Quick Access state has changed or is first time
if (h_menu && (!last_quick_access_state.has_value() || quick_access_enabled != last_quick_access_state.value()))
{
DestroyMenu(h_menu);
h_menu = nullptr;
h_sub_menu = nullptr;
}
last_quick_access_state = quick_access_enabled;
if (!h_menu)
{
h_menu = LoadMenu(reinterpret_cast<HINSTANCE>(&__ImageBase), MAKEINTRESOURCE(ID_TRAY_MENU));
}
if (h_menu)
{
static std::wstring settings_menuitem_label = GET_RESOURCE_STRING(IDS_SETTINGS_MENU_TEXT);
static std::wstring settings_menuitem_label_leftclick = GET_RESOURCE_STRING(IDS_SETTINGS_MENU_TEXT_LEFTCLICK);
static std::wstring close_menuitem_label = GET_RESOURCE_STRING(IDS_CLOSE_MENU_TEXT);
static std::wstring submit_bug_menuitem_label = GET_RESOURCE_STRING(IDS_SUBMIT_BUG_TEXT);
static std::wstring documentation_menuitem_label = GET_RESOURCE_STRING(IDS_DOCUMENTATION_MENU_TEXT);
static std::wstring quick_access_menuitem_label = GET_RESOURCE_STRING(IDS_QUICK_ACCESS_MENU_TEXT);
// Update Settings menu text based on Quick Access state
if (quick_access_enabled)
{
change_menu_item_text(ID_SETTINGS_MENU_COMMAND, settings_menuitem_label.data());
}
else
{
change_menu_item_text(ID_SETTINGS_MENU_COMMAND, settings_menuitem_label_leftclick.data());
}
change_menu_item_text(ID_CLOSE_MENU_COMMAND, close_menuitem_label.data());
change_menu_item_text(ID_REPORT_BUG_COMMAND, submit_bug_menuitem_label.data());
bool bug_report_disabled = is_bug_report_running();
EnableMenuItem(h_sub_menu, ID_REPORT_BUG_COMMAND, MF_BYCOMMAND | (bug_report_disabled ? MF_GRAYED : MF_ENABLED));
change_menu_item_text(ID_DOCUMENTATION_MENU_COMMAND, documentation_menuitem_label.data());
change_menu_item_text(ID_QUICK_ACCESS_MENU_COMMAND, quick_access_menuitem_label.data());
// Hide or show Quick Access menu item based on setting
if (!h_sub_menu)
{
h_sub_menu = GetSubMenu(h_menu, 0);
}
if (!quick_access_enabled)
{
// Remove Quick Access menu item when disabled
DeleteMenu(h_sub_menu, ID_QUICK_ACCESS_MENU_COMMAND, MF_BYCOMMAND);
}
}
if (!h_sub_menu)
{
h_sub_menu = GetSubMenu(h_menu, 0);
}
// Dynamically add/remove "Update available" menu item and its separator
DeleteMenu(h_sub_menu, ID_UPDATE_MENU_COMMAND, MF_BYCOMMAND);
// Remove the separator right after the update item (position 0 after deletion)
if (GetMenuItemCount(h_sub_menu) > 0)
{
MENUITEMINFOW mii = { .cbSize = sizeof(mii), .fMask = MIIM_FTYPE };
if (GetMenuItemInfoW(h_sub_menu, 0, TRUE, &mii) && (mii.fType & MFT_SEPARATOR))
{
DeleteMenu(h_sub_menu, 0, MF_BYPOSITION);
}
}
if (update_available)
{
InsertMenuW(h_sub_menu, 0, MF_BYPOSITION | MF_STRING, ID_UPDATE_MENU_COMMAND, GET_RESOURCE_STRING(IDS_UPDATE_AVAILABLE_MENU_TEXT).c_str());
InsertMenuW(h_sub_menu, 1, MF_BYPOSITION | MF_SEPARATOR, 0, nullptr);
}
POINT mouse_pointer;
GetCursorPos(&mouse_pointer);
SetForegroundWindow(window); // Needed for the context menu to disappear.
TrackPopupMenu(h_sub_menu, TPM_CENTERALIGN | TPM_BOTTOMALIGN, mouse_pointer.x, mouse_pointer.y, 0, window, nullptr);
break;
}
case WM_LBUTTONUP:
{
// ignore event if this is the second click of a double click
if (!double_click_timer_running)
{
// start timer for detecting single or double click
double_click_timer_running = true;
double_clicked = false;
UINT doubleClickTime = GetDoubleClickTime();
std::thread([doubleClickTime]() {
std::this_thread::sleep_for(std::chrono::milliseconds(doubleClickTime));
click_timer_elapsed();
}).detach();
}
break;
}
case WM_LBUTTONDBLCLK:
{
// Log telemetry
Trace::TrayIconDoubleClick(get_general_settings().enableQuickAccess);
double_clicked = true;
open_settings_window(std::nullopt);
break;
}
break;
}
}
else if (message == wm_run_on_main_ui_thread)
{
if (lparam != NULL)
{
struct run_on_main_ui_thread_msg* msg = reinterpret_cast<struct run_on_main_ui_thread_msg*>(lparam);
msg->_callback(msg->data);
delete msg;
lparam = NULL;
}
break;
}
else if (message == wm_taskbar_restart)
{
tray_icon_created = Shell_NotifyIcon(NIM_ADD, &tray_icon_data) == TRUE;
break;
}
}
return DefWindowProc(window, message, wparam, lparam);
}
static HICON get_icon(Theme theme)
{
std::wstring icon_path = get_module_folderpath();
if (theme == Theme::Dark)
{
icon_path += update_available ? L"\\svgs\\PowerToysWhiteUpdate.ico" : L"\\svgs\\PowerToysWhite.ico";
}
else
{
icon_path += update_available ? L"\\svgs\\PowerToysDarkUpdate.ico" : L"\\svgs\\PowerToysDark.ico";
}
Logger::trace(L"get_icon: Loading icon from path: {}", icon_path);
HICON icon = static_cast<HICON>(LoadImage(NULL,
icon_path.c_str(),
IMAGE_ICON,
0,
0,
LR_LOADFROMFILE | LR_DEFAULTSIZE | LR_SHARED));
if (!icon)
{
Logger::warn(L"get_icon: Failed to load icon from {}, error: {}", icon_path, GetLastError());
}
return icon;
}
static void handle_theme_change()
{
if (theme_adaptive_enabled)
{
tray_icon_data.hIcon = get_icon(ThemeHelpers::GetSystemTheme());
Shell_NotifyIcon(NIM_MODIFY, &tray_icon_data);
}
}
void update_bug_report_menu_status(bool isRunning)
{
if (h_sub_menu != nullptr)
{
EnableMenuItem(h_sub_menu, ID_REPORT_BUG_COMMAND, MF_BYCOMMAND | (isRunning ? MF_GRAYED : MF_ENABLED));
}
}
void start_tray_icon(bool isProcessElevated, bool theme_adaptive)
{
theme_adaptive_enabled = theme_adaptive;
auto h_instance = reinterpret_cast<HINSTANCE>(&__ImageBase);
// Check if an update is available at startup
auto state = UpdateState::read();
update_available = (state.state == UpdateState::readyToDownload || state.state == UpdateState::readyToInstall);
HICON const icon = theme_adaptive
? get_icon(ThemeHelpers::GetSystemTheme())
: LoadIcon(h_instance, MAKEINTRESOURCE(update_available ? APPICON_UPDATE : APPICON));
if (icon)
{
UINT id_tray_icon = 1;
WNDCLASS wc = {};
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
wc.hInstance = h_instance;
wc.lpszClassName = pt_tray_icon_window_class;
wc.style = CS_HREDRAW | CS_VREDRAW;
wc.lpfnWndProc = tray_icon_window_proc;
wc.hIcon = icon;
RegisterClass(&wc);
auto hwnd = CreateWindowW(wc.lpszClassName,
pt_tray_icon_window_class,
WS_OVERLAPPEDWINDOW | WS_POPUP,
CW_USEDEFAULT,
CW_USEDEFAULT,
CW_USEDEFAULT,
CW_USEDEFAULT,
nullptr,
nullptr,
wc.hInstance,
nullptr);
WINRT_VERIFY(hwnd);
CentralizedHotkeys::RegisterWindow(hwnd);
CentralizedKeyboardHook::RegisterWindow(hwnd);
memset(&tray_icon_data, 0, sizeof(tray_icon_data));
tray_icon_data.cbSize = sizeof(tray_icon_data);
tray_icon_data.hIcon = icon;
tray_icon_data.hWnd = hwnd;
tray_icon_data.uID = id_tray_icon;
tray_icon_data.uCallbackMessage = wm_icon_notify;
std::wstringstream pt_version_tooltip_stream;
if (isProcessElevated)
{
pt_version_tooltip_stream << GET_RESOURCE_STRING(IDS_TRAY_ICON_ADMIN_TOOLTIP) << L": ";
}
pt_version_tooltip_stream << L"PowerToys " << get_product_version() << '\0';
std::wstring pt_version_tooltip = pt_version_tooltip_stream.str();
wcscpy_s(tray_icon_data.szTip, sizeof(tray_icon_data.szTip) / sizeof(WCHAR), pt_version_tooltip.c_str());
tray_icon_data.uFlags = NIF_ICON | NIF_TIP | NIF_MESSAGE;
ChangeWindowMessageFilterEx(hwnd, WM_COMMAND, MSGFLT_ALLOW, nullptr);
tray_icon_created = Shell_NotifyIcon(NIM_ADD, &tray_icon_data) == TRUE;
theme_listener.AddSystemThemeChangedHandler(&handle_theme_change);
// Register callback to update bug report menu item status
BugReportManager::instance().register_callback([](bool isRunning) {
dispatch_run_on_main_ui_thread([](PVOID data) {
bool* running = static_cast<bool*>(data);
update_bug_report_menu_status(*running);
delete running;
},
new bool(isRunning));
});
}
}
void set_tray_icon_visible(bool shouldIconBeVisible)
{
tray_icon_data.uFlags |= NIF_STATE;
tray_icon_data.dwStateMask = NIS_HIDDEN;
tray_icon_data.dwState = shouldIconBeVisible ? 0 : NIS_HIDDEN;
Shell_NotifyIcon(NIM_MODIFY, &tray_icon_data);
}
void set_tray_icon_update_available(bool available)
{
if (update_available == available)
{
return;
}
update_available = available;
Logger::info(L"set_tray_icon_update_available: update_available={}", update_available);
if (theme_adaptive_enabled)
{
tray_icon_data.hIcon = get_icon(ThemeHelpers::GetSystemTheme());
}
else
{
auto h_instance = reinterpret_cast<HINSTANCE>(&__ImageBase);
tray_icon_data.hIcon = LoadIcon(h_instance, MAKEINTRESOURCE(available ? APPICON_UPDATE : APPICON));
}
Shell_NotifyIcon(NIM_MODIFY, &tray_icon_data);
}
void set_tray_icon_theme_adaptive(bool theme_adaptive)
{
Logger::info(L"set_tray_icon_theme_adaptive: Called with theme_adaptive={}, current theme_adaptive_enabled={}",
theme_adaptive, theme_adaptive_enabled);
auto h_instance = reinterpret_cast<HINSTANCE>(&__ImageBase);
HICON icon = nullptr;
if (theme_adaptive)
{
icon = get_icon(ThemeHelpers::GetSystemTheme());
if (!icon)
{
Logger::warn(L"set_tray_icon_theme_adaptive: Failed to load theme adaptive icon, falling back to default");
}
}
// If not requesting adaptive icon, or if adaptive icon failed to load, use default icon
if (!icon)
{
icon = LoadIcon(h_instance, MAKEINTRESOURCE(update_available ? APPICON_UPDATE : APPICON));
if (theme_adaptive && icon)
{
// We requested adaptive but had to fall back, so update the flag
theme_adaptive = false;
Logger::info(L"set_tray_icon_theme_adaptive: Using default icon as fallback");
}
}
theme_adaptive_enabled = theme_adaptive;
if (icon)
{
tray_icon_data.hIcon = icon;
BOOL result = Shell_NotifyIcon(NIM_MODIFY, &tray_icon_data);
Logger::info(L"set_tray_icon_theme_adaptive: Icon updated, theme_adaptive_enabled={}, Shell_NotifyIcon result={}",
theme_adaptive_enabled, result);
}
else
{
Logger::error(L"set_tray_icon_theme_adaptive: Failed to load any icon");
}
}
void stop_tray_icon()
{
if (tray_icon_created)
{
// Clear bug report callbacks
BugReportManager::instance().clear_callbacks();
SendMessage(tray_icon_hwnd, WM_CLOSE, 0, 0);
}
}
void update_quick_access_hotkey(bool enabled, PowerToysSettings::HotkeyObject hotkey)
{
static PowerToysSettings::HotkeyObject current_hotkey;
static bool is_registered = false;
auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance();
if (is_registered)
{
CentralizedKeyboardHook::ClearModuleHotkeys(L"QuickAccess");
hkmng.RemoveHotkeyByModule(L"GeneralSettings");
is_registered = false;
}
if (enabled && hotkey.get_code() != 0)
{
HotkeyConflictDetector::Hotkey hk = {
hotkey.win_pressed(),
hotkey.ctrl_pressed(),
hotkey.shift_pressed(),
hotkey.alt_pressed(),
static_cast<unsigned char>(hotkey.get_code())
};
hkmng.AddHotkey(hk, L"GeneralSettings", 0, true);
CentralizedKeyboardHook::SetHotkeyAction(L"QuickAccess", hk, []() {
open_quick_access_flyout_window();
return true;
});
current_hotkey = hotkey;
is_registered = true;
}
}