@crossbind/extension-menu 0.0.0-stage → 2.0.0-beta.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,687 @@
1
+ // The page's menus, the tray's icon in the notification area and the system's notifications on Windows: each window's
2
+ // menus on it, whose procedure the extension takes their commands in, and notifications as balloons of the tray's icon.
3
+ #include "menu.h"
4
+
5
+ #include "crossbind/app.h"
6
+
7
+ #include <windows.h>
8
+
9
+ #include <commctrl.h>
10
+ #include <shellapi.h>
11
+ #include <windowsx.h>
12
+
13
+ #include "WebView2.h"
14
+
15
+ #include <algorithm>
16
+ #include <atomic>
17
+ #include <cctype>
18
+ #include <map>
19
+ #include <stdexcept>
20
+ #include <utility>
21
+ #include <vector>
22
+
23
+ namespace crossbind::menu {
24
+ namespace {
25
+
26
+ // Commands below it are the window's own.
27
+ constexpr UINT FIRST_COMMAND = 0x1000;
28
+ // The messages of the extension's own, registered, as other extensions post the app's window theirs.
29
+ // An accelerator's item, which the webview's key handler posts with the generation of the menus it was pressed in.
30
+ const UINT WM_SHORTCUT = RegisterWindowMessageW(L"crossbind.menu.shortcut");
31
+ // Sets the menus the page set while one of the bar's was open.
32
+ const UINT WM_MENUS_WAITING = RegisterWindowMessageW(L"crossbind.menu.waiting");
33
+ // What Windows tells of the tray's icon, which it sends the tray's window.
34
+ const UINT WM_TRAY = RegisterWindowMessageW(L"crossbind.menu.tray");
35
+ // The one icon the app puts in the notification area.
36
+ constexpr UINT TRAY_ICON = 1;
37
+ // The app's icon among its resources, which app.rc gives the id of Windows' own.
38
+ constexpr WORD APP_ICON = 32512;
39
+ // The extension's subclass of each window of the app's.
40
+ constexpr UINT_PTR SUBCLASS = 1;
41
+
42
+ // What a click of an item does: one of the page's tells the page its id, a standard one does what its role says.
43
+ struct Command {
44
+ std::string id;
45
+ std::string role;
46
+ bool isCheck = false;
47
+ };
48
+
49
+ // An accelerator, which reaches the menus from the webview, as it holds the keyboard.
50
+ struct Shortcut {
51
+ UINT key;
52
+ bool control;
53
+ bool alt;
54
+ bool shift;
55
+ UINT index;
56
+ };
57
+
58
+ struct Built {
59
+ std::vector<Command> commands;
60
+ std::vector<Shortcut> shortcuts;
61
+ };
62
+
63
+ // A standard item's label and the accelerator Windows apps show for it. The webview does the editing ones when its user
64
+ // presses that key with Ctrl, which a click sends it.
65
+ struct Role {
66
+ const wchar_t *label;
67
+ const wchar_t *shortcut;
68
+ WORD editKey;
69
+ };
70
+
71
+ const std::map<std::string, Role> ROLES{
72
+ {"undo", {L"Undo", L"Ctrl+Z", 'Z'}}, {"redo", {L"Redo", L"Ctrl+Y", 'Y'}}, {"cut", {L"Cut", L"Ctrl+X", 'X'}},
73
+ {"copy", {L"Copy", L"Ctrl+C", 'C'}}, {"paste", {L"Paste", L"Ctrl+V", 'V'}}, {"selectAll", {L"Select All", L"Ctrl+A", 'A'}},
74
+ {"minimize", {L"Minimize", L"", 0}}, {"close", {L"Close", L"", 0}}, {"quit", {L"Exit", L"Alt+F4", 0}},
75
+ };
76
+
77
+ const std::map<std::string, UINT> NAMED_KEYS{
78
+ {"Enter", VK_RETURN}, {"Escape", VK_ESCAPE}, {"Tab", VK_TAB}, {"Space", VK_SPACE}, {"Backspace", VK_BACK},
79
+ {"Delete", VK_DELETE}, {"Up", VK_UP}, {"Down", VK_DOWN}, {"Left", VK_LEFT}, {"Right", VK_RIGHT},
80
+ {"Home", VK_HOME}, {"End", VK_END}, {"PageUp", VK_PRIOR}, {"PageDown", VK_NEXT}, {"Plus", VK_OEM_PLUS},
81
+ {"Minus", VK_OEM_MINUS}, {"Comma", VK_OEM_COMMA}, {"Period", VK_OEM_PERIOD},
82
+ };
83
+
84
+ // A window's menus: the window and its webview, its bar and what its items do, a generation each change of them moves
85
+ // on, so an accelerator pressed before one runs nothing after it, and the menus set while one of its own was open, which
86
+ // wait for it.
87
+ struct WindowMenus {
88
+ HWND window = nullptr;
89
+ ICoreWebView2Controller *controller = nullptr;
90
+ HMENU bar = nullptr;
91
+ Built built;
92
+ LPARAM generation = 0;
93
+ std::vector<std::function<void()>> waiting;
94
+ };
95
+ // By the windows' ids, on the main thread.
96
+ std::map<uint64_t, WindowMenus> windowMenus;
97
+ MenuClicked onClick;
98
+
99
+ // A hidden window takes the tray icon's messages. As a top-level one, it hears as well that Explorer started anew, as
100
+ // after a crash, without the icons it showed.
101
+ HWND trayWindow = nullptr;
102
+ UINT taskbarCreated = 0;
103
+ HMENU trayMenu = nullptr;
104
+ Built trayBuilt;
105
+ std::wstring trayTip;
106
+ // An icon shows in the notification area while the page sets the tray, or while a notification comes from it.
107
+ bool isTraySet = false;
108
+ bool isIconShown = false;
109
+ // The notifications shown from an icon the page did not set, which goes with the last.
110
+ int balloons = 0;
111
+ bool isTrayMenuOpen = false;
112
+ std::vector<std::function<void()>> trayWaiting;
113
+
114
+ std::wstring wide(const std::string &text) {
115
+ if (text.empty()) return {};
116
+ const int size = MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), nullptr, 0);
117
+ std::wstring result(static_cast<size_t>(size), L'\0');
118
+ MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), result.data(), size);
119
+ return result;
120
+ }
121
+
122
+ UINT keyOf(const Accelerator &accelerator) {
123
+ const std::string &key = accelerator.key;
124
+ if (accelerator.function) return VK_F1 + static_cast<UINT>(accelerator.function) - 1;
125
+ if (key.size() == 1) return static_cast<UINT>(std::toupper(static_cast<unsigned char>(key[0])));
126
+ const auto named = NAMED_KEYS.find(key);
127
+ if (named == NAMED_KEYS.end()) throw std::invalid_argument("crossbind: no accelerator has the key " + key);
128
+ return named->second;
129
+ }
130
+
131
+ std::wstring shortcutText(const Accelerator &accelerator) {
132
+ return std::wstring(accelerator.control ? L"Ctrl+" : L"") + (accelerator.alt ? L"Alt+" : L"") + (accelerator.shift ? L"Shift+" : L"") +
133
+ wide(accelerator.key);
134
+ }
135
+
136
+ // Windows reads & in a label as the start of a mnemonic.
137
+ std::wstring labelOf(const std::string &label) {
138
+ std::wstring out;
139
+ for (const wchar_t c : wide(label)) out += c == L'&' ? std::wstring(L"&&") : std::wstring(1, c);
140
+ return out;
141
+ }
142
+
143
+ // One of the window's own, not of a dialog it shows.
144
+ bool isMenuOpen(HWND window) {
145
+ GUITHREADINFO info{};
146
+ info.cbSize = sizeof(info);
147
+ return GetGUIThreadInfo(GetCurrentThreadId(), &info) && (info.flags & GUI_INMENUMODE) && info.hwndMenuOwner == window;
148
+ }
149
+
150
+ // A standard item's role, which acts on the window whose menu it is in.
151
+ void runRole(const std::string &role, HWND window, ICoreWebView2Controller *controller) {
152
+ const auto found = ROLES.find(role);
153
+ if (found != ROLES.end() && found->second.editKey) {
154
+ if (controller) controller->MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC);
155
+ INPUT keys[4] = {};
156
+ for (auto &key : keys) key.type = INPUT_KEYBOARD;
157
+ keys[0].ki.wVk = VK_CONTROL;
158
+ keys[1].ki.wVk = found->second.editKey;
159
+ keys[2].ki.wVk = found->second.editKey;
160
+ keys[2].ki.dwFlags = KEYEVENTF_KEYUP;
161
+ keys[3].ki.wVk = VK_CONTROL;
162
+ keys[3].ki.dwFlags = KEYEVENTF_KEYUP;
163
+ SendInput(4, keys, sizeof(INPUT));
164
+ } else if (role == "minimize") {
165
+ ShowWindow(window, SW_MINIMIZE);
166
+ } else if (role == "quit") {
167
+ app::requestQuit();
168
+ } else {
169
+ PostMessageW(window, WM_CLOSE, 0, 0);
170
+ }
171
+ }
172
+
173
+ // An item of a window's menus, or for 0 of the tray's, by its index among their commands.
174
+ void run(HMENU menu, const Built &from, UINT index, uint64_t window) {
175
+ const UINT command = FIRST_COMMAND + index;
176
+ if (index >= from.commands.size() || (GetMenuState(menu, command, MF_BYCOMMAND) & (MF_GRAYED | MF_DISABLED))) return;
177
+ const Command &item = from.commands[index];
178
+ if (!item.role.empty()) {
179
+ const auto menus = windowMenus.find(window);
180
+ if (menus != windowMenus.end()) runRole(item.role, menus->second.window, menus->second.controller);
181
+ return;
182
+ }
183
+ std::optional<bool> checked;
184
+ if (item.isCheck) {
185
+ checked = !(GetMenuState(menu, command, MF_BYCOMMAND) & MF_CHECKED);
186
+ CheckMenuItem(menu, command, MF_BYCOMMAND | (*checked ? MF_CHECKED : MF_UNCHECKED));
187
+ }
188
+ if (onClick && !item.id.empty()) onClick(window, item.id, checked);
189
+ }
190
+
191
+ // Cut to what Windows keeps.
192
+ template <size_t N>
193
+ void copyText(wchar_t (&into)[N], const std::wstring &text) {
194
+ const size_t length = std::min(text.size(), N - 1);
195
+ std::copy_n(text.data(), length, into);
196
+ into[length] = L'\0';
197
+ }
198
+
199
+ // The app's icon at one of Windows' sizes, or Windows' own for an app without one.
200
+ HICON iconAt(int metric) {
201
+ const int size = GetSystemMetrics(metric);
202
+ if (auto *own = static_cast<HICON>(LoadImageW(GetModuleHandleW(nullptr), MAKEINTRESOURCEW(APP_ICON), IMAGE_ICON, size, size, 0))) return own;
203
+ return static_cast<HICON>(LoadImageW(nullptr, MAKEINTRESOURCEW(APP_ICON), IMAGE_ICON, size, size, LR_SHARED));
204
+ }
205
+
206
+ NOTIFYICONDATAW iconData(UINT flags) {
207
+ NOTIFYICONDATAW data{};
208
+ data.cbSize = sizeof(data);
209
+ data.hWnd = trayWindow;
210
+ data.uID = TRAY_ICON;
211
+ data.uFlags = flags | NIF_TIP | NIF_SHOWTIP;
212
+ copyText(data.szTip, trayTip);
213
+ return data;
214
+ }
215
+
216
+ bool showIcon() {
217
+ static const HICON icon = iconAt(SM_CXSMICON);
218
+ NOTIFYICONDATAW data = iconData(NIF_MESSAGE | NIF_ICON);
219
+ data.uCallbackMessage = WM_TRAY;
220
+ data.hIcon = icon;
221
+ if (!Shell_NotifyIconW(NIM_ADD, &data)) return false;
222
+ data.uVersion = NOTIFYICON_VERSION_4;
223
+ Shell_NotifyIconW(NIM_SETVERSION, &data);
224
+ isIconShown = true;
225
+ return true;
226
+ }
227
+
228
+ void hideIcon() {
229
+ if (isIconShown) {
230
+ NOTIFYICONDATAW data = iconData(0);
231
+ Shell_NotifyIconW(NIM_DELETE, &data);
232
+ }
233
+ isIconShown = false;
234
+ balloons = 0;
235
+ }
236
+
237
+ // Why the icon did not show, right after Windows took none.
238
+ std::string noNotificationArea() {
239
+ return "crossbind: Windows' notification area did not take the app's icon: error " + std::to_string(GetLastError());
240
+ }
241
+
242
+ // A window's subclass, which knows the window's id.
243
+ LRESULT CALLBACK menuProcedure(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam, UINT_PTR, DWORD_PTR id) {
244
+ const auto found = windowMenus.find(static_cast<uint64_t>(id));
245
+ if (found == windowMenus.end()) return DefSubclassProc(hwnd, message, wParam, lParam);
246
+ WindowMenus &menus = found->second;
247
+ if (message == WM_COMMAND && HIWORD(wParam) == 0 && lParam == 0 && LOWORD(wParam) >= FIRST_COMMAND) {
248
+ run(menus.bar, menus.built, LOWORD(wParam) - FIRST_COMMAND, static_cast<uint64_t>(id));
249
+ return 0;
250
+ }
251
+ if (message == WM_SHORTCUT) {
252
+ if (lParam == menus.generation) run(menus.bar, menus.built, static_cast<UINT>(wParam), static_cast<uint64_t>(id));
253
+ return 0;
254
+ }
255
+ if (message == WM_EXITMENULOOP && !menus.waiting.empty()) PostMessageW(hwnd, WM_MENUS_WAITING, 0, 0);
256
+ // Posted once more, so the command of a click on the menu that closed runs first, on the menus it was made for.
257
+ if (message == WM_MENUS_WAITING) {
258
+ if (wParam == 0) {
259
+ PostMessageW(hwnd, WM_MENUS_WAITING, 1, 0);
260
+ } else if (!isMenuOpen(hwnd)) {
261
+ for (auto &set : std::exchange(menus.waiting, {})) set();
262
+ }
263
+ return 0;
264
+ }
265
+ return DefSubclassProc(hwnd, message, wParam, lParam);
266
+ }
267
+
268
+ // A window's webview's key presses, which reach its menus' accelerators first, as the webview holds the keyboard.
269
+ class KeyPressed final : public ICoreWebView2AcceleratorKeyPressedEventHandler {
270
+ public:
271
+ explicit KeyPressed(uint64_t window) : window_(window) {}
272
+
273
+ ULONG STDMETHODCALLTYPE AddRef() override { return ++refs_; }
274
+ ULONG STDMETHODCALLTYPE Release() override {
275
+ const ULONG left = --refs_;
276
+ if (left == 0) delete this;
277
+ return left;
278
+ }
279
+ HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **out) override {
280
+ if (!out) return E_POINTER;
281
+ if (!IsEqualIID(riid, IID_IUnknown) && !IsEqualIID(riid, IID_ICoreWebView2AcceleratorKeyPressedEventHandler)) {
282
+ *out = nullptr;
283
+ return E_NOINTERFACE;
284
+ }
285
+ *out = static_cast<ICoreWebView2AcceleratorKeyPressedEventHandler *>(this);
286
+ AddRef();
287
+ return S_OK;
288
+ }
289
+ HRESULT STDMETHODCALLTYPE Invoke(ICoreWebView2Controller *, ICoreWebView2AcceleratorKeyPressedEventArgs *args) override {
290
+ COREWEBVIEW2_KEY_EVENT_KIND kind;
291
+ UINT key = 0;
292
+ COREWEBVIEW2_PHYSICAL_KEY_STATUS status{};
293
+ if (FAILED(args->get_KeyEventKind(&kind)) || FAILED(args->get_VirtualKey(&key)) || FAILED(args->get_PhysicalKeyStatus(&status))) return S_OK;
294
+ if (kind != COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN && kind != COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN) return S_OK;
295
+ const auto found = windowMenus.find(window_);
296
+ if (found == windowMenus.end()) return S_OK;
297
+ const WindowMenus &menus = found->second;
298
+ const bool control = GetKeyState(VK_CONTROL) < 0;
299
+ const bool alt = GetKeyState(VK_MENU) < 0;
300
+ const bool shift = GetKeyState(VK_SHIFT) < 0;
301
+ for (const auto &shortcut : menus.built.shortcuts) {
302
+ if (shortcut.key != key || shortcut.control != control || shortcut.alt != alt || shortcut.shift != shift) continue;
303
+ // A held key repeats, which neither the page nor the menus take.
304
+ args->put_Handled(TRUE);
305
+ if (!status.WasKeyDown) PostMessageW(menus.window, WM_SHORTCUT, shortcut.index, menus.generation);
306
+ break;
307
+ }
308
+ return S_OK;
309
+ }
310
+
311
+ private:
312
+ std::atomic<ULONG> refs_{1};
313
+ uint64_t window_;
314
+ };
315
+
316
+ // A window's menus go on the top window, WebView2's own being a child of it: the extension's subclass of its procedure
317
+ // takes their commands first, and the webview hands their accelerators over. Once for each open window, on the main
318
+ // thread: none for one that closed.
319
+ WindowMenus *follow(uint64_t id) {
320
+ if (const auto found = windowMenus.find(id); found != windowMenus.end()) return &found->second;
321
+ const std::optional<Window> open = windowOf(id);
322
+ if (!open || !SetWindowSubclass(static_cast<HWND>(open->native()), menuProcedure, SUBCLASS, static_cast<DWORD_PTR>(id))) return nullptr;
323
+ WindowMenus &menus = windowMenus[id];
324
+ menus.window = static_cast<HWND>(open->native());
325
+ menus.controller = static_cast<ICoreWebView2Controller *>(open->webView());
326
+ if (menus.controller) {
327
+ auto *pressed = new KeyPressed(id);
328
+ EventRegistrationToken token;
329
+ menus.controller->add_AcceleratorKeyPressed(pressed, &token);
330
+ pressed->Release();
331
+ }
332
+ return &menus;
333
+ }
334
+
335
+ void addItem(HMENU menu, const MenuItem &page, Built &into) {
336
+ if (page.isSeparator) {
337
+ AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
338
+ return;
339
+ }
340
+ if (page.isSubmenu) {
341
+ HMENU submenu = CreatePopupMenu();
342
+ if (!submenu) throw std::runtime_error("crossbind: the window's menus cannot be made: error " + std::to_string(GetLastError()));
343
+ // In its menu first, which destroys it with itself.
344
+ AppendMenuW(menu, MF_POPUP | MF_STRING, reinterpret_cast<UINT_PTR>(submenu), labelOf(page.label).c_str());
345
+ for (const auto &child : page.items) addItem(submenu, child, into);
346
+ return;
347
+ }
348
+ const UINT index = static_cast<UINT>(into.commands.size());
349
+ std::wstring label = labelOf(page.label);
350
+ std::wstring shortcut = page.accelerator ? shortcutText(*page.accelerator) : L"";
351
+ bool isShortcut = page.accelerator.has_value();
352
+ if (!page.role.empty()) {
353
+ const auto role = ROLES.find(page.role);
354
+ if (role == ROLES.end()) throw std::invalid_argument("crossbind: no menu item has the role " + page.role);
355
+ if (page.label.empty()) label = role->second.label;
356
+ // An editing role keeps the keys the webview does itself.
357
+ if (role->second.editKey || !page.accelerator) shortcut = role->second.shortcut;
358
+ isShortcut = isShortcut && !role->second.editKey;
359
+ into.commands.push_back({"", page.role, false});
360
+ } else {
361
+ into.commands.push_back({page.id, "", page.checked.has_value()});
362
+ }
363
+ if (isShortcut) {
364
+ const Accelerator &accelerator = *page.accelerator;
365
+ into.shortcuts.push_back({keyOf(accelerator), accelerator.control, accelerator.alt, accelerator.shift, index});
366
+ }
367
+ if (!shortcut.empty()) label += L"\t" + shortcut;
368
+ const UINT flags = MF_STRING | (page.isEnabled ? MF_ENABLED : MF_GRAYED) | (page.checked.value_or(false) ? MF_CHECKED : MF_UNCHECKED);
369
+ AppendMenuW(menu, flags, FIRST_COMMAND + index, label.c_str());
370
+ }
371
+
372
+ // The page keeps the height its window gave it, so the window takes the bar's height on, unless it is maximized or
373
+ // fills the screen, which takes its caption away.
374
+ void keepPageHeight(HWND window, LONG pageHeight) {
375
+ RECT client{};
376
+ RECT frame{};
377
+ const bool fillsScreen = !(GetWindowLongPtrW(window, GWL_STYLE) & WS_CAPTION);
378
+ if (IsZoomed(window) || fillsScreen || !GetClientRect(window, &client) || !GetWindowRect(window, &frame)) return;
379
+ const LONG grow = pageHeight - (client.bottom - client.top);
380
+ if (grow == 0) return;
381
+ SetWindowPos(window, nullptr, 0, 0, frame.right - frame.left, frame.bottom - frame.top + grow, SWP_NOZORDER | SWP_NOMOVE | SWP_NOACTIVATE);
382
+ }
383
+
384
+ // No menus take the bar away.
385
+ void setMenus(WindowMenus &into, const std::vector<MenuItem> &menus, const MenuClicked &clicked, const std::function<void(Failure)> &done) {
386
+ HMENU next = menus.empty() ? nullptr : CreateMenu();
387
+ if (!menus.empty() && !next) {
388
+ done("crossbind: the window's menus cannot be made: error " + std::to_string(GetLastError()));
389
+ return;
390
+ }
391
+ Built nextBuilt;
392
+ try {
393
+ for (const auto &menu : menus) addItem(next, menu, nextBuilt);
394
+ } catch (const std::exception &error) {
395
+ DestroyMenu(next);
396
+ done(std::string(error.what()));
397
+ return;
398
+ }
399
+ RECT page{};
400
+ GetClientRect(into.window, &page);
401
+ if (!SetMenu(into.window, next)) {
402
+ const DWORD error = GetLastError();
403
+ if (next) DestroyMenu(next);
404
+ done("crossbind: the window's menus cannot change: error " + std::to_string(error));
405
+ return;
406
+ }
407
+ if (into.bar) DestroyMenu(into.bar);
408
+ into.bar = next;
409
+ into.built = std::move(nextBuilt);
410
+ into.generation += 1;
411
+ onClick = clicked;
412
+ keepPageHeight(into.window, page.bottom - page.top);
413
+ done(std::nullopt);
414
+ }
415
+
416
+ void balloonEnded() {
417
+ balloons = std::max(balloons - 1, 0);
418
+ if (!isTraySet && balloons == 0) hideIcon();
419
+ }
420
+
421
+ // Windows closes the menu as its user clicks elsewhere only while its window is in front, which a click of the icon
422
+ // lets it be, and opens the next only after a message of the window's. What the page sets meanwhile waits for it.
423
+ void openTrayMenu(int x, int y) {
424
+ if (isTrayMenuOpen) return;
425
+ SetForegroundWindow(trayWindow);
426
+ isTrayMenuOpen = true;
427
+ const auto command = static_cast<UINT>(TrackPopupMenu(trayMenu, TPM_RETURNCMD | TPM_NONOTIFY | TPM_RIGHTBUTTON, x, y, 0, trayWindow, nullptr));
428
+ isTrayMenuOpen = false;
429
+ PostMessageW(trayWindow, WM_NULL, 0, 0);
430
+ if (command >= FIRST_COMMAND) run(trayMenu, trayBuilt, command - FIRST_COMMAND, 0);
431
+ for (auto &set : std::exchange(trayWaiting, {})) set();
432
+ }
433
+
434
+ // A right click opens the tray's menu; any other click of the icon, or one without a menu, tells the page. An icon that
435
+ // shows for a notification alone tells nothing.
436
+ LRESULT CALLBACK trayProcedure(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {
437
+ if (message == WM_TRAY) {
438
+ switch (LOWORD(lParam)) {
439
+ case NIN_SELECT:
440
+ case NIN_KEYSELECT:
441
+ if (isTraySet) app::emitEventToFront("tray");
442
+ break;
443
+ case WM_CONTEXTMENU:
444
+ if (trayMenu) {
445
+ openTrayMenu(GET_X_LPARAM(wParam), GET_Y_LPARAM(wParam));
446
+ } else if (isTraySet) {
447
+ app::emitEventToFront("tray");
448
+ }
449
+ break;
450
+ case NIN_BALLOONUSERCLICK:
451
+ app::emitEventToFront("notification");
452
+ balloonEnded();
453
+ break;
454
+ case NIN_BALLOONTIMEOUT:
455
+ case NIN_BALLOONHIDE:
456
+ balloonEnded();
457
+ break;
458
+ default:
459
+ break;
460
+ }
461
+ return 0;
462
+ }
463
+ if (taskbarCreated && message == taskbarCreated) {
464
+ isIconShown = false;
465
+ balloons = 0;
466
+ if (isTraySet) showIcon();
467
+ return 0;
468
+ }
469
+ return DefWindowProcW(hwnd, message, wParam, lParam);
470
+ }
471
+
472
+ bool makeTrayWindow() {
473
+ if (trayWindow) return true;
474
+ WNDCLASSW type{};
475
+ type.lpfnWndProc = trayProcedure;
476
+ type.hInstance = GetModuleHandleW(nullptr);
477
+ type.lpszClassName = L"crossbind_tray";
478
+ RegisterClassW(&type);
479
+ trayWindow = CreateWindowExW(0, type.lpszClassName, L"", WS_POPUP, 0, 0, 0, 0, nullptr, nullptr, type.hInstance, nullptr);
480
+ if (!trayWindow) return false;
481
+ taskbarCreated = RegisterWindowMessageW(L"TaskbarCreated");
482
+ // Explorer runs as its user, whose messages reach an app run as administrator only where it lets them.
483
+ ChangeWindowMessageFilterEx(trayWindow, WM_TRAY, MSGFLT_ALLOW, nullptr);
484
+ if (taskbarCreated) ChangeWindowMessageFilterEx(trayWindow, taskbarCreated, MSGFLT_ALLOW, nullptr);
485
+ return true;
486
+ }
487
+
488
+ void setTrayNow(const TrayOptions &options, const MenuClicked &clicked, const std::function<void(Failure)> &done) {
489
+ if (!makeTrayWindow()) {
490
+ done("crossbind: the tray's window cannot be made: error " + std::to_string(GetLastError()));
491
+ return;
492
+ }
493
+ HMENU next = options.menu.empty() ? nullptr : CreatePopupMenu();
494
+ if (!options.menu.empty() && !next) {
495
+ done("crossbind: the tray's menu cannot be made: error " + std::to_string(GetLastError()));
496
+ return;
497
+ }
498
+ Built nextBuilt;
499
+ try {
500
+ for (const auto &item : options.menu) addItem(next, item, nextBuilt);
501
+ } catch (const std::exception &error) {
502
+ DestroyMenu(next);
503
+ done(std::string(error.what()));
504
+ return;
505
+ }
506
+ trayTip = wide(options.tooltip);
507
+ NOTIFYICONDATAW data = iconData(0);
508
+ if (!(isIconShown && Shell_NotifyIconW(NIM_MODIFY, &data)) && !showIcon()) {
509
+ const std::string why = noNotificationArea();
510
+ if (next) DestroyMenu(next);
511
+ done(why);
512
+ return;
513
+ }
514
+ if (trayMenu) DestroyMenu(trayMenu);
515
+ trayMenu = next;
516
+ trayBuilt = std::move(nextBuilt);
517
+ onClick = clicked;
518
+ isTraySet = true;
519
+ done(std::nullopt);
520
+ }
521
+
522
+ // Taking the icon away would take a notification that shows from it, so the icon stays until the notification goes.
523
+ void removeTrayNow() {
524
+ if (trayMenu) DestroyMenu(trayMenu);
525
+ trayMenu = nullptr;
526
+ trayBuilt = {};
527
+ trayTip.clear();
528
+ isTraySet = false;
529
+ if (balloons == 0) {
530
+ hideIcon();
531
+ return;
532
+ }
533
+ NOTIFYICONDATAW data = iconData(0);
534
+ Shell_NotifyIconW(NIM_MODIFY, &data);
535
+ }
536
+
537
+ } // namespace
538
+
539
+ // Menus set while one of the window's is open, or while others wait, wait for it to close, in order.
540
+ void setMenu(uint64_t window, std::vector<MenuItem> menus, MenuClicked clicked, std::function<void(Failure)> done) {
541
+ onMainThread([window, menus = std::move(menus), clicked = std::move(clicked), done = std::move(done)] {
542
+ WindowMenus *into = follow(window);
543
+ if (!into) {
544
+ done("crossbind: the page's window has closed");
545
+ return;
546
+ }
547
+ if (isMenuOpen(into->window) || !into->waiting.empty()) {
548
+ into->waiting.push_back([window, menus, clicked, done] {
549
+ if (WindowMenus *still = follow(window)) setMenus(*still, menus, clicked, done);
550
+ });
551
+ return;
552
+ }
553
+ setMenus(*into, menus, clicked, done);
554
+ });
555
+ }
556
+
557
+ // The subclass goes before the window, and the bar with it.
558
+ void dropMenus(uint64_t window) {
559
+ const auto found = windowMenus.find(window);
560
+ if (found == windowMenus.end()) return;
561
+ RemoveWindowSubclass(found->second.window, menuProcedure, SUBCLASS);
562
+ windowMenus.erase(found);
563
+ }
564
+
565
+ // The app's icon would stay in the notification area until a pointer passed over it.
566
+ void quitTray() { hideIcon(); }
567
+
568
+ // The menu opens at the page's point in the webview, which WebView2 zooms and scales to the screen's pixels, from the
569
+ // top left of its bounds in its parent window. Its loop runs the tasks of the main thread, and so the page's next
570
+ // popup, which waits for none.
571
+ void popupMenu(uint64_t window, std::vector<MenuItem> items, double x, double y, std::function<void(Failure, std::optional<std::string>)> done) {
572
+ onMainThread([window, items = std::move(items), x, y, done = std::move(done)] {
573
+ static bool isOpen = false;
574
+ WindowMenus *shown = follow(window);
575
+ if (!shown || !shown->controller) {
576
+ done(std::string("crossbind: the page's window has closed"), std::nullopt);
577
+ return;
578
+ }
579
+ ICoreWebView2Controller *controller = shown->controller;
580
+ if (isOpen) {
581
+ done(std::string("crossbind: a menu of the page's is open already"), std::nullopt);
582
+ return;
583
+ }
584
+ HMENU menu = CreatePopupMenu();
585
+ if (!menu) {
586
+ done("crossbind: the menu cannot be made: error " + std::to_string(GetLastError()), std::nullopt);
587
+ return;
588
+ }
589
+ Built popup;
590
+ try {
591
+ for (const auto &item : items) addItem(menu, item, popup);
592
+ } catch (const std::exception &error) {
593
+ DestroyMenu(menu);
594
+ done(std::string(error.what()), std::nullopt);
595
+ return;
596
+ }
597
+ RECT bounds{};
598
+ controller->get_Bounds(&bounds);
599
+ double zoom = 1;
600
+ controller->get_ZoomFactor(&zoom);
601
+ double scale = 1;
602
+ ICoreWebView2Controller3 *scaled = nullptr;
603
+ if (SUCCEEDED(controller->QueryInterface(IID_ICoreWebView2Controller3, reinterpret_cast<void **>(&scaled)))) {
604
+ scaled->get_RasterizationScale(&scale);
605
+ scaled->Release();
606
+ }
607
+ HWND parent = nullptr;
608
+ controller->get_ParentWindow(&parent);
609
+ POINT at{bounds.left + static_cast<LONG>(x * zoom * scale), bounds.top + static_cast<LONG>(y * zoom * scale)};
610
+ ClientToScreen(parent ? parent : shown->window, &at);
611
+ isOpen = true;
612
+ const HWND owner = shown->window;
613
+ const auto command = static_cast<UINT>(TrackPopupMenuEx(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, at.x, at.y, owner, nullptr));
614
+ isOpen = false;
615
+ DestroyMenu(menu);
616
+ std::optional<std::string> picked;
617
+ if (command >= FIRST_COMMAND && command - FIRST_COMMAND < popup.commands.size()) {
618
+ const Command &item = popup.commands[command - FIRST_COMMAND];
619
+ if (!item.role.empty()) {
620
+ runRole(item.role, owner, controller);
621
+ } else if (!item.id.empty()) {
622
+ picked = item.id;
623
+ }
624
+ }
625
+ done(std::nullopt, picked);
626
+ });
627
+ }
628
+
629
+ void setDockMenu(std::vector<MenuItem>, MenuClicked, std::function<void(Failure)>) {
630
+ throw std::runtime_error("crossbind: a macOS app alone has a Dock menu");
631
+ }
632
+
633
+ void setTray(TrayOptions options, MenuClicked clicked, std::function<void(Failure)> done) {
634
+ onMainThread([options = std::move(options), clicked = std::move(clicked), done = std::move(done)] {
635
+ if (isTrayMenuOpen) {
636
+ trayWaiting.push_back([options, clicked, done] { setTrayNow(options, clicked, done); });
637
+ return;
638
+ }
639
+ setTrayNow(options, clicked, done);
640
+ });
641
+ }
642
+
643
+ void removeTray(std::function<void(Failure)> done) {
644
+ onMainThread([done = std::move(done)] {
645
+ auto remove = [done] {
646
+ removeTrayNow();
647
+ done(std::nullopt);
648
+ };
649
+ if (isTrayMenuOpen) {
650
+ trayWaiting.push_back(remove);
651
+ } else {
652
+ remove();
653
+ }
654
+ });
655
+ }
656
+
657
+ // A balloon of the tray's icon, which Windows 10 and later show as a notification of theirs. Without the page's tray, an
658
+ // icon of the app's shows until the notification goes.
659
+ void showNotification(Notification notification, std::function<void(Failure)> done) {
660
+ onMainThread([notification = std::move(notification), done = std::move(done)] {
661
+ if (!makeTrayWindow()) {
662
+ done("crossbind: the tray's window cannot be made: error " + std::to_string(GetLastError()));
663
+ return;
664
+ }
665
+ if (!isIconShown && !showIcon()) {
666
+ done(noNotificationArea());
667
+ return;
668
+ }
669
+ static const HICON icon = iconAt(SM_CXICON);
670
+ NOTIFYICONDATAW data = iconData(NIF_INFO);
671
+ // A balloon without a text goes away.
672
+ const bool hasBody = !notification.body.empty();
673
+ copyText(data.szInfoTitle, hasBody ? wide(notification.title) : std::wstring());
674
+ copyText(data.szInfo, wide(hasBody ? notification.body : notification.title));
675
+ data.dwInfoFlags = NIIF_USER | NIIF_LARGE_ICON;
676
+ data.hBalloonIcon = icon;
677
+ if (!Shell_NotifyIconW(NIM_MODIFY, &data)) {
678
+ if (!isTraySet && balloons == 0) hideIcon();
679
+ done("crossbind: Windows did not show the notification");
680
+ return;
681
+ }
682
+ balloons += 1;
683
+ done(std::nullopt);
684
+ });
685
+ }
686
+
687
+ } // namespace crossbind::menu