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.
Files changed (59) hide show
  1. package/README.md +5 -3
  2. package/package.json +10 -3
  3. package/src/activate.js +12 -0
  4. package/src/anchor.js +6 -0
  5. package/src/appearance.js +351 -28
  6. package/src/appearancehooks.js +5 -2
  7. package/src/application.js +41 -0
  8. package/src/cocoa/app.js +367 -3
  9. package/src/cocoa/bezels.js +51 -1
  10. package/src/cocoa/dnd.js +358 -0
  11. package/src/cocoa/dock.js +39 -0
  12. package/src/cocoa/filepanels.js +155 -0
  13. package/src/cocoa/fonts.js +93 -2
  14. package/src/cocoa/globalmenu.js +41 -33
  15. package/src/cocoa/notifications.js +244 -0
  16. package/src/cocoa/permissions.js +74 -0
  17. package/src/cocoa/presenter.js +274 -33
  18. package/src/cocoa/promotion.js +708 -0
  19. package/src/cocoa/statusitem.js +112 -0
  20. package/src/cocoa/window.js +113 -4
  21. package/src/components/Button.js +20 -1
  22. package/src/components/Checkbox.js +17 -2
  23. package/src/components/Menu.js +108 -38
  24. package/src/components/Radio.js +17 -2
  25. package/src/components/Select.js +159 -27
  26. package/src/components/Switch.js +8 -1
  27. package/src/components/native.js +99 -0
  28. package/src/components/theme.js +37 -20
  29. package/src/desktopsettings.js +34 -2
  30. package/src/dnd.js +137 -11
  31. package/src/errors.js +6 -3
  32. package/src/filedialog.js +81 -16
  33. package/src/index.d.ts +29 -2
  34. package/src/index.js +17 -0
  35. package/src/launcher.js +170 -0
  36. package/src/launcherhooks.js +81 -0
  37. package/src/nodes.js +604 -37
  38. package/src/notificationhooks.js +56 -0
  39. package/src/notifications.js +558 -0
  40. package/src/palette.js +144 -8
  41. package/src/permissionhooks.js +89 -0
  42. package/src/permissions.js +196 -0
  43. package/src/style.d.ts +10 -4
  44. package/src/style.js +1 -0
  45. package/src/styles.js +161 -15
  46. package/src/textselection.js +1 -4
  47. package/src/trayhooks.js +90 -0
  48. package/src/types/appearance.d.ts +24 -0
  49. package/src/types/components.d.ts +10 -0
  50. package/src/types/elements.d.ts +14 -0
  51. package/src/types/events.d.ts +14 -0
  52. package/src/types/filedialog.d.ts +18 -7
  53. package/src/types/launcher.d.ts +43 -0
  54. package/src/types/notifications.d.ts +113 -0
  55. package/src/types/permissions.d.ts +100 -0
  56. package/src/types/style.d.ts +30 -2
  57. package/src/types/system.d.ts +5 -3
  58. package/src/types/tray.d.ts +54 -0
  59. package/src/windowid.js +23 -0
@@ -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 itemsOf = (ids) =>
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
- // the same route the close button takes: the app decides what
141
- // closing means, exactly as it would for the red light
142
- const wnd = this.target?.window ?? [...this.app._windows.values()][0];
143
- wnd?.emit('close', { preventDefault() {} });
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
+ }