@crossbind/extension-menu 0.0.0-bootstrap.0 → 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.
- package/LICENSE +21 -0
- package/README.md +25 -3
- package/crossbind.extension.js +18 -0
- package/native/android.cpp +30 -0
- package/native/darwin.mm +454 -0
- package/native/ios.cpp +26 -0
- package/native/linux-notifications.cpp +130 -0
- package/native/linux-tray.cpp +470 -0
- package/native/linux.cpp +320 -0
- package/native/menu.cpp +196 -0
- package/native/menu.h +99 -0
- package/native/windows.cpp +687 -0
- package/package.json +40 -6
- package/page.d.ts +76 -0
- package/page.js +148 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Buğra Sarı and crossbind contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
# @crossbind/extension-menu
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
package that exists, so it reserves the name for the crossbind release workflow.
|
|
3
|
+
The app's menus and context menus, the tray icon and system notifications.
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
An app has it by having `@crossbind/core-app-host`, which lists it as a dependency: there is nothing to install or list.
|
|
6
|
+
|
|
7
|
+
## Use
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { events, menu, notification, tray } from 'crossbind/app';
|
|
11
|
+
|
|
12
|
+
events.on('menu', ({ id }) => run(id));
|
|
13
|
+
await menu.set([{ label: 'File', items: [{ label: 'Export', id: 'export', accelerator: 'CmdOrCtrl+E' }] }]);
|
|
14
|
+
await tray.set({ tooltip: 'My app', menu: [{ label: 'Show', id: 'show' }] });
|
|
15
|
+
await notification.show({ title: 'Export done', body: 'report.csv is saved.' });
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
A click of an item with an id is the event `menu`; `menu.popup` opens a context menu at the page's point.
|
|
19
|
+
|
|
20
|
+
## Platforms
|
|
21
|
+
|
|
22
|
+
macOS, Linux and Windows; `menu.setDock` is macOS's alone. On a phone each call rejects. A Linux tray and its notifications go over D-Bus, and a Windows notification shows as a balloon of the tray's icon.
|
|
23
|
+
|
|
24
|
+
## Replace or leave out
|
|
25
|
+
|
|
26
|
+
It holds the roles `menu`, `notification` and `tray`. An extension that gives `replaces: 'menu'` takes that role alone, and this one goes on with the rest; `app: { without: ['menu'] }` in `crossbind.config.js` leaves it out. [The guide](https://crossbind.dev/guide/extensions/#replace) shows how, and [extensions.md](https://github.com/crossbind/crossbind/blob/main/docs/api/extensions.md#the-hosts-own-extensions) what a replacement answers.
|
|
27
|
+
|
|
28
|
+
[app.md](https://github.com/crossbind/crossbind/blob/main/docs/api/app.md#menus) has the menus' items, and [Tray and notifications](https://github.com/crossbind/crossbind/blob/main/docs/api/app.md#tray-and-notifications) the rest.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export default function menu() {
|
|
2
|
+
return {
|
|
3
|
+
name: 'menu',
|
|
4
|
+
contract: 1,
|
|
5
|
+
root: import.meta.dirname,
|
|
6
|
+
holds: ['menu', 'notification', 'tray'],
|
|
7
|
+
// A Linux tray names itself by the app's id on the session bus.
|
|
8
|
+
fromApp: (api) => (api.platform === 'linux' ? { id: api.app.id } : {}),
|
|
9
|
+
app: {
|
|
10
|
+
sources: ['native/menu.cpp'],
|
|
11
|
+
macos: { sources: ['native/darwin.mm'], frameworks: ['UserNotifications'] },
|
|
12
|
+
ios: { sources: ['native/ios.cpp'] },
|
|
13
|
+
linux: { sources: ['native/linux.cpp', 'native/linux-tray.cpp', 'native/linux-notifications.cpp'] },
|
|
14
|
+
windows: { sources: ['native/windows.cpp'], libraries: ['comctl32', 'shell32'] },
|
|
15
|
+
android: { sources: ['native/android.cpp'] },
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// An Android app has no menu bar and no tray icon, opens no menu of the page's, and shows no notifications yet.
|
|
2
|
+
#include "menu.h"
|
|
3
|
+
|
|
4
|
+
#include <stdexcept>
|
|
5
|
+
|
|
6
|
+
namespace crossbind::menu {
|
|
7
|
+
|
|
8
|
+
void setMenu(uint64_t, std::vector<MenuItem>, MenuClicked, std::function<void(Failure)>) {
|
|
9
|
+
throw std::runtime_error("crossbind: an Android app has no menu bar");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
void popupMenu(uint64_t, std::vector<MenuItem>, double, double, std::function<void(Failure, std::optional<std::string>)>) {
|
|
13
|
+
throw std::runtime_error("crossbind: an Android app opens no menu of the page's");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
void dropMenus(uint64_t) {}
|
|
17
|
+
|
|
18
|
+
void setDockMenu(std::vector<MenuItem>, MenuClicked, std::function<void(Failure)>) {
|
|
19
|
+
throw std::runtime_error("crossbind: a macOS app alone has a Dock menu");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
void setTray(TrayOptions, MenuClicked, std::function<void(Failure)>) { throw std::runtime_error("crossbind: an Android app has no tray icon"); }
|
|
23
|
+
|
|
24
|
+
void removeTray(std::function<void(Failure)>) { throw std::runtime_error("crossbind: an Android app has no tray icon"); }
|
|
25
|
+
|
|
26
|
+
void showNotification(Notification, std::function<void(Failure)>) {
|
|
27
|
+
throw std::runtime_error("crossbind: an Android app shows no notifications yet");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
} // namespace crossbind::menu
|
package/native/darwin.mm
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// The page's menus, the tray's icon in the menu bar and the system's notifications on macOS. The page's menus follow the
|
|
2
|
+
// app menu the host starts the app with.
|
|
3
|
+
#import <Cocoa/Cocoa.h>
|
|
4
|
+
#import <UserNotifications/UserNotifications.h>
|
|
5
|
+
#import <WebKit/WebKit.h>
|
|
6
|
+
#import <objc/runtime.h>
|
|
7
|
+
|
|
8
|
+
#include "menu.h"
|
|
9
|
+
|
|
10
|
+
#include "crossbind/app.h"
|
|
11
|
+
|
|
12
|
+
#include <cctype>
|
|
13
|
+
#include <cstdio>
|
|
14
|
+
#include <map>
|
|
15
|
+
#include <stdexcept>
|
|
16
|
+
|
|
17
|
+
// What a click of one of the page's items does: it tells the page, after toggling the item when it has checked. A click
|
|
18
|
+
// of the tray's icon without a menu tells the page as well.
|
|
19
|
+
@interface CrossbindMenuTarget : NSObject
|
|
20
|
+
- (void)clicked:(NSMenuItem *)item;
|
|
21
|
+
- (void)trayClicked:(id)sender;
|
|
22
|
+
@end
|
|
23
|
+
|
|
24
|
+
// What a click of one of a popup's items does: the popup answers with its id. AppKit sends the action before the popup
|
|
25
|
+
// returns.
|
|
26
|
+
@interface CrossbindPopupTarget : NSObject {
|
|
27
|
+
@public
|
|
28
|
+
NSString *picked;
|
|
29
|
+
}
|
|
30
|
+
- (void)picked:(NSMenuItem *)item;
|
|
31
|
+
@end
|
|
32
|
+
|
|
33
|
+
namespace crossbind::menu {
|
|
34
|
+
namespace {
|
|
35
|
+
|
|
36
|
+
// The tags of the page's items.
|
|
37
|
+
constexpr NSInteger CHECKABLE = 1;
|
|
38
|
+
constexpr NSInteger DISABLED = 2;
|
|
39
|
+
// The size of the icons in macOS's menu bar.
|
|
40
|
+
constexpr CGFloat TRAY_ICON_SIZE = 18;
|
|
41
|
+
|
|
42
|
+
MenuClicked onClick;
|
|
43
|
+
// A menu item holds its target weakly.
|
|
44
|
+
CrossbindMenuTarget *target;
|
|
45
|
+
NSStatusItem *tray;
|
|
46
|
+
|
|
47
|
+
// A menu bar and its Window menu, which AppKit lists the app's windows in.
|
|
48
|
+
struct Bar {
|
|
49
|
+
NSMenu *menu = nil;
|
|
50
|
+
NSMenu *windows = nil;
|
|
51
|
+
};
|
|
52
|
+
// The menus each window's page set, by the window's id, which the menu bar shows while that window is key, and the bar
|
|
53
|
+
// the host started the app with, for a window that set none.
|
|
54
|
+
std::map<uint64_t, Bar> bars;
|
|
55
|
+
Bar startBar;
|
|
56
|
+
// The Window menu the last bar made has, as windowMenu makes it.
|
|
57
|
+
NSMenu *madeWindowsMenu;
|
|
58
|
+
|
|
59
|
+
NSString *ns(const std::string &text) { return [NSString stringWithUTF8String:text.c_str()] ?: @""; }
|
|
60
|
+
|
|
61
|
+
void onMain(void (^block)(void)) { dispatch_async(dispatch_get_main_queue(), block); }
|
|
62
|
+
|
|
63
|
+
NSMenuItem *item(NSString *title, SEL action, NSString *key, NSEventModifierFlags modifiers = NSEventModifierFlagCommand) {
|
|
64
|
+
NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:action keyEquivalent:key];
|
|
65
|
+
item.keyEquivalentModifierMask = modifiers;
|
|
66
|
+
return item;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A standard item, which its role names and AppKit's responder chain does.
|
|
70
|
+
NSMenuItem *roleItem(const std::string &role) {
|
|
71
|
+
if (role == "undo") return item(@"Undo", @selector(undo:), @"z");
|
|
72
|
+
if (role == "redo") return item(@"Redo", @selector(redo:), @"z", NSEventModifierFlagCommand | NSEventModifierFlagShift);
|
|
73
|
+
if (role == "cut") return item(@"Cut", @selector(cut:), @"x");
|
|
74
|
+
if (role == "copy") return item(@"Copy", @selector(copy:), @"c");
|
|
75
|
+
if (role == "paste") return item(@"Paste", @selector(paste:), @"v");
|
|
76
|
+
if (role == "selectAll") return item(@"Select All", @selector(selectAll:), @"a");
|
|
77
|
+
if (role == "minimize") return item(@"Minimize", @selector(performMiniaturize:), @"m");
|
|
78
|
+
if (role == "close") return item(@"Close", @selector(performClose:), @"w");
|
|
79
|
+
if (role == "quit") return item([@"Quit " stringByAppendingString:ns(appName())], @selector(terminate:), @"q");
|
|
80
|
+
throw std::invalid_argument("crossbind: no menu item has the role " + role);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
void addEditItems(NSMenu *edit) {
|
|
84
|
+
[edit addItem:roleItem("undo")];
|
|
85
|
+
[edit addItem:roleItem("redo")];
|
|
86
|
+
[edit addItem:NSMenuItem.separatorItem];
|
|
87
|
+
[edit addItem:roleItem("cut")];
|
|
88
|
+
[edit addItem:roleItem("copy")];
|
|
89
|
+
[edit addItem:roleItem("paste")];
|
|
90
|
+
[edit addItem:item(@"Paste and Match Style", @selector(pasteAsPlainText:), @"v",
|
|
91
|
+
NSEventModifierFlagCommand | NSEventModifierFlagOption | NSEventModifierFlagShift)];
|
|
92
|
+
[edit addItem:item(@"Delete", @selector(delete:), @"")];
|
|
93
|
+
[edit addItem:roleItem("selectAll")];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// AppKit lists the app's windows below, once its bar shows.
|
|
97
|
+
void addWindowItems(NSMenu *window) {
|
|
98
|
+
[window addItem:roleItem("minimize")];
|
|
99
|
+
[window addItem:item(@"Zoom", @selector(performZoom:), @"")];
|
|
100
|
+
[window addItem:NSMenuItem.separatorItem];
|
|
101
|
+
[window addItem:roleItem("close")];
|
|
102
|
+
madeWindowsMenu = window;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const std::map<std::string, unichar> NAMED_KEYS{
|
|
106
|
+
{"Enter", '\r'}, {"Escape", 0x1b}, {"Tab", '\t'}, {"Space", ' '}, {"Backspace", NSBackspaceCharacter},
|
|
107
|
+
{"Delete", NSDeleteFunctionKey}, {"Up", NSUpArrowFunctionKey}, {"Down", NSDownArrowFunctionKey},
|
|
108
|
+
{"Left", NSLeftArrowFunctionKey}, {"Right", NSRightArrowFunctionKey}, {"Home", NSHomeFunctionKey},
|
|
109
|
+
{"End", NSEndFunctionKey}, {"PageUp", NSPageUpFunctionKey}, {"PageDown", NSPageDownFunctionKey}, {"Plus", '+'},
|
|
110
|
+
{"Minus", '-'}, {"Comma", ','}, {"Period", '.'},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
void setAccelerator(NSMenuItem *made, const Accelerator &accelerator) {
|
|
114
|
+
const std::string &key = accelerator.key;
|
|
115
|
+
unichar character = 0;
|
|
116
|
+
if (accelerator.function) {
|
|
117
|
+
character = static_cast<unichar>(NSF1FunctionKey + accelerator.function - 1);
|
|
118
|
+
} else if (key.size() == 1) {
|
|
119
|
+
character = static_cast<unichar>(std::tolower(static_cast<unsigned char>(key[0])));
|
|
120
|
+
} else if (const auto named = NAMED_KEYS.find(key); named != NAMED_KEYS.end()) {
|
|
121
|
+
character = named->second;
|
|
122
|
+
} else {
|
|
123
|
+
throw std::invalid_argument("crossbind: no accelerator has the key " + key);
|
|
124
|
+
}
|
|
125
|
+
made.keyEquivalent = [NSString stringWithCharacters:&character length:1];
|
|
126
|
+
made.keyEquivalentModifierMask = (accelerator.command ? NSEventModifierFlagCommand : 0) |
|
|
127
|
+
(accelerator.control ? NSEventModifierFlagControl : 0) |
|
|
128
|
+
(accelerator.alt ? NSEventModifierFlagOption : 0) |
|
|
129
|
+
(accelerator.shift ? NSEventModifierFlagShift : 0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
NSMenuItem *itemOf(const MenuItem &page);
|
|
133
|
+
|
|
134
|
+
NSMenuItem *menuOf(const MenuItem &page) {
|
|
135
|
+
const bool isEdit = page.role == "editMenu";
|
|
136
|
+
const bool isWindow = page.role == "windowMenu";
|
|
137
|
+
NSString *title = !page.label.empty() ? ns(page.label) : isEdit ? @"Edit" : isWindow ? @"Window" : @"";
|
|
138
|
+
NSMenu *menu = [[NSMenu alloc] initWithTitle:title];
|
|
139
|
+
if (isEdit) {
|
|
140
|
+
addEditItems(menu);
|
|
141
|
+
} else if (isWindow) {
|
|
142
|
+
addWindowItems(menu);
|
|
143
|
+
} else {
|
|
144
|
+
for (const auto &child : page.items) [menu addItem:itemOf(child)];
|
|
145
|
+
}
|
|
146
|
+
NSMenuItem *holder = [[NSMenuItem alloc] initWithTitle:title action:nil keyEquivalent:@""];
|
|
147
|
+
holder.submenu = menu;
|
|
148
|
+
return holder;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
NSMenuItem *itemOf(const MenuItem &page) {
|
|
152
|
+
if (page.isSeparator) return NSMenuItem.separatorItem;
|
|
153
|
+
if (page.isSubmenu || page.role == "editMenu" || page.role == "windowMenu") return menuOf(page);
|
|
154
|
+
NSMenuItem *made = nil;
|
|
155
|
+
if (page.role.empty()) {
|
|
156
|
+
made = [[NSMenuItem alloc] initWithTitle:ns(page.label) action:@selector(clicked:) keyEquivalent:@""];
|
|
157
|
+
made.target = target;
|
|
158
|
+
made.representedObject = ns(page.id);
|
|
159
|
+
made.tag = (page.checked ? CHECKABLE : 0) | (page.isEnabled ? 0 : DISABLED);
|
|
160
|
+
if (page.checked) made.state = *page.checked ? NSControlStateValueOn : NSControlStateValueOff;
|
|
161
|
+
} else {
|
|
162
|
+
made = roleItem(page.role);
|
|
163
|
+
if (!page.label.empty()) made.title = ns(page.label);
|
|
164
|
+
}
|
|
165
|
+
if (page.accelerator) setAccelerator(made, *page.accelerator);
|
|
166
|
+
return made;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The window of the app's whose bar a menu is in, by the bar's top menu; 0 for the tray's and the Dock's.
|
|
170
|
+
uint64_t windowOfMenu(NSMenu *menu) {
|
|
171
|
+
while (menu.supermenu) menu = menu.supermenu;
|
|
172
|
+
for (const auto &[window, bar] : bars) {
|
|
173
|
+
if (bar.menu == menu) return window;
|
|
174
|
+
}
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The window of the app's of an NSWindow: a sheet's parent, whose bar shows while the sheet is key.
|
|
179
|
+
uint64_t idOf(NSWindow *window) {
|
|
180
|
+
if (window.sheetParent) window = window.sheetParent;
|
|
181
|
+
for (const Window &open : openWindows()) {
|
|
182
|
+
if ((__bridge NSWindow *)open.native() == window) return open.id();
|
|
183
|
+
}
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The menu bar shows the menus of this window's page, or those the app started with, and the app menu moves to them.
|
|
188
|
+
void showBarOf(uint64_t window) {
|
|
189
|
+
const auto found = bars.find(window);
|
|
190
|
+
const Bar shown = found == bars.end() ? startBar : found->second;
|
|
191
|
+
if (!shown.menu || shown.menu == NSApp.mainMenu) return;
|
|
192
|
+
if (NSMenuItem *app = NSApp.mainMenu.itemArray.firstObject) {
|
|
193
|
+
[NSApp.mainMenu removeItem:app];
|
|
194
|
+
[shown.menu insertItem:app atIndex:0];
|
|
195
|
+
}
|
|
196
|
+
NSApp.mainMenu = shown.menu;
|
|
197
|
+
NSApp.windowsMenu = shown.windows;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The key window's bar shows, from the first menus a page sets on.
|
|
201
|
+
void watchKeyWindows() {
|
|
202
|
+
static bool isWatching = false;
|
|
203
|
+
if (isWatching) return;
|
|
204
|
+
isWatching = true;
|
|
205
|
+
startBar = Bar{NSApp.mainMenu, NSApp.windowsMenu};
|
|
206
|
+
[NSNotificationCenter.defaultCenter addObserverForName:NSWindowDidBecomeKeyNotification
|
|
207
|
+
object:nil
|
|
208
|
+
queue:nil
|
|
209
|
+
usingBlock:^(NSNotification *note) {
|
|
210
|
+
showBarOf(idOf(note.object));
|
|
211
|
+
}];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
} // namespace
|
|
215
|
+
} // namespace crossbind::menu
|
|
216
|
+
|
|
217
|
+
@implementation CrossbindMenuTarget
|
|
218
|
+
|
|
219
|
+
- (void)clicked:(NSMenuItem *)item {
|
|
220
|
+
std::optional<bool> checked;
|
|
221
|
+
if (item.tag & crossbind::menu::CHECKABLE) {
|
|
222
|
+
item.state = item.state == NSControlStateValueOn ? NSControlStateValueOff : NSControlStateValueOn;
|
|
223
|
+
checked = item.state == NSControlStateValueOn;
|
|
224
|
+
}
|
|
225
|
+
NSString *itemId = item.representedObject;
|
|
226
|
+
if (crossbind::menu::onClick && itemId.length > 0) crossbind::menu::onClick(crossbind::menu::windowOfMenu(item.menu), itemId.UTF8String, checked);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
- (void)trayClicked:(id)sender {
|
|
230
|
+
crossbind::app::emitEventToFront("tray");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// AppKit enables an item whose target takes its action, unless the item says otherwise.
|
|
234
|
+
- (BOOL)validateMenuItem:(NSMenuItem *)item {
|
|
235
|
+
return !(item.tag & crossbind::menu::DISABLED);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
@end
|
|
239
|
+
|
|
240
|
+
@implementation CrossbindPopupTarget
|
|
241
|
+
|
|
242
|
+
- (void)picked:(NSMenuItem *)item {
|
|
243
|
+
picked = item.representedObject;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
- (BOOL)validateMenuItem:(NSMenuItem *)item {
|
|
247
|
+
return !(item.tag & crossbind::menu::DISABLED);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
@end
|
|
251
|
+
|
|
252
|
+
namespace crossbind::menu {
|
|
253
|
+
|
|
254
|
+
// A window's menus follow the app menu, which the app keeps from the menus the host starts it with, and show while it
|
|
255
|
+
// is the key window.
|
|
256
|
+
void setMenu(uint64_t window, std::vector<MenuItem> menus, MenuClicked clicked, std::function<void(Failure)> done) {
|
|
257
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
258
|
+
if (!windowOf(window)) {
|
|
259
|
+
done(std::string("crossbind: the page's window has closed"));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
if (!target) target = [CrossbindMenuTarget new];
|
|
264
|
+
watchKeyWindows();
|
|
265
|
+
madeWindowsMenu = nil;
|
|
266
|
+
NSMenu *bar = [NSMenu new];
|
|
267
|
+
for (const auto &menu : menus) [bar addItem:itemOf(menu)];
|
|
268
|
+
// The bar it replaces lets go of the app menu first.
|
|
269
|
+
const bool isShown = bars.count(window) && bars[window].menu == NSApp.mainMenu;
|
|
270
|
+
if (isShown) showBarOf(0);
|
|
271
|
+
bars[window] = Bar{bar, madeWindowsMenu};
|
|
272
|
+
onClick = clicked;
|
|
273
|
+
if (isShown || idOf(NSApp.keyWindow) == window) showBarOf(window);
|
|
274
|
+
done(std::nullopt);
|
|
275
|
+
} catch (const std::exception &error) {
|
|
276
|
+
done(std::string(error.what()));
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
void dropMenus(uint64_t window) {
|
|
282
|
+
const auto found = bars.find(window);
|
|
283
|
+
if (found == bars.end()) return;
|
|
284
|
+
if (found->second.menu == NSApp.mainMenu) showBarOf(0);
|
|
285
|
+
bars.erase(found);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
namespace {
|
|
289
|
+
|
|
290
|
+
// A popup's items of the page's answer the popup, not the page's menus.
|
|
291
|
+
void answerPopup(NSMenu *menu, CrossbindPopupTarget *popup) {
|
|
292
|
+
for (NSMenuItem *entry in menu.itemArray) {
|
|
293
|
+
if (entry.target == target) {
|
|
294
|
+
entry.target = popup;
|
|
295
|
+
entry.action = @selector(picked:);
|
|
296
|
+
}
|
|
297
|
+
if (entry.submenu) answerPopup(entry.submenu, popup);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
} // namespace
|
|
302
|
+
|
|
303
|
+
// WKWebView's points are the page's CSS pixels at its zoom, from its top left. The menu's loop runs the main queue's
|
|
304
|
+
// blocks, and so the page's next popup, which waits for none.
|
|
305
|
+
void popupMenu(uint64_t window, std::vector<MenuItem> items, double x, double y, std::function<void(Failure, std::optional<std::string>)> done) {
|
|
306
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
307
|
+
static bool isOpen = false;
|
|
308
|
+
const std::optional<Window> open = windowOf(window);
|
|
309
|
+
WKWebView *view = open ? (__bridge WKWebView *)open->webView() : nil;
|
|
310
|
+
if (!view.window) {
|
|
311
|
+
done(std::string("crossbind: the app has no window to show a menu in"), std::nullopt);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (isOpen) {
|
|
315
|
+
done(std::string("crossbind: a menu of the page's is open already"), std::nullopt);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
CrossbindPopupTarget *popup = [CrossbindPopupTarget new];
|
|
319
|
+
NSMenu *menu = [NSMenu new];
|
|
320
|
+
try {
|
|
321
|
+
if (!target) target = [CrossbindMenuTarget new];
|
|
322
|
+
for (const auto &item : items) [menu addItem:itemOf(item)];
|
|
323
|
+
} catch (const std::exception &error) {
|
|
324
|
+
done(std::string(error.what()), std::nullopt);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
answerPopup(menu, popup);
|
|
328
|
+
NSPoint at = NSMakePoint(x * view.pageZoom, y * view.pageZoom);
|
|
329
|
+
if (!view.isFlipped) at.y = NSHeight(view.bounds) - at.y;
|
|
330
|
+
isOpen = true;
|
|
331
|
+
[menu popUpMenuPositioningItem:nil atLocation:at inView:view];
|
|
332
|
+
isOpen = false;
|
|
333
|
+
done(std::nullopt, popup->picked.length > 0 ? std::optional<std::string>(popup->picked.UTF8String) : std::nullopt);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
namespace {
|
|
338
|
+
|
|
339
|
+
// The Dock's menu of the page's, which the app's delegate gives the Dock, none before the page sets one.
|
|
340
|
+
NSMenu *dockMenu;
|
|
341
|
+
// Whether the app's delegate gives the Dock dockMenu, as answerDockMenu() made it.
|
|
342
|
+
bool isAnsweringDock = false;
|
|
343
|
+
|
|
344
|
+
} // namespace
|
|
345
|
+
|
|
346
|
+
void answerDockMenu() {
|
|
347
|
+
id delegate = NSApp.delegate;
|
|
348
|
+
if (!delegate || isAnsweringDock) return;
|
|
349
|
+
if ([delegate respondsToSelector:@selector(applicationDockMenu:)]) {
|
|
350
|
+
std::fprintf(stderr, "crossbind: the app's delegate gives the Dock a menu of its own, which menu.setDock leaves as it is\n");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const char *types = protocol_getMethodDescription(@protocol(NSApplicationDelegate), @selector(applicationDockMenu:), NO, YES).types;
|
|
354
|
+
class_addMethod([delegate class], @selector(applicationDockMenu:), imp_implementationWithBlock(^NSMenu *(id, NSApplication *) {
|
|
355
|
+
return dockMenu;
|
|
356
|
+
}),
|
|
357
|
+
types);
|
|
358
|
+
isAnsweringDock = true;
|
|
359
|
+
// AppKit learns what a delegate answers as it is set.
|
|
360
|
+
NSApp.delegate = nil;
|
|
361
|
+
NSApp.delegate = delegate;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
void setDockMenu(std::vector<MenuItem> items, MenuClicked clicked, std::function<void(Failure)> done) {
|
|
365
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
366
|
+
try {
|
|
367
|
+
if (!isAnsweringDock) throw std::runtime_error("crossbind: the app's delegate gives the Dock no menu of the page's");
|
|
368
|
+
if (!target) target = [CrossbindMenuTarget new];
|
|
369
|
+
NSMenu *menu = items.empty() ? nil : [NSMenu new];
|
|
370
|
+
for (const auto &item : items) [menu addItem:itemOf(item)];
|
|
371
|
+
dockMenu = menu;
|
|
372
|
+
onClick = clicked;
|
|
373
|
+
done(std::nullopt);
|
|
374
|
+
} catch (const std::exception &error) {
|
|
375
|
+
done(std::string(error.what()));
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// The tray's icon is the app's own. A click opens its menu, or without one tells the page.
|
|
381
|
+
void setTray(TrayOptions options, MenuClicked clicked, std::function<void(Failure)> done) {
|
|
382
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
383
|
+
try {
|
|
384
|
+
if (!target) target = [CrossbindMenuTarget new];
|
|
385
|
+
NSMenu *menu = nil;
|
|
386
|
+
if (!options.menu.empty()) {
|
|
387
|
+
menu = [NSMenu new];
|
|
388
|
+
for (const auto &item : options.menu) [menu addItem:itemOf(item)];
|
|
389
|
+
}
|
|
390
|
+
if (!tray) {
|
|
391
|
+
tray = [NSStatusBar.systemStatusBar statusItemWithLength:NSSquareStatusItemLength];
|
|
392
|
+
NSImage *icon = [NSApp.applicationIconImage copy];
|
|
393
|
+
icon.size = NSMakeSize(TRAY_ICON_SIZE, TRAY_ICON_SIZE);
|
|
394
|
+
tray.button.image = icon;
|
|
395
|
+
// A right click tells the page as a click does elsewhere.
|
|
396
|
+
[tray.button sendActionOn:NSEventMaskLeftMouseUp | NSEventMaskRightMouseUp];
|
|
397
|
+
}
|
|
398
|
+
tray.button.toolTip = options.tooltip.empty() ? nil : ns(options.tooltip);
|
|
399
|
+
tray.button.target = target;
|
|
400
|
+
tray.button.action = menu ? nil : @selector(trayClicked:);
|
|
401
|
+
tray.menu = menu;
|
|
402
|
+
onClick = clicked;
|
|
403
|
+
done(std::nullopt);
|
|
404
|
+
} catch (const std::exception &error) {
|
|
405
|
+
done(std::string(error.what()));
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
void removeTray(std::function<void(Failure)> done) {
|
|
411
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
412
|
+
if (tray) [NSStatusBar.systemStatusBar removeStatusItem:tray];
|
|
413
|
+
tray = nil;
|
|
414
|
+
done(std::nullopt);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// macOS asks its user the first time, and has no notifications for an executable outside a bundle, as a development
|
|
419
|
+
// session may run. The host's notification center shows each while the app is in front, and hands menu.cpp its taps.
|
|
420
|
+
void showNotification(Notification notification, std::function<void(Failure)> done) {
|
|
421
|
+
onMain(^{
|
|
422
|
+
if (!NSBundle.mainBundle.bundleIdentifier) {
|
|
423
|
+
done("crossbind: a macOS app shows notifications from its bundle alone");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
UNUserNotificationCenter *center = UNUserNotificationCenter.currentNotificationCenter;
|
|
427
|
+
UNMutableNotificationContent *content = [UNMutableNotificationContent new];
|
|
428
|
+
content.title = ns(notification.title);
|
|
429
|
+
content.body = ns(notification.body);
|
|
430
|
+
content.sound = UNNotificationSound.defaultSound;
|
|
431
|
+
[center requestAuthorizationWithOptions:UNAuthorizationOptionAlert | UNAuthorizationOptionSound
|
|
432
|
+
completionHandler:^(BOOL isAllowed, NSError *refused) {
|
|
433
|
+
if (!isAllowed) {
|
|
434
|
+
const std::string why = refused ? refused.localizedDescription.UTF8String : "its user turned them off";
|
|
435
|
+
onMain(^{
|
|
436
|
+
done("crossbind: macOS does not let the app show notifications: " + why);
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
NSString *id = [@(NOTIFICATION_ID_PREFIX) stringByAppendingString:NSUUID.UUID.UUIDString];
|
|
441
|
+
UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:id content:content trigger:nil];
|
|
442
|
+
[center addNotificationRequest:request withCompletionHandler:^(NSError *failed) {
|
|
443
|
+
const Failure failure = failed ? Failure(std::string("crossbind: macOS did not show the notification: ") +
|
|
444
|
+
failed.localizedDescription.UTF8String)
|
|
445
|
+
: Failure();
|
|
446
|
+
onMain(^{
|
|
447
|
+
done(failure);
|
|
448
|
+
});
|
|
449
|
+
}];
|
|
450
|
+
}];
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
} // namespace crossbind::menu
|
package/native/ios.cpp
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// An iOS app has no menu bar and no tray icon, opens no menu of the page's, and shows no notifications yet.
|
|
2
|
+
#include "menu.h"
|
|
3
|
+
|
|
4
|
+
#include <stdexcept>
|
|
5
|
+
|
|
6
|
+
namespace crossbind::menu {
|
|
7
|
+
|
|
8
|
+
void setMenu(uint64_t, std::vector<MenuItem>, MenuClicked, std::function<void(Failure)>) { throw std::runtime_error("crossbind: an iOS app has no menu bar"); }
|
|
9
|
+
|
|
10
|
+
void popupMenu(uint64_t, std::vector<MenuItem>, double, double, std::function<void(Failure, std::optional<std::string>)>) {
|
|
11
|
+
throw std::runtime_error("crossbind: an iOS app opens no menu of the page's");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
void dropMenus(uint64_t) {}
|
|
15
|
+
|
|
16
|
+
void setDockMenu(std::vector<MenuItem>, MenuClicked, std::function<void(Failure)>) {
|
|
17
|
+
throw std::runtime_error("crossbind: a macOS app alone has a Dock menu");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
void setTray(TrayOptions, MenuClicked, std::function<void(Failure)>) { throw std::runtime_error("crossbind: an iOS app has no tray icon"); }
|
|
21
|
+
|
|
22
|
+
void removeTray(std::function<void(Failure)>) { throw std::runtime_error("crossbind: an iOS app has no tray icon"); }
|
|
23
|
+
|
|
24
|
+
void showNotification(Notification, std::function<void(Failure)>) { throw std::runtime_error("crossbind: an iOS app shows no notifications yet"); }
|
|
25
|
+
|
|
26
|
+
} // namespace crossbind::menu
|