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/src/pacing.js
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// Frame pacing — how often a window (or a `<glarea>`) paints while its
|
|
2
|
+
// content changes faster than anyone can read it, priced in CPU time rather
|
|
3
|
+
// than counted in updates.
|
|
4
|
+
//
|
|
5
|
+
// The frame clock decides *when* a frame may go out: the display's period
|
|
6
|
+
// on the Cocoa backend, the server fence or the vertical blank on X11. It
|
|
7
|
+
// says nothing about whether the frame is worth painting. A window fed off
|
|
8
|
+
// an input path — a terminal under `cat`, a chart on a socket, a simulation
|
|
9
|
+
// — claims a repaint every time its data moves, and with nothing between the
|
|
10
|
+
// claim and the clock it paints every refresh: on a 120Hz panel whose frames
|
|
11
|
+
// cost 6ms of CoreGraphics each, that is 70% of the JS thread spent painting
|
|
12
|
+
// screens nobody sees, and the producer — the pty reader, the parser — gets
|
|
13
|
+
// what is left. The X11 fence hides the same problem behind backpressure;
|
|
14
|
+
// the Cocoa clock has none.
|
|
15
|
+
//
|
|
16
|
+
// The rule here is a token bucket over paint time. Credit accrues at
|
|
17
|
+
// `budget` milliseconds per millisecond of wall time — a budget of 0.25 is
|
|
18
|
+
// "painting may take a quarter of the time" — up to a burst allowance; a
|
|
19
|
+
// frame spends its measured cost; a claim that finds the bucket in debt
|
|
20
|
+
// waits for it to refill to zero, and no longer. Four properties follow,
|
|
21
|
+
// and they are what the tests pin:
|
|
22
|
+
//
|
|
23
|
+
// - **Idle is immediate.** A quiet window has a full bucket, so the first
|
|
24
|
+
// claim after a pause — a keystroke, a click — paints on the next tick,
|
|
25
|
+
// whatever the last frame cost.
|
|
26
|
+
// - **Cheap is unthrottled.** A frame that costs less than the credit it
|
|
27
|
+
// accrued leaves the bucket where it was. A blit-scrolled list on a 120Hz
|
|
28
|
+
// panel keeps 120Hz: the frames are memmoves and a strip.
|
|
29
|
+
// - **A rare expensive frame is free.** The burst is what absorbs the one
|
|
30
|
+
// relayout in a scroll: it costs the credit, the credit refills, and no
|
|
31
|
+
// frame waited. Only a *stream* of expensive frames drains the bucket —
|
|
32
|
+
// and then the wait after each is `cost × (1/budget − 1)`, which holds
|
|
33
|
+
// paint at exactly the budget's share of wall time.
|
|
34
|
+
// - **A flood that ends is un-throttled at once.** The debt is at most one
|
|
35
|
+
// frame's cost, so the prompt that appears when the flood stops waits
|
|
36
|
+
// for that and nothing more — where an averaged rate would keep
|
|
37
|
+
// throttling it.
|
|
38
|
+
//
|
|
39
|
+
// A floor (`minFps`) bounds the wait whatever the debt — the screen is never
|
|
40
|
+
// more than `1000 / minFps` behind the last paint — and a ceiling (`maxFps`)
|
|
41
|
+
// holds even cheap frames to a rate, the knob every terminal has.
|
|
42
|
+
//
|
|
43
|
+
// **Cost is the JS thread's time**: the flush (layout, paint, the requests
|
|
44
|
+
// or the CoreGraphics work) plus, on Cocoa, the present that follows it.
|
|
45
|
+
// Not the server's — on X11 the fence already paces to that, and the pacer
|
|
46
|
+
// is deliberately inert where a backend has backpressure of its own. The
|
|
47
|
+
// clock is injected so that the whole rule runs under a fake one in tests.
|
|
48
|
+
//
|
|
49
|
+
// Off by default: `'display'` is the built-in, and paints every frame the
|
|
50
|
+
// clock gives, after React's own batching — a UI answers its input as
|
|
51
|
+
// quickly as it can unless it says otherwise. `'adaptive'` is what a window
|
|
52
|
+
// that streams asks for. docs/architecture/frame-pacing.md is the account.
|
|
53
|
+
|
|
54
|
+
export const DEFAULT_FRAME_RATE = 'display';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The presets, as the three numbers they stand for. `budget` is the share
|
|
58
|
+
* of wall time paints may take while the window is busy; `minFps` is the
|
|
59
|
+
* floor (0: none); `maxFps` the ceiling (0: none).
|
|
60
|
+
*/
|
|
61
|
+
export const FRAME_RATE_PRESETS = Object.freeze({
|
|
62
|
+
display: Object.freeze({ mode: 'display', budget: 1, minFps: 0, maxFps: 0 }),
|
|
63
|
+
adaptive: Object.freeze({
|
|
64
|
+
mode: 'adaptive',
|
|
65
|
+
budget: 0.25,
|
|
66
|
+
minFps: 20,
|
|
67
|
+
maxFps: 0,
|
|
68
|
+
}),
|
|
69
|
+
throughput: Object.freeze({
|
|
70
|
+
mode: 'throughput',
|
|
71
|
+
budget: 0.1,
|
|
72
|
+
minFps: 10,
|
|
73
|
+
maxFps: 30,
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const FIELDS = ['budget', 'minFps', 'maxFps'];
|
|
78
|
+
|
|
79
|
+
// How much paint time a window with no floor may spend before the pacer
|
|
80
|
+
// starts charging for it (with a floor, the floor's interval is the burst:
|
|
81
|
+
// one frame up to the longest wait the pacer may impose is always free).
|
|
82
|
+
const DEFAULT_BURST_MS = 50;
|
|
83
|
+
|
|
84
|
+
// A wait shorter than this is not armed: a timer cannot keep it, and the
|
|
85
|
+
// frame clock's own period would swallow it. The debt carries to the next
|
|
86
|
+
// claim instead, so a run of sub-millisecond frames claimed back to back
|
|
87
|
+
// still pays for itself once the debt is worth a timer — the budget holds
|
|
88
|
+
// on average — while a cheap frame after a flood lands on the tick it would
|
|
89
|
+
// have landed on anyway.
|
|
90
|
+
const MIN_WAIT_MS = 1;
|
|
91
|
+
|
|
92
|
+
const warned = new Set();
|
|
93
|
+
function warnOnce(message) {
|
|
94
|
+
if (process.env.NODE_ENV === 'production' || warned.has(message)) return;
|
|
95
|
+
warned.add(message);
|
|
96
|
+
console.warn(message);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const describe = (value) =>
|
|
100
|
+
typeof value === 'string' ? JSON.stringify(value) : String(value);
|
|
101
|
+
|
|
102
|
+
const HOW =
|
|
103
|
+
"'display' (every frame the clock gives — the default), 'adaptive' " +
|
|
104
|
+
"(paints stay under a quarter of the time, 20fps floor), 'throughput' " +
|
|
105
|
+
'(a tenth, 10fps floor, 30fps ceiling), a number (a ceiling in frames ' +
|
|
106
|
+
'per second), or { budget, minFps, maxFps } — see docs/elements.md ' +
|
|
107
|
+
'"frameRate".';
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A `frameRate` value — a preset name, a number, or the three numbers — as
|
|
111
|
+
* the policy it stands for: `{ mode, budget, minFps, maxFps }`, frozen.
|
|
112
|
+
* `mode` is the preset's name, or `'custom'` for a number or an object.
|
|
113
|
+
*
|
|
114
|
+
* A number is a ceiling over the default: `60` is "never more than 60fps",
|
|
115
|
+
* and nothing else changes. An object names any of the three; the ones it
|
|
116
|
+
* leaves out are the default's, so `{ budget: 0.25 }` has no floor — which
|
|
117
|
+
* is worth a warning, because a budget with no floor can hold a frame for
|
|
118
|
+
* as long as the last one cost, three times over.
|
|
119
|
+
*
|
|
120
|
+
* `where` names the call site in the error, since the same value arrives
|
|
121
|
+
* as a prop, a root option and an environment variable.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveFrameRate(value, where = 'frameRate') {
|
|
124
|
+
if (value === undefined || value === null) value = DEFAULT_FRAME_RATE;
|
|
125
|
+
if (typeof value === 'string') {
|
|
126
|
+
const preset = FRAME_RATE_PRESETS[value];
|
|
127
|
+
if (!preset) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`react-x11: ${where} ${describe(value)} is not a frame rate — ${HOW}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return preset;
|
|
133
|
+
}
|
|
134
|
+
const base = FRAME_RATE_PRESETS[DEFAULT_FRAME_RATE];
|
|
135
|
+
if (typeof value === 'number') {
|
|
136
|
+
if (!(value > 0)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`react-x11: ${where} ${describe(value)} — a number is a ceiling in ` +
|
|
139
|
+
`frames per second, so it has to be above zero; ${HOW}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (!Number.isFinite(value)) return base;
|
|
143
|
+
return Object.freeze({ ...base, mode: 'custom', maxFps: value });
|
|
144
|
+
}
|
|
145
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`react-x11: ${where} ${describe(value)} is not a frame rate — ${HOW}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const unknown = Object.keys(value).filter((k) => !FIELDS.includes(k));
|
|
151
|
+
if (unknown.length) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`react-x11: ${where} has no ${unknown.map((k) => JSON.stringify(k)).join(', ')} ` +
|
|
154
|
+
`— the three numbers are budget, minFps and maxFps; ${HOW}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const num = (name, check, what) => {
|
|
158
|
+
const v = value[name];
|
|
159
|
+
if (v === undefined) return base[name];
|
|
160
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || !check(v)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`react-x11: ${where}.${name} ${describe(v)} — ${what}; ${HOW}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return v;
|
|
166
|
+
};
|
|
167
|
+
const policy = Object.freeze({
|
|
168
|
+
mode: 'custom',
|
|
169
|
+
budget: num(
|
|
170
|
+
'budget',
|
|
171
|
+
(v) => v > 0 && v <= 1,
|
|
172
|
+
'the share of wall time paints may take, above 0 and at most 1',
|
|
173
|
+
),
|
|
174
|
+
minFps: num('minFps', (v) => v >= 0, 'a floor in frames per second, or 0'),
|
|
175
|
+
maxFps: num(
|
|
176
|
+
'maxFps',
|
|
177
|
+
(v) => v >= 0,
|
|
178
|
+
'a ceiling in frames per second, or 0',
|
|
179
|
+
),
|
|
180
|
+
});
|
|
181
|
+
if (policy.minFps > 0 && policy.maxFps > 0 && policy.minFps > policy.maxFps) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`react-x11: ${where} puts the floor (minFps ${policy.minFps}) above the ` +
|
|
184
|
+
`ceiling (maxFps ${policy.maxFps}) — the floor is how stale the screen ` +
|
|
185
|
+
'may get, the ceiling how often it may paint, so minFps <= maxFps.',
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (policy.budget < 1 && policy.minFps === 0) {
|
|
189
|
+
warnOnce(
|
|
190
|
+
`react-x11: ${where} sets budget ${policy.budget} with no minFps — ` +
|
|
191
|
+
'after an expensive frame the next may wait ' +
|
|
192
|
+
`${(1 / policy.budget - 1).toFixed(1)}× its cost with nothing to bound ` +
|
|
193
|
+
"it. Name a floor: { budget, minFps: 20 } is what 'adaptive' does.",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return policy;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Whether two resolved policies pace the same way. */
|
|
200
|
+
export function sameFramePolicy(a, b) {
|
|
201
|
+
return (
|
|
202
|
+
a === b ||
|
|
203
|
+
(Boolean(a && b) &&
|
|
204
|
+
a.budget === b.budget &&
|
|
205
|
+
a.minFps === b.minFps &&
|
|
206
|
+
a.maxFps === b.maxFps)
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const ENV = 'REACT_X11_FRAME_RATE';
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The environment's say: `REACT_X11_FRAME_RATE=adaptive` (or `display`,
|
|
214
|
+
* `throughput`, or a number). It overrides every window's prop and the root
|
|
215
|
+
* option, the way `REACT_X11_BACKEND` overrides `createRoot({ backend })`:
|
|
216
|
+
* an A/B run and a field diagnosis must not need a code change. Undefined
|
|
217
|
+
* when unset or empty; garbage throws with the fix, at the first window.
|
|
218
|
+
*/
|
|
219
|
+
export function frameRateFromEnv(env = process.env) {
|
|
220
|
+
const raw = env[ENV];
|
|
221
|
+
if (raw === undefined || raw === '') return undefined;
|
|
222
|
+
const text = raw.trim();
|
|
223
|
+
const n = Number(text);
|
|
224
|
+
return text !== '' && Number.isFinite(n) ? n : text;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const defaults = new WeakMap();
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* `createRoot({ frameRate })`: the default for every window this root opens
|
|
231
|
+
* that names none of its own. Validated here, so a bad value fails at the
|
|
232
|
+
* call that passed it rather than at the first window it reaches.
|
|
233
|
+
*/
|
|
234
|
+
export function setFrameRateDefault(app, value) {
|
|
235
|
+
if (value === undefined) {
|
|
236
|
+
defaults.delete(app);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
resolveFrameRate(value, 'createRoot({ frameRate })');
|
|
240
|
+
defaults.set(app, value);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export const frameRateDefaultFor = (app) => defaults.get(app);
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The policy a window resolves to: the environment first, then its own
|
|
247
|
+
* prop, then the root's default, then the built-in. `where` labels a bad
|
|
248
|
+
* prop in the error; the other two sources are labelled by their names.
|
|
249
|
+
*/
|
|
250
|
+
export function resolveFramePolicy(prop, app, where = '<window frameRate>') {
|
|
251
|
+
const env = frameRateFromEnv();
|
|
252
|
+
if (env !== undefined) return resolveFrameRate(env, ENV);
|
|
253
|
+
if (prop !== undefined && prop !== null) return resolveFrameRate(prop, where);
|
|
254
|
+
const fallback = frameRateDefaultFor(app);
|
|
255
|
+
if (fallback !== undefined) {
|
|
256
|
+
return resolveFrameRate(fallback, 'createRoot({ frameRate })');
|
|
257
|
+
}
|
|
258
|
+
return FRAME_RATE_PRESETS[DEFAULT_FRAME_RATE];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The real clock: `performance.now()`, and a one-shot that never holds the
|
|
262
|
+
* process open — a deferral pending when the last window closes must not
|
|
263
|
+
* keep an otherwise finished process alive. */
|
|
264
|
+
export const realClock = Object.freeze({
|
|
265
|
+
now: () => performance.now(),
|
|
266
|
+
after(ms, fn) {
|
|
267
|
+
const timer = setTimeout(fn, Math.max(1, ms));
|
|
268
|
+
timer.unref?.();
|
|
269
|
+
return () => clearTimeout(timer);
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The pacer one frame source owns — a `WindowNode`, a `GlAreaNode`. The
|
|
275
|
+
* source asks `defer(fn)` before scheduling a frame: `false` means "now",
|
|
276
|
+
* and the source schedules as it always did; `true` means the pacer has
|
|
277
|
+
* armed a one-shot that will call `fn` when the frame may start, and a
|
|
278
|
+
* second `defer` before then is coalesced into it. The source brackets each
|
|
279
|
+
* frame with `began()`/`ended()`, and reports work outside that bracket —
|
|
280
|
+
* the Cocoa present — with `charge()`. Any frame that runs by another route
|
|
281
|
+
* (a discrete input's early flush) calls `cancel()`, so a deferral never
|
|
282
|
+
* fires for a frame that has already been painted.
|
|
283
|
+
*
|
|
284
|
+
* `clock` is `{ now(), after(ms, fn) → cancel }`, replaceable for a test;
|
|
285
|
+
* every method reads it at call time.
|
|
286
|
+
*/
|
|
287
|
+
export class FramePacer {
|
|
288
|
+
constructor(
|
|
289
|
+
policy = FRAME_RATE_PRESETS[DEFAULT_FRAME_RATE],
|
|
290
|
+
clock = realClock,
|
|
291
|
+
) {
|
|
292
|
+
this._clock = clock;
|
|
293
|
+
this.policy = null;
|
|
294
|
+
this._timer = null;
|
|
295
|
+
this._startedAt = null;
|
|
296
|
+
this._armedWait = 0;
|
|
297
|
+
this.stats = {
|
|
298
|
+
mode: DEFAULT_FRAME_RATE,
|
|
299
|
+
budget: 1,
|
|
300
|
+
minFps: 0,
|
|
301
|
+
maxFps: 0,
|
|
302
|
+
/** frames that painted */
|
|
303
|
+
frames: 0,
|
|
304
|
+
/** claims that armed a wait */
|
|
305
|
+
deferred: 0,
|
|
306
|
+
/** claims folded into a wait already armed */
|
|
307
|
+
coalesced: 0,
|
|
308
|
+
/** what the last painted frame cost, ms (flush plus present) */
|
|
309
|
+
lastCostMs: 0,
|
|
310
|
+
/** the wait the last painted frame followed, ms; 0 when none */
|
|
311
|
+
lastWaitMs: 0,
|
|
312
|
+
};
|
|
313
|
+
this.configure(policy);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Take a (new) policy. A change starts from a full bucket: `'display'`
|
|
318
|
+
* takes effect on the next claim rather than after the next paint, and a
|
|
319
|
+
* tighter budget charges from now rather than for a debt run up under the
|
|
320
|
+
* old rule. The same policy again is a no-op, so a caller may sync every
|
|
321
|
+
* frame.
|
|
322
|
+
*/
|
|
323
|
+
configure(policy) {
|
|
324
|
+
if (sameFramePolicy(this.policy, policy)) return;
|
|
325
|
+
this.policy = policy;
|
|
326
|
+
this._minGap = policy.maxFps > 0 ? 1000 / policy.maxFps : 0;
|
|
327
|
+
this._maxWait = policy.minFps > 0 ? 1000 / policy.minFps : Infinity;
|
|
328
|
+
this._burst = Number.isFinite(this._maxWait)
|
|
329
|
+
? this._maxWait
|
|
330
|
+
: DEFAULT_BURST_MS;
|
|
331
|
+
this._reset();
|
|
332
|
+
const s = this.stats;
|
|
333
|
+
s.mode = policy.mode;
|
|
334
|
+
s.budget = policy.budget;
|
|
335
|
+
s.minFps = policy.minFps;
|
|
336
|
+
s.maxFps = policy.maxFps;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** A full bucket, and no frame yet, as of the clock's now. */
|
|
340
|
+
_reset() {
|
|
341
|
+
this._credit = this._burst;
|
|
342
|
+
this._creditAt = this._clock.now();
|
|
343
|
+
this._lastStart = -Infinity;
|
|
344
|
+
this._lastEnd = -Infinity;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
get clock() {
|
|
348
|
+
return this._clock;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* A new clock starts the pacer afresh in that clock's time. Every
|
|
353
|
+
* timestamp the rule keeps — when credit last accrued, when the last
|
|
354
|
+
* frame started and ended — is in the old clock's terms, and a test that
|
|
355
|
+
* hands over a fake clock mid-life would otherwise wait for it to catch
|
|
356
|
+
* up with `performance.now()` before any credit accrued: minutes into a
|
|
357
|
+
* suite on a slow runner, which is exactly where it was found.
|
|
358
|
+
*/
|
|
359
|
+
set clock(value) {
|
|
360
|
+
this._clock = value;
|
|
361
|
+
this._reset();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Whether this policy can ever hold a frame. `'display'` cannot, and
|
|
365
|
+
* costs the frame source one property read per claim. */
|
|
366
|
+
get active() {
|
|
367
|
+
return this.policy.budget < 1 || this._minGap > 0;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
_accrue(now) {
|
|
371
|
+
if (now > this._creditAt) {
|
|
372
|
+
this._credit = Math.min(
|
|
373
|
+
this._burst,
|
|
374
|
+
this._credit + this.policy.budget * (now - this._creditAt),
|
|
375
|
+
);
|
|
376
|
+
this._creditAt = now;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** How long a frame claimed at `now` has to wait, in ms; 0 for now. */
|
|
381
|
+
wait(now = this.clock.now()) {
|
|
382
|
+
if (!this.active) return 0;
|
|
383
|
+
this._accrue(now);
|
|
384
|
+
const { budget } = this.policy;
|
|
385
|
+
// the debt, paid back at the budget's rate
|
|
386
|
+
let wait = this._credit >= 0 ? 0 : -this._credit / budget;
|
|
387
|
+
// the floor: whatever the debt, a frame is due within `maxWait` of the
|
|
388
|
+
// last frame's end — a promise about staleness, not a rate
|
|
389
|
+
if (Number.isFinite(this._maxWait)) {
|
|
390
|
+
wait = Math.min(wait, Math.max(0, this._lastEnd + this._maxWait - now));
|
|
391
|
+
}
|
|
392
|
+
// the ceiling: not sooner than a period after the last frame *started*,
|
|
393
|
+
// cheap frames included — a rate, measured start to start
|
|
394
|
+
if (this._minGap > 0) {
|
|
395
|
+
wait = Math.max(wait, this._lastStart + this._minGap - now);
|
|
396
|
+
}
|
|
397
|
+
return wait >= MIN_WAIT_MS ? wait : 0;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Hold `fn` until the frame may start, or answer `false` for "schedule it
|
|
402
|
+
* now". At most one wait is armed; a claim while one is armed is folded
|
|
403
|
+
* into it and answers `true` too.
|
|
404
|
+
*/
|
|
405
|
+
defer(fn, now = this.clock.now()) {
|
|
406
|
+
if (this._timer) {
|
|
407
|
+
this.stats.coalesced += 1;
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
const wait = this.wait(now);
|
|
411
|
+
if (!(wait > 0)) return false;
|
|
412
|
+
this.stats.deferred += 1;
|
|
413
|
+
this._armedWait = wait;
|
|
414
|
+
this._timer = this.clock.after(wait, () => {
|
|
415
|
+
this._timer = null;
|
|
416
|
+
fn();
|
|
417
|
+
});
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Whether a wait is armed. */
|
|
422
|
+
get deferring() {
|
|
423
|
+
return this._timer !== null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** The wait the frame now running followed, ms — read inside the frame,
|
|
427
|
+
* before `ended` files it under the frame's stats. 0 when none. */
|
|
428
|
+
get pendingWait() {
|
|
429
|
+
return this._armedWait;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** A frame ran by another route: the wait, if any, has nothing to do. */
|
|
433
|
+
cancel() {
|
|
434
|
+
if (!this._timer) return;
|
|
435
|
+
this._timer();
|
|
436
|
+
this._timer = null;
|
|
437
|
+
// the frame that runs instead was not held by the pacer
|
|
438
|
+
this._armedWait = 0;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** A frame is starting. */
|
|
442
|
+
began(now = this.clock.now()) {
|
|
443
|
+
this._startedAt = now;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The frame is over. `painted` false is a flush that found nothing to
|
|
448
|
+
* paint: its cost is charged like any work on the thread, but it is not
|
|
449
|
+
* a frame — the ceiling measures from the last frame that *was* one.
|
|
450
|
+
*/
|
|
451
|
+
ended(now = this.clock.now(), painted = true) {
|
|
452
|
+
const startedAt = this._startedAt ?? now;
|
|
453
|
+
this._startedAt = null;
|
|
454
|
+
const cost = Math.max(0, now - startedAt);
|
|
455
|
+
this._accrue(now);
|
|
456
|
+
this._credit -= cost;
|
|
457
|
+
this._lastEnd = now;
|
|
458
|
+
if (!painted) return;
|
|
459
|
+
this._lastStart = startedAt;
|
|
460
|
+
const s = this.stats;
|
|
461
|
+
s.frames += 1;
|
|
462
|
+
s.lastCostMs = cost;
|
|
463
|
+
s.lastWaitMs = this._armedWait;
|
|
464
|
+
this._armedWait = 0;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Work the frame cost outside `began`/`ended` — the present on Cocoa,
|
|
468
|
+
* which runs after the flush returns. Extends the last frame. */
|
|
469
|
+
charge(ms, now = this.clock.now()) {
|
|
470
|
+
if (!(ms > 0)) return;
|
|
471
|
+
this._accrue(now);
|
|
472
|
+
this._credit -= ms;
|
|
473
|
+
this._lastEnd = now;
|
|
474
|
+
this.stats.lastCostMs += ms;
|
|
475
|
+
}
|
|
476
|
+
}
|
package/src/permissionhooks.js
CHANGED
|
@@ -42,30 +42,33 @@ import {
|
|
|
42
42
|
export function usePermission(kind, options = {}) {
|
|
43
43
|
const app = useAppOrNull();
|
|
44
44
|
const target = options.target;
|
|
45
|
+
// `calendars` only: which level to ask for. Part of the effect keys
|
|
46
|
+
// because asking for a narrower grant is asking a different question.
|
|
47
|
+
const access = options.access;
|
|
45
48
|
const available = permissionBackend({ app }) !== null;
|
|
46
49
|
const [status, setStatus] = useState('unknown');
|
|
47
50
|
const inflight = useRef(null);
|
|
48
51
|
|
|
49
52
|
const refresh = useCallback(async () => {
|
|
50
|
-
const next = await permissionStatus(kind, { app, target });
|
|
53
|
+
const next = await permissionStatus(kind, { app, target, access });
|
|
51
54
|
setStatus(next);
|
|
52
55
|
return next;
|
|
53
|
-
}, [kind, app, target]);
|
|
56
|
+
}, [kind, app, target, access]);
|
|
54
57
|
|
|
55
58
|
useEffect(() => {
|
|
56
59
|
let alive = true;
|
|
57
|
-
permissionStatus(kind, { app, target }).then(
|
|
60
|
+
permissionStatus(kind, { app, target, access }).then(
|
|
58
61
|
(next) => alive && setStatus(next),
|
|
59
62
|
() => alive && setStatus('unknown'),
|
|
60
63
|
);
|
|
61
64
|
return () => {
|
|
62
65
|
alive = false;
|
|
63
66
|
};
|
|
64
|
-
}, [kind, app, target]);
|
|
67
|
+
}, [kind, app, target, access]);
|
|
65
68
|
|
|
66
69
|
const request = useCallback(() => {
|
|
67
70
|
if (inflight.current) return inflight.current;
|
|
68
|
-
const run = requestPermission(kind, { app, target })
|
|
71
|
+
const run = requestPermission(kind, { app, target, access })
|
|
69
72
|
.then((next) => {
|
|
70
73
|
setStatus(next);
|
|
71
74
|
return next;
|
|
@@ -75,7 +78,7 @@ export function usePermission(kind, options = {}) {
|
|
|
75
78
|
});
|
|
76
79
|
inflight.current = run;
|
|
77
80
|
return run;
|
|
78
|
-
}, [kind, app, target]);
|
|
81
|
+
}, [kind, app, target, access]);
|
|
79
82
|
|
|
80
83
|
const openSettings = useCallback(
|
|
81
84
|
() => openPrivacySettings(kind, { app }),
|
package/src/permissions.js
CHANGED
|
@@ -23,18 +23,24 @@
|
|
|
23
23
|
//
|
|
24
24
|
// ## Two things the vocabulary decides
|
|
25
25
|
//
|
|
26
|
-
// - A status is one of
|
|
26
|
+
// - A status is one of six words. `'granted'`, `'denied'` and
|
|
27
27
|
// `'restricted'` (MDM or parental controls: the user cannot grant it) are
|
|
28
28
|
// the platform's; `'prompt'` is "not decided yet — a request would ask";
|
|
29
29
|
// `'unknown'` is "nothing here can say", which is a fact about the machine
|
|
30
30
|
// rather than about the permission, and the reason a query never throws.
|
|
31
|
+
// `'write-only'` is the sixth and the odd one: macOS 14's partial grant
|
|
32
|
+
// for `calendars` and `reminders`, where the app may save an item it
|
|
33
|
+
// cannot read. It crosses as its own word rather than being flattened
|
|
34
|
+
// into one of the other two, because it is a grant to a writer and a
|
|
35
|
+
// refusal to a reader and only the caller knows which it is.
|
|
31
36
|
// - A request answers with the status **after** the user has, never with a
|
|
32
37
|
// bare boolean, because `'restricted'` and `'denied'` want different UI —
|
|
33
38
|
// one is a Settings switch the user can flip, the other is not.
|
|
34
39
|
|
|
35
40
|
import { liveApps } from './trace-registry.js';
|
|
36
41
|
|
|
37
|
-
/** The kinds a status can be asked for. `automation` wants `{ target }
|
|
42
|
+
/** The kinds a status can be asked for. `automation` wants `{ target }`;
|
|
43
|
+
* `calendars` takes `{ access: 'write-only' }` for the narrower grant. */
|
|
38
44
|
export const PERMISSION_KINDS = Object.freeze([
|
|
39
45
|
'camera',
|
|
40
46
|
'microphone',
|
|
@@ -43,6 +49,8 @@ export const PERMISSION_KINDS = Object.freeze([
|
|
|
43
49
|
'input-monitoring',
|
|
44
50
|
'automation',
|
|
45
51
|
'location',
|
|
52
|
+
'calendars',
|
|
53
|
+
'reminders',
|
|
46
54
|
]);
|
|
47
55
|
|
|
48
56
|
/** The Settings panes, the kinds above plus the two that have no API at all
|
|
@@ -55,6 +63,8 @@ const SETTINGS_PANES = Object.freeze({
|
|
|
55
63
|
'input-monitoring': 'Privacy_ListenEvent',
|
|
56
64
|
automation: 'Privacy_Automation',
|
|
57
65
|
location: 'Privacy_LocationServices',
|
|
66
|
+
calendars: 'Privacy_Calendars',
|
|
67
|
+
reminders: 'Privacy_Reminders',
|
|
58
68
|
'files-and-folders': 'Privacy_FilesAndFolders',
|
|
59
69
|
'full-disk-access': 'Privacy_AllFiles',
|
|
60
70
|
});
|
package/src/trace-registry.js
CHANGED
|
@@ -51,9 +51,10 @@ export function onApp(fn) {
|
|
|
51
51
|
* of an unset hook is one property read — which is the whole design: the
|
|
52
52
|
* frame loop must not pay for a tracer nobody started.
|
|
53
53
|
*
|
|
54
|
-
* - `frame({ root, rects, reasons, start, end })` — after
|
|
55
|
-
* `rects` is the damage list, null for a full repaint;
|
|
56
|
-
* `performance.now()
|
|
54
|
+
* - `frame({ root, rects, reasons, start, end, landed, waited })` — after
|
|
55
|
+
* a window painted. `rects` is the damage list, null for a full repaint;
|
|
56
|
+
* times come from `performance.now()`; `waited` is how long the frame
|
|
57
|
+
* pacer held the claim, 0 when it did not (src/pacing.js).
|
|
57
58
|
* - `commitStart()` / `commitEnd()` — around a React commit.
|
|
58
59
|
*/
|
|
59
60
|
export const hooks = {
|