From 4ab3c49adf9381c3fa6175dd5efa93f62e2389f3 Mon Sep 17 00:00:00 2001 From: "ren.ji" Date: Mon, 12 Oct 2026 23:45:16 +0800 Subject: [PATCH] Add Windows tray menu controller --- CHANGELOG.md | 5 + docs/system-services.md | 17 +- docs/windows-native-window.md | 6 + .../taskboard/windows/MainWindow.xaml.cpp | 23 ++- examples/taskboard/windows/MainWindow.xaml.h | 3 +- platform/windows/system/system_services.cpp | 195 ++++++++++++++++++ platform/windows/system/system_services.hpp | 61 ++++++ tests/documentation.rkt | 9 + tests/native-ui/windows-uia.ps1 | 13 +- 9 files changed, 319 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdcc3d7..96bb705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Add a Windows `rivet::system::TrayController` that owns tray window + subclassing, localized native menu presentation, command dispatch, and + automatic `TaskbarCreated` recovery. The reference Taskboard now exercises + the blessed tray-resident host pattern instead of leaving every application + to rediscover `TrackPopupMenu`'s foreground-window requirement. - The Windows tray icon (`rivet::system::TrayIcon`) can update its tooltip (`SetTooltip`) and re-register itself after an Explorer restart (`Readd`, called from the host's `TaskbarCreated` broadcast handler) — diff --git a/docs/system-services.md b/docs/system-services.md index 48acb4c..b22b58b 100644 --- a/docs/system-services.md +++ b/docs/system-services.md @@ -14,12 +14,25 @@ Generated native hosts call the first-party Swift/C++ system libraries directly, - named mutexes for process uniqueness; - command-line activation for URL schemes and file associations registered by MSI; -- `Shell_NotifyIconW` for tray presence and notification balloons; +- `Shell_NotifyIconW` for tray presence and notification balloons, plus a + `TrayController` that owns the window subclass, native popup menu, command + routing, and `TaskbarCreated` recovery; - the current user's `Run` registry value for opt-in autostart; - Windows Credential Manager (`CredWriteW`/`CredReadW`) for secrets; - `SetUnhandledExceptionFilter` and `RegisterApplicationRestart` as low-level crash/restart hooks. -The host owns the window handle and receives tray callbacks on its WinUI thread. Applications can replace the built-in crash callback with Crashpad, Sentry, a private endpoint, or no uploader; Rivet does not bind to any provider. +The host owns the window handle and receives tray callbacks on its WinUI thread. +Use the low-level `TrayIcon` only when a host deliberately owns all message +routing. Normal tray-resident hosts use `TrayController`: pass localized +`TrayMenuItem` labels and application callbacks, and destroy the controller +before its `HWND`. The controller installs and removes `SetWindowSubclass`, +restores the icon after Explorer restarts, calls `SetForegroundWindow` before +`TrackPopupMenu` so outside clicks dismiss the popup, and routes only its own +menu command identifiers. Callback exceptions never unwind through the Win32 +message procedure. The reference Taskboard is the executable recipe. + +Applications can replace the built-in crash callback with Crashpad, Sentry, a +private endpoint, or no uploader; Rivet does not bind to any provider. ## macOS diff --git a/docs/windows-native-window.md b/docs/windows-native-window.md index 0b0fed2..a910f3e 100644 --- a/docs/windows-native-window.md +++ b/docs/windows-native-window.md @@ -35,6 +35,12 @@ cross-thread access. Remove listeners, subclasses, and registered hotkeys before the owning window is destroyed; an `HWND` is not a lifetime-owning reference. +For tray applications, do not reproduce the `SetWindowSubclass`, +`TaskbarCreated`, `TrackPopupMenu`, and `WM_COMMAND` recipe in each host. +`rivet::system::TrayController` owns that HWND-bound lifecycle. The application +still owns menu semantics by providing localized `TrayMenuItem` values and +callbacks; see the reference Taskboard's `MainWindow.xaml.cpp`. + Example: ```cpp diff --git a/examples/taskboard/windows/MainWindow.xaml.cpp b/examples/taskboard/windows/MainWindow.xaml.cpp index 435f1a7..4ac812f 100644 --- a/examples/taskboard/windows/MainWindow.xaml.cpp +++ b/examples/taskboard/windows/MainWindow.xaml.cpp @@ -69,9 +69,15 @@ MainWindow::MainWindow() { TaskList().ItemsSource(task_rows_); try { auto const window = rivet::system::NativeWindowHandle(*this); - tray_icon_ = std::make_unique( + std::vector menu; + menu.emplace_back(1, L"Open Taskboard", + [this] { ActivateFromTray(); }); + menu.emplace_back(rivet::system::TrayMenuItem::Type::separator); + menu.emplace_back(2, L"Exit", [this] { Close(); }); + tray_controller_ = std::make_unique( window, 1, WM_APP + 42, L"Rivet Taskboard", - ::LoadIconW(nullptr, IDI_APPLICATION)); + ::LoadIconW(nullptr, IDI_APPLICATION), std::move(menu), + [this] { ActivateFromTray(); }); } catch (...) { // Notification availability is platform policy and never blocks startup. } @@ -80,12 +86,19 @@ MainWindow::MainWindow() { // stop. Keeping either owner alive after the last XAML window closes would // strand an apparently exited GUI process. closing_.store(true, std::memory_order_release); - tray_icon_.reset(); + tray_controller_.reset(); if (backend_) backend_->stop(); }); InitializeBackendAsync(); } +void MainWindow::ActivateFromTray() { + auto const window = rivet::system::NativeWindowHandle(*this); + ::ShowWindow(window, SW_RESTORE); + ::SetForegroundWindow(window); + Activate(); +} + winrt::fire_and_forget MainWindow::InitializeBackendAsync() { auto const dispatcher = DispatcherQueue(); auto const weak = get_weak(); @@ -210,9 +223,9 @@ void MainWindow::ShowSelectedTask() { } void MainWindow::NotifyCompleted(std::string const& title) { - if (!tray_icon_) return; + if (!tray_controller_) return; try { - tray_icon_->Notify(L"Task completed", winrt::to_hstring(title).c_str()); + tray_controller_->Notify(L"Task completed", winrt::to_hstring(title).c_str()); } catch (...) { // A denied notification does not turn a successful backend update into an // application failure. diff --git a/examples/taskboard/windows/MainWindow.xaml.h b/examples/taskboard/windows/MainWindow.xaml.h index 76cc73e..97b03aa 100644 --- a/examples/taskboard/windows/MainWindow.xaml.h +++ b/examples/taskboard/windows/MainWindow.xaml.h @@ -36,6 +36,7 @@ struct MainWindow : MainWindowT { void ApplyTasks(std::vector items, std::optional preferred = std::nullopt); void ShowSelectedTask(); + void ActivateFromTray(); void NotifyCompleted(std::string const& title); void SetBusy(bool busy); void SetReadyUi(); @@ -49,7 +50,7 @@ struct MainWindow : MainWindowT { winrt::Windows::Foundation::IInspectable>()}; std::optional selected_id_; std::optional generation_request_; - std::unique_ptr tray_icon_; + std::unique_ptr tray_controller_; std::atomic closing_{false}; }; diff --git a/platform/windows/system/system_services.cpp b/platform/windows/system/system_services.cpp index 39d41c0..d5fe6c1 100644 --- a/platform/windows/system/system_services.cpp +++ b/platform/windows/system/system_services.cpp @@ -1,14 +1,18 @@ #include "system_services.hpp" +#include #include #include +#include #include #include #include +#include #include #pragma comment(lib, "Advapi32.lib") +#pragma comment(lib, "Comctl32.lib") #pragma comment(lib, "Shell32.lib") namespace rivet::system { @@ -192,6 +196,197 @@ void TrayIcon::Notify(std::wstring const& title, if (!::Shell_NotifyIconW(NIM_MODIFY, &data)) ThrowLastError("Shell_NotifyIconW"); } +namespace { + +HWND RequireTrayWindow(HWND window) { + if (window == nullptr) throw std::invalid_argument("tray window must not be null"); + return window; +} + +UINT RequireTrayCallbackMessage(UINT message) { + if (message < WM_APP || message > 0xBFFF) { + throw std::invalid_argument("tray callback message must be in the WM_APP range"); + } + return message; +} + +} // namespace + +TrayMenuItem::TrayMenuItem(UINT identifier, std::wstring label, + std::function activated, bool enabled, + bool checked) + : identifier(identifier), + label(std::move(label)), + enabled(enabled), + checked(checked), + activated(std::move(activated)) {} + +TrayController::TrayController(HWND window, UINT identifier, + UINT callback_message, + std::wstring const& tooltip, HICON icon, + std::vector menu, + std::function activated) + : window_(RequireTrayWindow(window)), + identifier_(identifier), + callback_message_(RequireTrayCallbackMessage(callback_message)), + subclass_id_(reinterpret_cast(this)), + activated_(std::move(activated)) { + SetMenu(std::move(menu)); + taskbar_created_message_ = ::RegisterWindowMessageW(L"TaskbarCreated"); + if (taskbar_created_message_ == 0) ThrowLastError("RegisterWindowMessageW"); + icon_ = std::make_unique(window_, identifier_, callback_message_, + tooltip, icon); + if (!::SetWindowSubclass(window_, &TrayController::WindowSubclass, + subclass_id_, reinterpret_cast(this))) { + ThrowLastError("SetWindowSubclass"); + } +} + +TrayController::~TrayController() { + if (window_ != nullptr) { + ::RemoveWindowSubclass(window_, &TrayController::WindowSubclass, + subclass_id_); + } +} + +void TrayController::SetMenu(std::vector menu) { + std::unordered_set identifiers; + for (auto const& item : menu) { + if (item.type == TrayMenuItem::Type::separator) continue; + if (item.identifier == 0 || item.identifier > 0xFFFF) { + throw std::invalid_argument( + "tray menu command identifiers must be between 1 and 65535"); + } + if (item.label.empty()) { + throw std::invalid_argument("tray menu item labels must not be empty"); + } + if (!item.activated) { + throw std::invalid_argument("tray menu items require a callback"); + } + if (!identifiers.insert(item.identifier).second) { + throw std::invalid_argument("tray menu command identifiers must be unique"); + } + } + menu_ = std::move(menu); +} + +void TrayController::SetActivationHandler(std::function activated) { + activated_ = std::move(activated); +} + +void TrayController::SetTooltip(std::wstring const& tooltip) { + if (!icon_) throw std::logic_error("tray window has already been destroyed"); + icon_->SetTooltip(tooltip); +} + +void TrayController::Notify(std::wstring const& title, + std::wstring const& body) const { + if (!icon_) throw std::logic_error("tray window has already been destroyed"); + icon_->Notify(title, body); +} + +LRESULT CALLBACK TrayController::WindowSubclass( + HWND window, UINT message, WPARAM w_param, LPARAM l_param, UINT_PTR, + DWORD_PTR reference_data) noexcept { + auto* self = reinterpret_cast(reference_data); + if (self == nullptr) return ::DefSubclassProc(window, message, w_param, l_param); + return self->HandleMessage(message, w_param, l_param); +} + +LRESULT TrayController::HandleMessage(UINT message, WPARAM w_param, + LPARAM l_param) noexcept { + if (message == WM_NCDESTROY) { + auto const window = window_; + icon_.reset(); + window_ = nullptr; + ::RemoveWindowSubclass(window, &TrayController::WindowSubclass, + subclass_id_); + return ::DefSubclassProc(window, message, w_param, l_param); + } + + try { + if (message == taskbar_created_message_) { + if (icon_) icon_->Readd(); + return 0; + } + if (message == callback_message_ && + static_cast(w_param) == identifier_) { + auto const event = static_cast(l_param); + if (event == WM_RBUTTONUP || event == WM_CONTEXTMENU) { + ShowMenu(); + return 0; + } + if (event == WM_LBUTTONUP || event == NIN_SELECT || + event == NIN_KEYSELECT) { + auto activated = activated_; + if (activated) activated(); + return 0; + } + } + if (message == WM_COMMAND && HIWORD(w_param) == 0 && l_param == 0 && + DispatchCommand(LOWORD(w_param))) { + return 0; + } + } catch (...) { + ReportCallbackFailure(); + return 0; + } + return ::DefSubclassProc(window_, message, w_param, l_param); +} + +void TrayController::ShowMenu() { + if (menu_.empty() || window_ == nullptr) return; + auto const owner = window_; + auto const popup = ::CreatePopupMenu(); + if (popup == nullptr) ThrowLastError("CreatePopupMenu"); + try { + for (auto const& item : menu_) { + if (item.type == TrayMenuItem::Type::separator) { + if (!::AppendMenuW(popup, MF_SEPARATOR, 0, nullptr)) { + ThrowLastError("AppendMenuW"); + } + continue; + } + UINT flags = MF_STRING; + if (!item.enabled) flags |= MF_GRAYED; + if (item.checked) flags |= MF_CHECKED; + if (!::AppendMenuW(popup, flags, item.identifier, item.label.c_str())) { + ThrowLastError("AppendMenuW"); + } + } + POINT point{}; + if (!::GetCursorPos(&point)) ThrowLastError("GetCursorPos"); + // Required by TrackPopupMenu: without a foreground owner the popup can + // remain visible after the user clicks elsewhere. + ::SetForegroundWindow(owner); + ::TrackPopupMenu(popup, TPM_LEFTALIGN | TPM_BOTTOMALIGN | TPM_RIGHTBUTTON, + point.x, point.y, 0, owner, nullptr); + ::PostMessageW(owner, WM_NULL, 0, 0); + } catch (...) { + ::DestroyMenu(popup); + throw; + } + ::DestroyMenu(popup); +} + +bool TrayController::DispatchCommand(UINT identifier) { + auto const item = std::find_if( + menu_.begin(), menu_.end(), [identifier](TrayMenuItem const& candidate) { + return candidate.type == TrayMenuItem::Type::item && + candidate.identifier == identifier; + }); + if (item == menu_.end()) return false; + if (!item->enabled) return true; + auto activated = item->activated; + activated(); + return true; +} + +void TrayController::ReportCallbackFailure() noexcept { + ::OutputDebugStringW( + L"Rivet TrayController contained an exception at the Win32 callback boundary.\n"); +} + void InstallCrashHook(CrashCallback callback, std::wstring const& restart_arguments) { g_crash_callback = callback; diff --git a/platform/windows/system/system_services.hpp b/platform/windows/system/system_services.hpp index 29f6a18..5a28809 100644 --- a/platform/windows/system/system_services.hpp +++ b/platform/windows/system/system_services.hpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -90,6 +91,66 @@ class TrayIcon final { HICON icon_{}; }; +// One native popup-menu entry. Command identifiers must be unique, non-zero, +// and fit in the low word of WM_COMMAND. Labels and callbacks stay owned by +// the application so localization and business actions do not cross RVT1. +struct TrayMenuItem final { + enum class Type { item, separator }; + + TrayMenuItem() = default; + TrayMenuItem(UINT identifier, std::wstring label, + std::function activated, bool enabled = true, + bool checked = false); + explicit TrayMenuItem(Type type) : type(type) {} + + Type type = Type::item; + UINT identifier{}; + std::wstring label; + bool enabled = true; + bool checked = false; + std::function activated; +}; + +// High-level, UI-thread-owned tray host. It installs and removes the HWND +// subclass, re-adds the icon after Explorer broadcasts TaskbarCreated, opens +// a dismissible native popup menu, and routes WM_COMMAND to app callbacks. +// Destroy this object before destroying the HWND. Callback exceptions are +// contained at the Win32 boundary and reported to the debugger. +class TrayController final { + public: + TrayController(HWND window, UINT identifier, UINT callback_message, + std::wstring const& tooltip, HICON icon, + std::vector menu = {}, + std::function activated = {}); + ~TrayController(); + TrayController(TrayController const&) = delete; + TrayController& operator=(TrayController const&) = delete; + + void SetMenu(std::vector menu); + void SetActivationHandler(std::function activated); + void SetTooltip(std::wstring const& tooltip); + void Notify(std::wstring const& title, std::wstring const& body) const; + + private: + static LRESULT CALLBACK WindowSubclass(HWND window, UINT message, + WPARAM w_param, LPARAM l_param, + UINT_PTR subclass_id, + DWORD_PTR reference_data) noexcept; + LRESULT HandleMessage(UINT message, WPARAM w_param, LPARAM l_param) noexcept; + void ShowMenu(); + bool DispatchCommand(UINT identifier); + static void ReportCallbackFailure() noexcept; + + HWND window_{}; + UINT identifier_{}; + UINT callback_message_{}; + UINT taskbar_created_message_{}; + UINT_PTR subclass_id_{}; + std::unique_ptr icon_; + std::vector menu_; + std::function activated_; +}; + using CrashCallback = void (*)(EXCEPTION_POINTERS const*) noexcept; void InstallCrashHook(CrashCallback callback, std::wstring const& restart_arguments = L""); diff --git a/tests/documentation.rkt b/tests/documentation.rkt index 0392688..67ee4df 100644 --- a/tests/documentation.rkt +++ b/tests/documentation.rkt @@ -162,3 +162,12 @@ (define runtime-limits-guide (build-path repository-root "docs" "runtime-limits.md")) (check-file-contains runtime-limits-guide "Windows, macOS, and Linux") + +(define system-services-guide + (build-path repository-root "docs" "system-services.md")) +(for ([expected (in-list '("TrayController" + "SetWindowSubclass" + "TaskbarCreated" + "SetForegroundWindow" + "TrackPopupMenu"))]) + (check-file-contains system-services-guide expected)) diff --git a/tests/native-ui/windows-uia.ps1 b/tests/native-ui/windows-uia.ps1 index ccd1c5c..03e45d3 100644 --- a/tests/native-ui/windows-uia.ps1 +++ b/tests/native-ui/windows-uia.ps1 @@ -164,7 +164,7 @@ try { Set-Content -LiteralPath (Join-Path $OutputDirectory 'accessibility-after.json') -Encoding utf8 [ordered]@{ application = 'Rivet Taskboard' - actions = @('invoke:new-task', 'invoke:generate-demo') + actions = @('invoke:new-task', 'invoke:generate-demo', 'tray-command:exit') observedStatus = @($observedStatus) assertions = @( 'native WinUI window exposed' @@ -172,6 +172,7 @@ try { 'RPC-created task appeared' 'operation-progress Event appeared' '1,000-row State reached the UI' + 'tray menu command reached its application callback' ) } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'interaction-trace.json') -Encoding utf8 @@ -180,13 +181,15 @@ try { # Target the exact HWND exposed by the UIA root. Process.MainWindowHandle # is heuristic and may select a diagnostic console instead of the WinUI # window; WindowPattern is also absent when WinUI exposes the root as a - # pane. WM_CLOSE is the same native path as the title-bar close button. + # pane. Command 2 is the reference host's tray-menu Exit action, so this + # also proves that TrayController's HWND subclass routes WM_COMMAND back to + # its application callback before the normal close lifecycle runs. $shutdownTimer = [System.Diagnostics.Stopwatch]::StartNew() $nativeWindow = [IntPtr]$window.Current.NativeWindowHandle if ($nativeWindow -eq [IntPtr]::Zero -or -not [RivetNativeUi]::PostMessage( - $nativeWindow, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)) { - throw 'Taskboard did not expose a closeable native WinUI HWND' + $nativeWindow, 0x0111, [IntPtr]2, [IntPtr]::Zero)) { + throw 'Taskboard did not expose a tray-command-capable native WinUI HWND' } # UI Automation proxies keep out-of-process provider objects alive. Drop # every retained proxy once WM_CLOSE is sent so XAML can destroy the large @@ -201,7 +204,7 @@ try { [GC]::WaitForPendingFinalizers() [GC]::Collect() if (-not $process.WaitForExit(10000)) { - throw 'Taskboard did not exit within ten seconds of native window close' + throw 'Taskboard did not exit within ten seconds of its tray Exit command' } $shutdownTimer.Stop() [ordered]@{ shutdownMilliseconds = $shutdownTimer.ElapsedMilliseconds } |