react-x11 2.8.3 → 2.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.8.3",
3
+ "version": "2.9.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -32,6 +32,7 @@
32
32
  "examples:tooltips": "tsx examples/tooltips.jsx",
33
33
  "examples:chat": "tsx examples/chat.jsx",
34
34
  "examples:clipboard": "tsx examples/clipboard.jsx",
35
+ "examples:calendar": "tsx examples/calendar.jsx",
35
36
  "labs:urischeme": "tsx examples/labs/urischeme.jsx",
36
37
  "filedialog:probe": "node scripts/filedialog-probe.mjs",
37
38
  "a11y:probe": "node scripts/a11y-probe.mjs",
@@ -92,13 +93,14 @@
92
93
  "node": ">=20.19"
93
94
  },
94
95
  "dependencies": {
96
+ "ical.js": "^2.2.1",
95
97
  "linebreak": "^1.1.0",
96
98
  "ntk": "^8.7.0",
97
99
  "react-reconciler": "^0.33.0",
98
100
  "yoga-layout": "^3.2.1"
99
101
  },
100
102
  "optionalDependencies": {
101
- "@windowkit/appkit": "^0.6.0",
103
+ "@windowkit/appkit": "^0.8.0",
102
104
  "dbus-native": "^0.15.1",
103
105
  "x11-dri": "^0.7.0"
104
106
  },
package/src/Reconciler.js CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  windowAttributes,
34
34
  setTextStripBelow,
35
35
  } from './nodes.js';
36
+ import { setFrameRateDefault } from './pacing.js';
36
37
  import { hasDropProps } from './dnd.js';
37
38
  import { AppProvider } from './appcontext.js';
38
39
  import {
@@ -796,6 +797,12 @@ export async function createRoot(options = {}) {
796
797
  // default, 0 for glyphs at every size.
797
798
  setTextStripBelow(app, rest.textStripBelow);
798
799
 
800
+ // How this root's windows pace their frames when their content changes
801
+ // faster than the display refreshes (src/pacing.js): `'display'` — every
802
+ // frame the clock gives — unless the root says otherwise; a window's own
803
+ // `frameRate` prop wins over this, and REACT_X11_FRAME_RATE over both.
804
+ setFrameRateDefault(app, rest.frameRate);
805
+
799
806
  // Whether a subtree coming back out of hiding — a `<Suspense>` boundary
800
807
  // resolving, an `<Activity>` shown again — takes the keyboard back with it
801
808
  // (src/events.js, `subtreeRevealed`). On by default; `false` is the
package/src/cocoa/app.js CHANGED
@@ -25,6 +25,7 @@ import { CocoaGLArea, cocoaGLConfig, resolveCocoaGLRuntime } from './glarea.js';
25
25
  import { CocoaDockMenu } from './dock.js';
26
26
  import { CocoaGlobalMenuExport } from './globalmenu.js';
27
27
  import { CocoaStatusItem } from './statusitem.js';
28
+ import { CocoaCalendars } from './calendar.js';
28
29
  import { CocoaNotifications } from './notifications.js';
29
30
  import { CocoaPaneHost } from './panehost.js';
30
31
  import { CocoaPermissions } from './permissions.js';
@@ -177,6 +178,17 @@ export class CocoaApp {
177
178
  ? new CocoaNotifications(this)
178
179
  : null;
179
180
 
181
+ // EventKit (src/cocoa/calendar.js). Present exactly when the bridge has
182
+ // it (>= 0.8), and its presence is the top rung of
183
+ // src/desktopcalendar.js's ladder for this app — an older bridge leaves
184
+ // the ladder to find `osascript` instead, which answers the same
185
+ // questions out of the same framework.
186
+ this.calendars =
187
+ typeof native.calendars === 'function' &&
188
+ typeof native.eventsBetween === 'function'
189
+ ? new CocoaCalendars(this)
190
+ : null;
191
+
180
192
  // The GL policy, glbackend.js's shape. No GLX exists here, so the
181
193
  // default is 'auto' (the direct backend where the runtime loads);
182
194
  // useSupports('shaders') stays false until the first <glarea> resolves
@@ -537,8 +549,25 @@ export class CocoaApp {
537
549
  for (const wnd of this._windows.values()) wnd.liveResizing = false;
538
550
  }
539
551
 
552
+ /**
553
+ * `requestAnimationFrame`, for a window or for the app's own clock. A
554
+ * frame asked for between two pump ticks used to wait for the next tick
555
+ * to look at it — up to a pump interval on top of whatever the clock
556
+ * owed. Now, while the pump runs, the first request into an empty queue
557
+ * arms the same one-shot a tick arms for a frame it just missed
558
+ * (`_armFrameTimer`), at the moment the clock is due; a request for a
559
+ * clock due after the next tick leaves it to the tick, as before. What
560
+ * a frame is and how many there are does not change — the clock still
561
+ * gates every one — only when it is looked at, which is what makes a
562
+ * paced claim (src/pacing.js) land at its wait rather than at its wait
563
+ * rounded up to the pump.
564
+ */
540
565
  _requestFrame(cb, wnd = null) {
541
566
  this._rafQueue.push({ cb, wnd });
567
+ if (this._pump && this._rafQueue.length === 1) {
568
+ const now = performance.now();
569
+ this._armFrameTimer(Math.max(1, this._frameWait(wnd ?? this, now)), now);
570
+ }
542
571
  return this._rafQueue.length;
543
572
  }
544
573
 
@@ -750,6 +779,12 @@ export class CocoaApp {
750
779
  case 'notification-dismissed':
751
780
  this.notifications?.route(ev);
752
781
  return this._afterInput();
782
+ case 'calendar-store-changed':
783
+ // EventKit's own notification, which names nothing that changed. A
784
+ // watcher's answer is to query again, and that query's result is a
785
+ // render, so the frame goes out the way an input's does.
786
+ this.calendars?.route(ev);
787
+ return this._afterInput();
753
788
  case 'animation-end':
754
789
  // the presenter that added the animation registered for its id; an
755
790
  // id nobody knows is an animation already forgotten (cancelled, or
@@ -0,0 +1,186 @@
1
+ // The user's calendars on the cocoa backend — EventKit (`EKEventStore`)
2
+ // through @windowkit/appkit (>= 0.8), the top rung of
3
+ // src/desktopcalendar.js's ladder. `CocoaApp.calendars` is this object and
4
+ // its *presence* is the capability, the rule `filePanels`, `permissions` and
5
+ // `notifications` follow, so the ladder never names a backend.
6
+ //
7
+ // EventKit is the macOS counterpart of Evolution Data Server plus GNOME
8
+ // Online Accounts in one framework: every account the user added in System
9
+ // Settings > Internet Accounts — iCloud, Google, Exchange, CalDAV, a
10
+ // subscribed feed — is served by one store, the desktop did the OAuth, and
11
+ // the app never sees a credential.
12
+ //
13
+ // Three things about the framework shape the code here:
14
+ //
15
+ // - **It expands recurrences itself.** `predicateForEventsWithStartDate…`
16
+ // answers occurrences, so there is no `ical.js` on this rung and no
17
+ // recurrence arithmetic to get wrong. The EDS rung pays for that
18
+ // client-side; this one does not.
19
+ // - **The predicate spans at most four years** (the framework's own limit),
20
+ // so a longer range is asked for in chunks and the pieces are merged —
21
+ // `chunkSpan` in ../desktopcalendar.js, shared with the `osascript` rung
22
+ // because it is the same framework underneath.
23
+ // - **An all-day event ends at the last second of its last day.** Every
24
+ // other rung, and `byDay`, read `end` as exclusive, so it is normalised
25
+ // here — at the edge, once, rather than in every consumer.
26
+ //
27
+ // A change to anything in the store arrives as one `calendar-store-changed`
28
+ // backend event that names nothing, so `watch` reports `kind: 'changed'`
29
+ // with a null calendar and no count. Re-query; do not try to patch.
30
+
31
+ import {
32
+ calendarBackendName,
33
+ chunkSpan,
34
+ hexFromComponents,
35
+ sortEvents,
36
+ withExclusiveEnd,
37
+ } from '../desktopcalendar.js';
38
+
39
+ /** `EKCalendar` as the shape every rung answers with. */
40
+ export function calendarInfo(cal) {
41
+ return {
42
+ uid: String(cal.id),
43
+ name: cal.title ?? '',
44
+ // EventKit has no "unchecked in the sidebar" state to report — a
45
+ // calendar hidden in Calendar.app is still in the store — so every
46
+ // calendar here is enabled, which is what `enabled` means to a caller
47
+ // filtering the list.
48
+ enabled: true,
49
+ color: hexFromComponents(cal.color),
50
+ backend: calendarBackendName(cal.type),
51
+ readOnly: cal.allowsModifications === false || cal.immutable === true,
52
+ // The account the calendar came from, which is what the EDS rung's
53
+ // `account` is too: an `EKSource` is the Internet Accounts entry.
54
+ account: cal.source?.title ?? undefined,
55
+ };
56
+ }
57
+
58
+ /** One occurrence from the bridge as the shape every rung answers with. */
59
+ export function desktopEvent(ev, byId) {
60
+ const meta = byId.get(String(ev.calendar));
61
+ const start = new Date(ev.start);
62
+ return {
63
+ uid: String(ev.id ?? ev.itemId ?? `${ev.calendar}:${ev.start}`),
64
+ summary: ev.title ?? '',
65
+ location: ev.location ?? undefined,
66
+ description: ev.notes ?? undefined,
67
+ start,
68
+ end: withExclusiveEnd(new Date(ev.end), Boolean(ev.allDay)),
69
+ allDay: Boolean(ev.allDay),
70
+ recurring: Boolean(ev.recurring),
71
+ calendar: meta
72
+ ? { uid: meta.uid, name: meta.name, color: meta.color }
73
+ : { uid: String(ev.calendar), name: '' },
74
+ };
75
+ }
76
+
77
+ export class CocoaCalendars {
78
+ constructor(app) {
79
+ this.app = app;
80
+ this._native = app._native;
81
+ this.backend = 'cocoa';
82
+ this._watchers = new Set();
83
+ }
84
+
85
+ /**
86
+ * The TCC grant, read without prompting. `'write-only'` is macOS 14's
87
+ * partial grant, which is a refusal to a reader — but its own word, so a
88
+ * caller that only saves events can tell it from a denial.
89
+ */
90
+ async access() {
91
+ return this.app.permissions
92
+ ? this.app.permissions.status('calendars')
93
+ : 'unknown';
94
+ }
95
+
96
+ /** The system prompt, once per app; the status after the user answered. */
97
+ async requestAccess() {
98
+ return this.app.permissions
99
+ ? this.app.permissions.request('calendars')
100
+ : 'unknown';
101
+ }
102
+
103
+ listCalendars() {
104
+ return new Promise((resolve, reject) => {
105
+ this._native.calendars((err, list) =>
106
+ err ? reject(err) : resolve((list ?? []).map(calendarInfo)),
107
+ );
108
+ });
109
+ }
110
+
111
+ _events(range) {
112
+ return new Promise((resolve, reject) => {
113
+ this._native.eventsBetween(range, (err, events) =>
114
+ err ? reject(err) : resolve(events ?? []),
115
+ );
116
+ });
117
+ }
118
+
119
+ /**
120
+ * The occurrences in a range, already expanded by the framework.
121
+ *
122
+ * `errors` is always empty here: there is one store, and a failure in it
123
+ * is a failure of the whole read rather than of one account. The EDS rung
124
+ * is the one where a single unreachable CalDAV server must not blank the
125
+ * month.
126
+ */
127
+ async eventsBetween(from, to, options = {}) {
128
+ const metas = options.calendars ?? (await this.listCalendars());
129
+ // "These calendars, of which there are none" is not "every calendar",
130
+ // which is what an empty filter means to the predicate underneath. A
131
+ // caller whose filter matched nothing must get nothing.
132
+ if (options.calendars && metas.length === 0) {
133
+ return { events: [], errors: [] };
134
+ }
135
+ const byId = new Map(metas.map((meta) => [meta.uid, meta]));
136
+ const ids = options.calendars ? metas.map((meta) => meta.uid) : undefined;
137
+
138
+ const events = [];
139
+ const seen = new Set();
140
+ for (const [start, end] of chunkSpan(from, to)) {
141
+ const raw = await this._events({ start, end, calendars: ids });
142
+ for (const one of raw) {
143
+ // An occurrence that straddles a chunk boundary is reported by both
144
+ // predicates; the caller must not see it twice.
145
+ const key = `${one.id} ${one.start}`;
146
+ if (seen.has(key)) continue;
147
+ seen.add(key);
148
+ events.push(desktopEvent(one, byId));
149
+ }
150
+ }
151
+ return { events: sortEvents(events), errors: [] };
152
+ }
153
+
154
+ /**
155
+ * `EKEventStoreChangedNotification`, as the ladder's change signal.
156
+ *
157
+ * The notification names neither the calendar nor what happened — a
158
+ * detail EventKit does not have — so the change carries a null calendar
159
+ * and no count, and re-querying is the only correct answer. Every watcher
160
+ * hears every change, whatever range it asked for, because a change
161
+ * outside a range can still move what is inside it (a recurrence master
162
+ * edited months away).
163
+ */
164
+ async watch(from, to, onChange) {
165
+ const entry = { onChange };
166
+ this._watchers.add(entry);
167
+ return async () => {
168
+ this._watchers.delete(entry);
169
+ };
170
+ }
171
+
172
+ /** `calendar-store-changed`, routed from the app's backend callback. */
173
+ route() {
174
+ for (const entry of [...this._watchers]) {
175
+ try {
176
+ entry.onChange({ calendar: null, kind: 'changed', count: null });
177
+ } catch {
178
+ // a watcher that throws is not the other watchers' problem
179
+ }
180
+ }
181
+ }
182
+
183
+ /** Nothing to release: the store belongs to the process, and a handle's
184
+ * own watchers are dropped by the stop function `watch` returned. */
185
+ async close() {}
186
+ }
@@ -85,13 +85,23 @@ const clamp01 = (v) => Math.min(1, Math.max(0, Number(v) || 0));
85
85
  /**
86
86
  * The Render ops text draws with, numbered as XRender numbers them so a
87
87
  * caller's `ctx.Render?.PictOp?.Over ?? 3` reads the same on both
88
- * backends. Every op draws as Over here: the bridge composites glyph
89
- * coverage with the context's fill and offers no blend-mode switch, and
90
- * for the opaque inks text uses Src and Over agree.
88
+ * backends. The `op` a `drawGlyphs` call names is ignored here: the bridge
89
+ * composites glyph coverage with the context's fill through the context's
90
+ * own blend mode `globalCompositeOperation`, below and for the opaque
91
+ * inks text uses Src and Over agree.
91
92
  */
92
93
  const PICT_OP = Object.freeze({ Src: 1, Over: 3 });
93
94
  const RENDER = Object.freeze({ PictOp: PICT_OP });
94
95
 
96
+ /**
97
+ * `_state.clip` when the clip in force is not one this class can name — a
98
+ * rounded corner, a glyph, an arc, or a rect under a rotation. Null means
99
+ * nothing is clipped and a rect means that rect, in surface pixels; this
100
+ * means "there is one and I cannot tell you where", which is the answer
101
+ * that turns the memcpy blit off (see `_blit`).
102
+ */
103
+ const NON_RECT = Symbol('non-rectangular clip');
104
+
95
105
  /**
96
106
  * The path, recorded alongside the native one, so `stroke` can re-issue it
97
107
  * in pieces — `CGContextStrokePath` is QUADRATIC in the number of subpaths
@@ -189,7 +199,18 @@ export class CocoaContext2D {
189
199
  shadowOffsetY: 0,
190
200
  shadowColor: 'rgba(0,0,0,0)',
191
201
  ctm: [1, 0, 0, 1, 0, 0],
202
+ gco: 'source-over',
203
+ // null: nothing clipped. A rect (surface pixels): that rect. NON_RECT:
204
+ // a clip this class cannot name.
205
+ clip: null,
192
206
  };
207
+ // What this bridge can do, asked once. Both verbs arrived together in
208
+ // @windowkit/appkit 0.7.0, and anything older is a bridge that draws
209
+ // every composite as `source-over` through a CGImage — so the property
210
+ // refuses what it cannot honour rather than lying about it, and the
211
+ // blit path is simply never taken.
212
+ this._blendModes = typeof native.ctxSetBlendMode === 'function';
213
+ this._blits = typeof native.blitSurface === 'function';
193
214
  this._onDirty = null;
194
215
  // the recorded path, and whether the native one still matches it (a
195
216
  // chunked stroke leaves only its last chunk behind)
@@ -211,6 +232,13 @@ export class CocoaContext2D {
211
232
  n.ctxSetLineJoin(surface, st.lineJoin);
212
233
  n.ctxSetGlobalAlpha(surface, st.globalAlpha);
213
234
  n.ctxSetLineDash(surface, st.dash, st.dashOffset);
235
+ // a fresh surface is already at source-over, so only a context
236
+ // holding another op has anything to say
237
+ if (this._blendModes && st.gco !== 'source-over') {
238
+ n.ctxSetBlendMode(surface, st.gco);
239
+ }
240
+ // a fresh surface is unclipped, whatever the old one had in force
241
+ st.clip = null;
214
242
  this._stack.length = 0;
215
243
  // the path went with the surface it was built on; nothing may
216
244
  // replay it onto the new one
@@ -282,6 +310,47 @@ export class CocoaContext2D {
282
310
  }
283
311
  }
284
312
 
313
+ get globalCompositeOperation() {
314
+ return this._state.gco;
315
+ }
316
+
317
+ /**
318
+ * The vocabulary is the **bridge's**, not a table kept here: the names are
319
+ * canvas's own, `ctxSetBlendMode` answers false for one it does not have,
320
+ * and that answer is what decides whether the assignment sticks. So this
321
+ * class never goes stale against a bridge that grows an op, and never
322
+ * claims one it would not actually draw.
323
+ *
324
+ * Which makes the detection a caller writes canvas's own — an unknown
325
+ * value is *ignored* there too, leaving the op in force — so the way to
326
+ * ask is to assign and read back:
327
+ *
328
+ * ctx.globalCompositeOperation = 'copy';
329
+ * if (ctx.globalCompositeOperation === 'copy') { ... }
330
+ *
331
+ * A bridge with no `ctxSetBlendMode` at all — @windowkit/appkit before
332
+ * 0.7.0 — draws everything as source-over, so source-over is the one
333
+ * value that sticks on it. That is exactly true rather than a fallback.
334
+ *
335
+ * One divergence from a browser, shared with ntk and so the same on both
336
+ * backends: an op applies inside what the draw covers, not across the
337
+ * whole surface. A browser's `copy` clears everything the drawing missed;
338
+ * `kCGBlendModeCopy` and XRender's `Src` both leave it alone.
339
+ */
340
+ set globalCompositeOperation(value) {
341
+ if (typeof value !== 'string') return;
342
+ if (!this._blendModes) {
343
+ if (value === 'source-over') this._state.gco = value;
344
+ return;
345
+ }
346
+ // false is the bridge saying it left its own mode alone; anything else
347
+ // means the mode is now `value`, and the two must not drift — a JS state
348
+ // ahead of the native one would take the memcpy path in `_blit` for a
349
+ // composite the fallback draw would have blended.
350
+ if (this._native.ctxSetBlendMode(this._s(), value) === false) return;
351
+ this._state.gco = value;
352
+ }
353
+
285
354
  /**
286
355
  * Whether a stroke of a path with many subpaths may go out as several
287
356
  * `CGContextStrokePath` calls — on by default, because the alternative
@@ -776,9 +845,70 @@ export class CocoaContext2D {
776
845
  ) {
777
846
  return;
778
847
  }
848
+ this._state.clip = this._clipAfter();
779
849
  this._native.ctxClip(this._path());
780
850
  }
781
851
 
852
+ /**
853
+ * The current path as a whole-pixel rect in surface coordinates, or null
854
+ * for anything else. CoreGraphics owns the real clip and this is only a
855
+ * shadow of it, kept for the one caller that draws *around* the context —
856
+ * `_blit`, whose memcpy cannot see a CGContext's clip at all.
857
+ *
858
+ * So the answer has to be exact, never merely close: a rect that does not
859
+ * land on whole pixels is refused rather than rounded, because rounding
860
+ * out would copy pixels the clip excludes and rounding in would leave a
861
+ * seam of whatever the destination held. The clips a paint pass actually
862
+ * sets — the damage rect, a scroll viewport, a square-cornered `overflow`
863
+ * — are whole pixels under a translate, and those are the ones this
864
+ * recognises.
865
+ */
866
+ _pathRect() {
867
+ const cmds = this._cmds;
868
+ if (cmds.length !== 5 || cmds[0] !== P_RECT) return null;
869
+ const [, x, y, w, h] = cmds;
870
+ const [a, b, c, d, e, f] = this._state.ctm;
871
+ if (b !== 0 || c !== 0) return null; // rotated or skewed: not a rect here
872
+ const x0 = a * x + e;
873
+ const y0 = d * y + f;
874
+ const x1 = a * (x + w) + e;
875
+ const y1 = d * (y + h) + f;
876
+ const rect = {
877
+ x: Math.min(x0, x1),
878
+ y: Math.min(y0, y1),
879
+ width: Math.abs(x1 - x0),
880
+ height: Math.abs(y1 - y0),
881
+ };
882
+ for (const v of [rect.x, rect.y, rect.width, rect.height]) {
883
+ if (!Number.isInteger(v)) return null;
884
+ }
885
+ return rect;
886
+ }
887
+
888
+ /** The clip `clip()` is about to leave in force: the current one
889
+ * intersected with the path, or NON_RECT as soon as either is one. */
890
+ _clipAfter() {
891
+ const current = this._state.clip;
892
+ if (current === NON_RECT) return NON_RECT;
893
+ const rect = this._pathRect();
894
+ if (!rect) return NON_RECT;
895
+ if (!current) return rect;
896
+ const x = Math.max(current.x, rect.x);
897
+ const y = Math.max(current.y, rect.y);
898
+ return {
899
+ x,
900
+ y,
901
+ width: Math.max(
902
+ 0,
903
+ Math.min(current.x + current.width, rect.x + rect.width) - x,
904
+ ),
905
+ height: Math.max(
906
+ 0,
907
+ Math.min(current.y + current.height, rect.y + rect.height) - y,
908
+ ),
909
+ };
910
+ }
911
+
782
912
  fillRect(x, y, w, h) {
783
913
  if (!(w > 0) || !(h > 0)) return;
784
914
  const style = this._state.fillStyle;
@@ -843,10 +973,96 @@ export class CocoaContext2D {
843
973
  dw = sw;
844
974
  dh = sh;
845
975
  }
846
- this._native.ctxDrawSurface(this._s(), src, sx, sy, sw, sh, dx, dy, dw, dh);
976
+ if (!this._blit(src, sx, sy, sw, sh, dx, dy, dw, dh)) {
977
+ this._native.ctxDrawSurface(
978
+ this._s(),
979
+ src,
980
+ sx,
981
+ sy,
982
+ sw,
983
+ sh,
984
+ dx,
985
+ dy,
986
+ dw,
987
+ dh,
988
+ );
989
+ }
847
990
  this._dirty();
848
991
  }
849
992
 
993
+ /**
994
+ * `drawImage` as a row memcpy, for the one shape where a copy is all it
995
+ * ever was: a surface composited into another at a translate, whole
996
+ * pixels, same size in as out, under `globalCompositeOperation = 'copy'`.
997
+ * Answers false for everything else, and the caller draws.
998
+ *
999
+ * That shape is what an element with a surface of its own presents every
1000
+ * frame — a terminal's grid, a retained scene — and the CoreGraphics
1001
+ * route to it is `CGBitmapContextCreateImage` of the whole source plus
1002
+ * `CGContextDrawImage`: 1.7ms of a 6ms frame for a 125x45 terminal, where
1003
+ * the memcpy is 1.0 (sidorares/react-x11-components#69 §6). The saving is
1004
+ * per frame rather than per flood, which is why the frame pacer (#497)
1005
+ * had to land first for it to be worth anything.
1006
+ *
1007
+ * Every condition below is a way the memcpy would differ from the draw,
1008
+ * and a difference is a bug rather than a slower frame — so each is a
1009
+ * refusal, never a fixup:
1010
+ *
1011
+ * - **the op.** Only `copy` writes the source over the destination
1012
+ * without reading it. `source-over` is a blend, and blending is what
1013
+ * CoreGraphics is for.
1014
+ * - **the transform.** A pure translate at whole pixels. A scale or a
1015
+ * rotation resamples; a fractional offset resamples too (surfaces are
1016
+ * created with `kCGInterpolationMedium`).
1017
+ * - **`globalAlpha`, and a shadow.** Both are things `CGContextDrawImage`
1018
+ * does to the source on its way down that a memcpy does not do at all.
1019
+ * - **the clip.** A memcpy cannot see a CGContext's clip, so the rect it
1020
+ * copies is intersected with the one this class tracked — and a clip it
1021
+ * could not track (NON_RECT) means it does not know, so it draws.
1022
+ * - **the same surface twice.** Overlapping memcpy rows have no defined
1023
+ * result. The check here is on the handle; the bridge's is on the
1024
+ * backing store, which also catches two handles onto one bitmap — the
1025
+ * two ends of a shared IOSurface — and throws rather than corrupting it.
1026
+ */
1027
+ _blit(src, sx, sy, sw, sh, dx, dy, dw, dh) {
1028
+ if (!this._blits) return false;
1029
+ const st = this._state;
1030
+ if (st.gco !== 'copy') return false;
1031
+ if (st.clip === NON_RECT) return false;
1032
+ if (st.globalAlpha < 1) return false;
1033
+ if (st.shadowBlur > 0 && parseColor(st.shadowColor)[3] > 0) return false;
1034
+ if (sw !== dw || sh !== dh) return false;
1035
+ const [a, b, c, d, e, f] = st.ctm;
1036
+ if (a !== 1 || b !== 0 || c !== 0 || d !== 1) return false;
1037
+ const x = dx + e;
1038
+ const y = dy + f;
1039
+ if (
1040
+ !Number.isInteger(x) ||
1041
+ !Number.isInteger(y) ||
1042
+ !Number.isInteger(sx) ||
1043
+ !Number.isInteger(sy) ||
1044
+ !Number.isInteger(sw) ||
1045
+ !Number.isInteger(sh)
1046
+ ) {
1047
+ return false;
1048
+ }
1049
+ const dst = this._s();
1050
+ if (dst === src) return false;
1051
+ const clip = st.clip;
1052
+ this._native.blitSurface(
1053
+ src,
1054
+ sx,
1055
+ sy,
1056
+ sw,
1057
+ sh,
1058
+ dst,
1059
+ x,
1060
+ y,
1061
+ clip ? [clip.x, clip.y, clip.width, clip.height] : null,
1062
+ );
1063
+ return true;
1064
+ }
1065
+
850
1066
  /**
851
1067
  * Browser contract: a blank RGBA pixel block for the caller to fill and
852
1068
  * hand back to putImageData. Pure allocation — nothing touches the
@@ -11,10 +11,27 @@
11
11
  // is CommonJS + a .node binary, so `createRequire` is the honest loader.
12
12
  import { createRequire } from 'node:module';
13
13
 
14
- const require = createRequire(import.meta.url);
15
-
16
14
  const PACKAGE = '@windowkit/appkit';
17
15
 
16
+ // The loader is made on first use, and from the executable when there is
17
+ // no module URL to make it from. A bundle built for Node's single-executable
18
+ // format (docs/packaging.md, tier 3) is CommonJS, and esbuild leaves
19
+ // `import.meta` an empty object in that output — so a module-scope
20
+ // `createRequire(import.meta.url)` threw ERR_INVALID_ARG_VALUE the moment
21
+ // the backend loaded, in every cocoa app shipped as a SEA, before this
22
+ // could say what it was trying to load. `createRequire(process.execPath)`
23
+ // is the loader Node's SEA docs prescribe (the embedded main's `__filename`
24
+ // *is* the executable), and it answers both specs below: an absolute
25
+ // REACT_X11_CALAYERS_PATH resolves from anywhere, and the bare package name
26
+ // resolves through a `node_modules` beside the binary. Made lazily, a
27
+ // loader that cannot be made at all is reported by the error at the bottom
28
+ // rather than by a throw at import.
29
+ let require = null;
30
+ function load(spec) {
31
+ require ??= createRequire(import.meta.url ?? process.execPath);
32
+ return require(spec);
33
+ }
34
+
18
35
  let cached = null;
19
36
 
20
37
  export function loadNative() {
@@ -30,7 +47,7 @@ export function loadNative() {
30
47
  const path = process.env.REACT_X11_CALAYERS_PATH;
31
48
  for (const spec of [path, PACKAGE].filter(Boolean)) {
32
49
  try {
33
- const mod = require(spec);
50
+ const mod = load(spec);
34
51
  // the package's index.js exports the raw addon as `native`; a direct
35
52
  // path to a built checkout may be the addon itself
36
53
  cached = mod.native ?? mod;
@@ -13,23 +13,32 @@
13
13
  // bundled app must carry the usage-description keys
14
14
  // (`NSCameraUsageDescription` and friends) or a request never prompts.
15
15
 
16
- /** Apple's four words as the ladder's. */
16
+ /** Apple's words as the ladder's. `writeOnly` is macOS 14's partial grant
17
+ * for EventKit — a grant to a writer, a refusal to a reader — and stays its
18
+ * own word for exactly that reason. */
17
19
  const STATUS = Object.freeze({
18
20
  authorized: 'granted',
19
21
  denied: 'denied',
20
22
  restricted: 'restricted',
21
23
  notDetermined: 'prompt',
24
+ writeOnly: 'write-only',
22
25
  });
23
26
 
24
27
  export function statusFromBridge(status) {
25
28
  return STATUS[status] ?? 'unknown';
26
29
  }
27
30
 
28
- /** The bridge's options for a kind: `automation` carries its target. */
31
+ /** The bridge's options for a kind: `automation` carries its target, and
32
+ * `calendars` the level being asked for (`reminders` has no write-only
33
+ * grant, and the bridge refuses one with a TypeError). */
29
34
  function bridgeOptions(kind, options = {}) {
30
- return kind === 'automation' && options.target != null
31
- ? { target: String(options.target) }
32
- : undefined;
35
+ if (kind === 'automation' && options.target != null) {
36
+ return { target: String(options.target) };
37
+ }
38
+ if (kind === 'calendars' && options.access != null) {
39
+ return { access: String(options.access) };
40
+ }
41
+ return undefined;
33
42
  }
34
43
 
35
44
  export class CocoaPermissions {