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 +4 -2
- package/src/Reconciler.js +7 -0
- package/src/cocoa/app.js +35 -0
- package/src/cocoa/calendar.js +186 -0
- package/src/cocoa/context2d.js +220 -4
- package/src/cocoa/native.js +20 -3
- package/src/cocoa/permissions.js +14 -5
- package/src/cocoa/window.js +21 -5
- package/src/debug.js +8 -2
- package/src/desktopcalendar.js +1415 -0
- package/src/desktopcalendarhooks.js +259 -0
- package/src/glnodes.js +63 -4
- package/src/index.d.ts +11 -1
- package/src/index.js +9 -0
- package/src/node.d.ts +214 -2
- package/src/nodes.js +208 -13
- package/src/pacing.js +476 -0
- package/src/permissionhooks.js +9 -6
- package/src/permissions.js +12 -2
- package/src/trace-registry.js +4 -3
- package/src/types/desktopcalendar.d.ts +207 -0
- package/src/types/elements.d.ts +54 -0
- package/src/types/permissions.d.ts +12 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-x11",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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
|
+
}
|
package/src/cocoa/context2d.js
CHANGED
|
@@ -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.
|
|
89
|
-
* coverage with the context's fill
|
|
90
|
-
*
|
|
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
|
-
|
|
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
|
package/src/cocoa/native.js
CHANGED
|
@@ -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 =
|
|
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;
|
package/src/cocoa/permissions.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 {
|