react-x11 2.6.1 → 2.8.0
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/README.md +5 -3
- package/package.json +10 -3
- package/src/activate.js +12 -0
- package/src/anchor.js +6 -0
- package/src/appearance.js +351 -28
- package/src/appearancehooks.js +5 -2
- package/src/application.js +41 -0
- package/src/cocoa/app.js +367 -3
- package/src/cocoa/bezels.js +51 -1
- package/src/cocoa/dnd.js +358 -0
- package/src/cocoa/dock.js +39 -0
- package/src/cocoa/filepanels.js +155 -0
- package/src/cocoa/fonts.js +93 -2
- package/src/cocoa/globalmenu.js +41 -33
- package/src/cocoa/notifications.js +244 -0
- package/src/cocoa/permissions.js +74 -0
- package/src/cocoa/presenter.js +274 -33
- package/src/cocoa/promotion.js +708 -0
- package/src/cocoa/statusitem.js +112 -0
- package/src/cocoa/window.js +113 -4
- package/src/components/Button.js +20 -1
- package/src/components/Checkbox.js +17 -2
- package/src/components/Menu.js +108 -38
- package/src/components/Radio.js +17 -2
- package/src/components/Select.js +159 -27
- package/src/components/Switch.js +8 -1
- package/src/components/native.js +99 -0
- package/src/components/theme.js +37 -20
- package/src/desktopsettings.js +34 -2
- package/src/dnd.js +137 -11
- package/src/errors.js +6 -3
- package/src/filedialog.js +81 -16
- package/src/index.d.ts +29 -2
- package/src/index.js +17 -0
- package/src/launcher.js +170 -0
- package/src/launcherhooks.js +81 -0
- package/src/nodes.js +604 -37
- package/src/notificationhooks.js +56 -0
- package/src/notifications.js +558 -0
- package/src/palette.js +144 -8
- package/src/permissionhooks.js +89 -0
- package/src/permissions.js +196 -0
- package/src/style.d.ts +10 -4
- package/src/style.js +1 -0
- package/src/styles.js +161 -15
- package/src/textselection.js +1 -4
- package/src/trayhooks.js +90 -0
- package/src/types/appearance.d.ts +24 -0
- package/src/types/components.d.ts +10 -0
- package/src/types/elements.d.ts +14 -0
- package/src/types/events.d.ts +14 -0
- package/src/types/filedialog.d.ts +18 -7
- package/src/types/launcher.d.ts +43 -0
- package/src/types/notifications.d.ts +113 -0
- package/src/types/permissions.d.ts +100 -0
- package/src/types/style.d.ts +30 -2
- package/src/types/system.d.ts +5 -3
- package/src/types/tray.d.ts +54 -0
- package/src/windowid.js +23 -0
package/src/cocoa/globalmenu.js
CHANGED
|
@@ -51,6 +51,41 @@ function keyEquivalent(shortcut) {
|
|
|
51
51
|
return { key: key.toLowerCase(), modifiers: modifiers || FLAG_COMMAND };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A dbusmenu snapshot (`snapshot()`'s `Map<id, node>`) as the bridge's menu
|
|
56
|
+
* item spec — the vocabulary `setMainMenu`, `setDockMenu` and a status
|
|
57
|
+
* item's menu all take, so the menu bar, the Dock menu and the tray are one
|
|
58
|
+
* builder. `ids` defaults to the root's children.
|
|
59
|
+
*/
|
|
60
|
+
export function menuItemsSpec(nodes, ids = nodes.get(ROOT_ID).childIds) {
|
|
61
|
+
return ids.map((id) => {
|
|
62
|
+
const node = nodes.get(id);
|
|
63
|
+
const props = node.props;
|
|
64
|
+
const out = { id };
|
|
65
|
+
if (props.type === 'separator') {
|
|
66
|
+
out.separator = true;
|
|
67
|
+
if (props.visible === false) out.hidden = true;
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
out.title = String(props.label ?? '');
|
|
71
|
+
if (props.enabled === false) out.enabled = false;
|
|
72
|
+
if (props.visible === false) out.hidden = true;
|
|
73
|
+
if (props['toggle-state'] === 1) out.checked = true;
|
|
74
|
+
// the serialisable icon pair (menuitem.js): the name is read in the
|
|
75
|
+
// platform's icon theme — SF Symbols here, freedesktop on a Linux panel
|
|
76
|
+
// — and the bytes are the literal-pixel fallback
|
|
77
|
+
if (props['icon-name']) out.iconName = props['icon-name'];
|
|
78
|
+
if (props['icon-data']) out.iconData = props['icon-data'];
|
|
79
|
+
const key = keyEquivalent(props.shortcut);
|
|
80
|
+
if (key) {
|
|
81
|
+
out.key = key.key;
|
|
82
|
+
out.modifiers = key.modifiers;
|
|
83
|
+
}
|
|
84
|
+
if (node.childIds.length) out.items = menuItemsSpec(nodes, node.childIds);
|
|
85
|
+
return out;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
54
89
|
export class CocoaGlobalMenuExport {
|
|
55
90
|
constructor(app, { getMenus, onSelect, onAboutToShow, target, onChange }) {
|
|
56
91
|
this.app = app;
|
|
@@ -93,35 +128,7 @@ export class CocoaGlobalMenuExport {
|
|
|
93
128
|
* `activate` like every real item.
|
|
94
129
|
*/
|
|
95
130
|
_spec() {
|
|
96
|
-
const
|
|
97
|
-
ids.map((id) => {
|
|
98
|
-
const node = this.nodes.get(id);
|
|
99
|
-
const props = node.props;
|
|
100
|
-
const out = { id };
|
|
101
|
-
if (props.type === 'separator') {
|
|
102
|
-
out.separator = true;
|
|
103
|
-
if (props.visible === false) out.hidden = true;
|
|
104
|
-
return out;
|
|
105
|
-
}
|
|
106
|
-
out.title = String(props.label ?? '');
|
|
107
|
-
if (props.enabled === false) out.enabled = false;
|
|
108
|
-
if (props.visible === false) out.hidden = true;
|
|
109
|
-
if (props['toggle-state'] === 1) out.checked = true;
|
|
110
|
-
// the serialisable icon pair (menuitem.js): the name is read in
|
|
111
|
-
// the platform's icon theme — SF Symbols here, freedesktop on a
|
|
112
|
-
// Linux panel — and the bytes are the literal-pixel fallback
|
|
113
|
-
if (props['icon-name']) out.iconName = props['icon-name'];
|
|
114
|
-
if (props['icon-data']) out.iconData = props['icon-data'];
|
|
115
|
-
const key = keyEquivalent(props.shortcut);
|
|
116
|
-
if (key) {
|
|
117
|
-
out.key = key.key;
|
|
118
|
-
out.modifiers = key.modifiers;
|
|
119
|
-
}
|
|
120
|
-
if (node.childIds.length) out.items = itemsOf(node.childIds);
|
|
121
|
-
return out;
|
|
122
|
-
});
|
|
123
|
-
const root = this.nodes.get(ROOT_ID);
|
|
124
|
-
const menus = itemsOf(root.childIds).map((menu) => ({
|
|
131
|
+
const menus = menuItemsSpec(this.nodes).map((menu) => ({
|
|
125
132
|
title: menu.title ?? '',
|
|
126
133
|
items: menu.items ?? [],
|
|
127
134
|
}));
|
|
@@ -137,10 +144,11 @@ export class CocoaGlobalMenuExport {
|
|
|
137
144
|
/** A backend menu-activate event landed on this export. */
|
|
138
145
|
activate(id) {
|
|
139
146
|
if (id === QUIT_ID) {
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
147
|
+
// ⌘Q is a quit request like the Dock's and a logout's: one route for
|
|
148
|
+
// all of them (`CocoaApp.requestQuit`), which is the primary window's
|
|
149
|
+
// close request — the app decides what closing means, exactly as it
|
|
150
|
+
// would for the red light
|
|
151
|
+
this.app.requestQuit();
|
|
144
152
|
return;
|
|
145
153
|
}
|
|
146
154
|
const item = this.nodes?.get(id)?.item;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Desktop notifications on the cocoa backend — `UNUserNotificationCenter`
|
|
2
|
+
// through @windowkit/appkit (>= 0.5), the top rung of src/notifications.js's
|
|
3
|
+
// ladder. `CocoaApp.notifications` is this object; its presence is the
|
|
4
|
+
// capability, and its `available()` is the second gate: the centre only
|
|
5
|
+
// delivers for a code-signed app bundle with a bundle id, and a bare `node`
|
|
6
|
+
// process reports itself unavailable so the ladder can move on to
|
|
7
|
+
// `osascript` rather than post into the void.
|
|
8
|
+
//
|
|
9
|
+
// Three things about the system shape the code:
|
|
10
|
+
//
|
|
11
|
+
// - **Authorization is the system's prompt, once per bundle.** The first
|
|
12
|
+
// post from an app the user has not answered for asks first, and a
|
|
13
|
+
// refusal is a refusal — reported, not fallen through.
|
|
14
|
+
// - **Actions are categories.** The centre knows an action set only as a
|
|
15
|
+
// registered category, and `setNotificationCategories` *replaces* the set,
|
|
16
|
+
// so every distinct action list this process has used stays registered
|
|
17
|
+
// under a name derived from its keys. A click on the banner itself is the
|
|
18
|
+
// action `default`, the freedesktop word the ladder speaks.
|
|
19
|
+
// - **The user's answer comes back as an event** on the app's callback,
|
|
20
|
+
// naming the notification by identifier — `notification-action` and
|
|
21
|
+
// `notification-dismissed`, which the app routes here.
|
|
22
|
+
|
|
23
|
+
function categoryId(actions) {
|
|
24
|
+
return `react-x11:${actions.map((a) => a.key).join(',')}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class CocoaNotifications {
|
|
28
|
+
constructor(app) {
|
|
29
|
+
this.app = app;
|
|
30
|
+
this._native = app._native;
|
|
31
|
+
this._handles = new Map(); // identifier -> handle
|
|
32
|
+
this._categories = new Map(); // id -> { id, actions }
|
|
33
|
+
this._settings = null;
|
|
34
|
+
this._authorized = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The bridge's settings, read once — `available` is the bundle question. */
|
|
38
|
+
settings() {
|
|
39
|
+
if (this._settings) return this._settings;
|
|
40
|
+
this._settings = new Promise((resolve) => {
|
|
41
|
+
try {
|
|
42
|
+
this._native.notificationSettings((s) =>
|
|
43
|
+
resolve(s ?? { available: false }),
|
|
44
|
+
);
|
|
45
|
+
} catch {
|
|
46
|
+
resolve({ available: false });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
return this._settings;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async available() {
|
|
53
|
+
const s = await this.settings();
|
|
54
|
+
return s?.available === true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ask once; the system remembers, and so does this.
|
|
59
|
+
*
|
|
60
|
+
* Three answers, not two. `granted` and `denied` are the obvious pair, and
|
|
61
|
+
* `unasked` is the one that matters: a falsy `granted` does **not** mean
|
|
62
|
+
* the user said no. A bundle Launch Services has never registered — an
|
|
63
|
+
* ad-hoc signature in a temp directory, say — gets no prompt at all, and
|
|
64
|
+
* the request comes back refused with the status still `notDetermined`.
|
|
65
|
+
* Calling that a refusal would tell the user they declined something they
|
|
66
|
+
* were never shown, and would stop a ladder that has every right to carry
|
|
67
|
+
* on: nobody has turned anything off. So the status is read again
|
|
68
|
+
* afterwards, and it is the system, not the boolean, that says which
|
|
69
|
+
* happened.
|
|
70
|
+
*
|
|
71
|
+
* @returns {Promise<'granted'|'denied'|'unasked'>}
|
|
72
|
+
*/
|
|
73
|
+
async _authorize() {
|
|
74
|
+
if (this._authorized != null) return this._authorized;
|
|
75
|
+
const s = await this.settings();
|
|
76
|
+
if (
|
|
77
|
+
s.authorizationStatus === 'authorized' ||
|
|
78
|
+
s.authorizationStatus === 'provisional'
|
|
79
|
+
) {
|
|
80
|
+
this._authorized = 'granted';
|
|
81
|
+
return this._authorized;
|
|
82
|
+
}
|
|
83
|
+
if (s.authorizationStatus === 'denied') {
|
|
84
|
+
this._authorized = 'denied';
|
|
85
|
+
return this._authorized;
|
|
86
|
+
}
|
|
87
|
+
const granted = await new Promise((resolve) => {
|
|
88
|
+
try {
|
|
89
|
+
this._native.requestNotificationAuthorization(
|
|
90
|
+
['alert', 'sound', 'badge'],
|
|
91
|
+
(ok) => resolve(Boolean(ok)),
|
|
92
|
+
);
|
|
93
|
+
} catch {
|
|
94
|
+
resolve(false);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
if (granted) {
|
|
98
|
+
this._authorized = 'granted';
|
|
99
|
+
return this._authorized;
|
|
100
|
+
}
|
|
101
|
+
// Refused — by the user, or by a system that never asked one. The prompt
|
|
102
|
+
// moves the status off `notDetermined`; nothing else does.
|
|
103
|
+
this._settings = null;
|
|
104
|
+
const after = await this.settings();
|
|
105
|
+
if (after.authorizationStatus === 'notDetermined') {
|
|
106
|
+
// Not cached: a later post, from a bundle the system has since
|
|
107
|
+
// registered, deserves the prompt this one never got.
|
|
108
|
+
return 'unasked';
|
|
109
|
+
}
|
|
110
|
+
this._authorized = 'denied';
|
|
111
|
+
return this._authorized;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_ensureCategory(actions) {
|
|
115
|
+
if (!actions?.length) return undefined;
|
|
116
|
+
const id = categoryId(actions);
|
|
117
|
+
if (!this._categories.has(id)) {
|
|
118
|
+
this._categories.set(id, {
|
|
119
|
+
id,
|
|
120
|
+
actions: actions.map((a) => ({
|
|
121
|
+
id: a.key,
|
|
122
|
+
title: a.label ?? a.key,
|
|
123
|
+
foreground: true,
|
|
124
|
+
})),
|
|
125
|
+
});
|
|
126
|
+
this._native.setNotificationCategories([...this._categories.values()]);
|
|
127
|
+
}
|
|
128
|
+
return id;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_props(options, identifier) {
|
|
132
|
+
const props = {
|
|
133
|
+
title: options.summary,
|
|
134
|
+
body: options.body ?? '',
|
|
135
|
+
sound: options.silent ? null : 'default',
|
|
136
|
+
};
|
|
137
|
+
if (identifier) props.identifier = identifier;
|
|
138
|
+
if (options.subtitle) props.subtitle = options.subtitle;
|
|
139
|
+
const categoryId = this._ensureCategory(options.actions);
|
|
140
|
+
if (categoryId) props.categoryId = categoryId;
|
|
141
|
+
if (options.userInfo) props.userInfo = options.userInfo;
|
|
142
|
+
return props;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Post, if the user has allowed it.
|
|
147
|
+
*
|
|
148
|
+
* `null` rather than a throw when the centre could not ask (see
|
|
149
|
+
* `_authorize`): the ladder reads that as "this rung cannot serve" and
|
|
150
|
+
* moves on. A real refusal still throws, because it is an answer.
|
|
151
|
+
*/
|
|
152
|
+
async post(options) {
|
|
153
|
+
const auth = await this._authorize();
|
|
154
|
+
if (auth === 'denied') {
|
|
155
|
+
const err = new Error(
|
|
156
|
+
'react-x11: notifications are not allowed for this app — the user ' +
|
|
157
|
+
'declined them in System Settings › Notifications.',
|
|
158
|
+
);
|
|
159
|
+
err.name = 'NotificationsDeniedError';
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
if (auth !== 'granted') return null;
|
|
163
|
+
const handle = new CocoaHandle(this, options);
|
|
164
|
+
await handle._post({});
|
|
165
|
+
return handle;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `notification-action` / `notification-dismissed` from the app. */
|
|
169
|
+
route(ev) {
|
|
170
|
+
const handle = this._handles.get(ev.identifier);
|
|
171
|
+
if (!handle) return;
|
|
172
|
+
if (ev.type === 'notification-action') {
|
|
173
|
+
handle._action(
|
|
174
|
+
ev.actionId === 'default' ? 'default' : String(ev.actionId),
|
|
175
|
+
);
|
|
176
|
+
} else if (ev.type === 'notification-dismissed') {
|
|
177
|
+
this._handles.delete(ev.identifier);
|
|
178
|
+
handle._closed('dismissed');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
class CocoaHandle {
|
|
184
|
+
constructor(centre, options) {
|
|
185
|
+
this.backend = 'cocoa';
|
|
186
|
+
this.id = null;
|
|
187
|
+
this._centre = centre;
|
|
188
|
+
this._options = options;
|
|
189
|
+
this._done = false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_post(patch) {
|
|
193
|
+
const centre = this._centre;
|
|
194
|
+
const native = centre._native;
|
|
195
|
+
this._options = { ...this._options, ...patch };
|
|
196
|
+
const props = centre._props(this._options, this.id);
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
const done = (error) => (error ? reject(error) : resolve(this));
|
|
199
|
+
try {
|
|
200
|
+
if (this.id) {
|
|
201
|
+
native.updateNotification(this.id, props, done);
|
|
202
|
+
} else {
|
|
203
|
+
this.id = native.postNotification(props, done);
|
|
204
|
+
centre._handles.set(this.id, this);
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
reject(err);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
update(patch = {}) {
|
|
213
|
+
if (this._done) return Promise.resolve(this);
|
|
214
|
+
return this._post(patch);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async close() {
|
|
218
|
+
if (this._done || !this.id) return;
|
|
219
|
+
this._centre._handles.delete(this.id);
|
|
220
|
+
try {
|
|
221
|
+
this._centre._native.removeNotification(this.id);
|
|
222
|
+
} finally {
|
|
223
|
+
this._closed('closed');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
_action(key) {
|
|
228
|
+
try {
|
|
229
|
+
this._options.onAction?.(key);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.error('react-x11: a notification action handler threw', err);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
_closed(reason) {
|
|
236
|
+
if (this._done) return;
|
|
237
|
+
this._done = true;
|
|
238
|
+
try {
|
|
239
|
+
this._options.onClose?.(reason);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error('react-x11: a notification close handler threw', err);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// macOS privacy authorizations (TCC) on the cocoa backend, through
|
|
2
|
+
// @windowkit/appkit (>= 0.5): the rung that answers in src/permissions.js's
|
|
3
|
+
// ladder. `CocoaApp.permissions` is this object and its presence is the
|
|
4
|
+
// capability, the rule `filePanels` and `nativeBezels` follow.
|
|
5
|
+
//
|
|
6
|
+
// The bridge is mechanism — `authorizationStatus(kind, opts?)` never
|
|
7
|
+
// prompts, `requestAuthorization(kind, opts?, cb)` raises the system's
|
|
8
|
+
// prompt where a framework offers one and answers once, asynchronously,
|
|
9
|
+
// `openPrivacySettings(kind?)` deep-links — and its vocabulary is Apple's.
|
|
10
|
+
// What is decided here is the translation into the ladder's five words,
|
|
11
|
+
// and one thing worth knowing: a bare `node` process is attributed to its
|
|
12
|
+
// responsible process (the terminal, an IDE) or to `node` itself, and a
|
|
13
|
+
// bundled app must carry the usage-description keys
|
|
14
|
+
// (`NSCameraUsageDescription` and friends) or a request never prompts.
|
|
15
|
+
|
|
16
|
+
/** Apple's four words as the ladder's. */
|
|
17
|
+
const STATUS = Object.freeze({
|
|
18
|
+
authorized: 'granted',
|
|
19
|
+
denied: 'denied',
|
|
20
|
+
restricted: 'restricted',
|
|
21
|
+
notDetermined: 'prompt',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function statusFromBridge(status) {
|
|
25
|
+
return STATUS[status] ?? 'unknown';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The bridge's options for a kind: `automation` carries its target. */
|
|
29
|
+
function bridgeOptions(kind, options = {}) {
|
|
30
|
+
return kind === 'automation' && options.target != null
|
|
31
|
+
? { target: String(options.target) }
|
|
32
|
+
: undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class CocoaPermissions {
|
|
36
|
+
constructor(native) {
|
|
37
|
+
this._native = native;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
status(kind, options) {
|
|
41
|
+
const opts = bridgeOptions(kind, options);
|
|
42
|
+
return statusFromBridge(
|
|
43
|
+
opts
|
|
44
|
+
? this._native.authorizationStatus(kind, opts)
|
|
45
|
+
: this._native.authorizationStatus(kind),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
request(kind, options) {
|
|
50
|
+
const opts = bridgeOptions(kind, options);
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const done = (granted, status) =>
|
|
53
|
+
resolve(
|
|
54
|
+
status != null
|
|
55
|
+
? statusFromBridge(status)
|
|
56
|
+
: granted
|
|
57
|
+
? 'granted'
|
|
58
|
+
: 'denied',
|
|
59
|
+
);
|
|
60
|
+
try {
|
|
61
|
+
if (opts) this._native.requestAuthorization(kind, opts, done);
|
|
62
|
+
else this._native.requestAuthorization(kind, done);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
reject(err);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
openSettings(kind) {
|
|
70
|
+
if (kind == null) this._native.openPrivacySettings();
|
|
71
|
+
else this._native.openPrivacySettings(kind);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
}
|