Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) —
Expand Down
17 changes: 15 additions & 2 deletions docs/system-services.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions docs/windows-native-window.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 18 additions & 5 deletions examples/taskboard/windows/MainWindow.xaml.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,15 @@ MainWindow::MainWindow() {
TaskList().ItemsSource(task_rows_);
try {
auto const window = rivet::system::NativeWindowHandle(*this);
tray_icon_ = std::make_unique<rivet::system::TrayIcon>(
std::vector<rivet::system::TrayMenuItem> 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<rivet::system::TrayController>(
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.
}
Expand All @@ -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();
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion examples/taskboard/windows/MainWindow.xaml.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ struct MainWindow : MainWindowT<MainWindow> {
void ApplyTasks(std::vector<rivet_app::BoardTask> items,
std::optional<std::int64_t> preferred = std::nullopt);
void ShowSelectedTask();
void ActivateFromTray();
void NotifyCompleted(std::string const& title);
void SetBusy(bool busy);
void SetReadyUi();
Expand All @@ -49,7 +50,7 @@ struct MainWindow : MainWindowT<MainWindow> {
winrt::Windows::Foundation::IInspectable>()};
std::optional<std::int64_t> selected_id_;
std::optional<std::uint64_t> generation_request_;
std::unique_ptr<rivet::system::TrayIcon> tray_icon_;
std::unique_ptr<rivet::system::TrayController> tray_controller_;
std::atomic<bool> closing_{false};
};

Expand Down
195 changes: 195 additions & 0 deletions platform/windows/system/system_services.cpp
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
#include "system_services.hpp"

#include <commctrl.h>
#include <shellapi.h>
#include <wincred.h>

#include <algorithm>
#include <functional>
#include <stdexcept>
#include <system_error>
#include <unordered_set>
#include <utility>

#pragma comment(lib, "Advapi32.lib")
#pragma comment(lib, "Comctl32.lib")
#pragma comment(lib, "Shell32.lib")

namespace rivet::system {
Expand Down Expand Up @@ -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<void()> 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<TrayMenuItem> menu,
std::function<void()> activated)
: window_(RequireTrayWindow(window)),
identifier_(identifier),
callback_message_(RequireTrayCallbackMessage(callback_message)),
subclass_id_(reinterpret_cast<UINT_PTR>(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<TrayIcon>(window_, identifier_, callback_message_,
tooltip, icon);
if (!::SetWindowSubclass(window_, &TrayController::WindowSubclass,
subclass_id_, reinterpret_cast<DWORD_PTR>(this))) {
ThrowLastError("SetWindowSubclass");
}
}

TrayController::~TrayController() {
if (window_ != nullptr) {
::RemoveWindowSubclass(window_, &TrayController::WindowSubclass,
subclass_id_);
}
}

void TrayController::SetMenu(std::vector<TrayMenuItem> menu) {
std::unordered_set<UINT> 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<void()> 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<TrayController*>(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<UINT>(w_param) == identifier_) {
auto const event = static_cast<UINT>(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;
Expand Down
Loading
Loading