ntk 8.2.0 → 8.3.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/lib/app.js +346 -7
- package/lib/cleanup.js +23 -7
- package/lib/drawable.js +10 -0
- package/lib/events_map.js +100 -1
- package/lib/glyphdirectory.js +275 -0
- package/lib/glyphdwire.js +142 -0
- package/lib/glyphset.js +32 -5
- package/lib/index.js +25 -2
- package/lib/pictformat.js +196 -0
- package/lib/pixmap.js +166 -7
- package/lib/region.js +148 -0
- package/lib/renderingcontext_2d.js +717 -235
- package/lib/shapeglyphs.js +94 -26
- package/lib/sharedglyphs.js +433 -0
- package/lib/text/glyphs.js +143 -26
- package/lib/widgets/svgview.js +7 -5
- package/lib/window.js +308 -40
- package/lib/xembed.js +3 -1
- package/package.json +2 -2
package/lib/shapeglyphs.js
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
import GlyphSet from './glyphset.js';
|
|
36
36
|
import { ellipseSegments, flattenPath } from './path.js';
|
|
37
37
|
import { rasterizePolys } from './rasterize.js';
|
|
38
|
+
import { SHAPE_PAGE_TOKEN, sharedGlyphsFor } from './sharedglyphs.js';
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Shape rendering policy, overridable per app via `app.shapePolicy`
|
|
@@ -164,6 +165,28 @@ export function orientCorner(master, corner) {
|
|
|
164
165
|
return out;
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
/** a fresh AddGlyphs object per call — node-x11 mutates its input; the
|
|
169
|
+
* oriented bitmap buffer itself is never written to and can be retained */
|
|
170
|
+
function uploadCorner(lid, image, stride, height) {
|
|
171
|
+
return {
|
|
172
|
+
id: lid,
|
|
173
|
+
// width is the padded stride, like the text page: node-x11 then
|
|
174
|
+
// ships the buffer as-is instead of re-padding a copy, and the spare
|
|
175
|
+
// zero columns composite as no-ops under Over
|
|
176
|
+
width: stride,
|
|
177
|
+
height,
|
|
178
|
+
// x/y 0: the glyph's top-left lands exactly on its elt position
|
|
179
|
+
x: 0,
|
|
180
|
+
y: 0,
|
|
181
|
+
// corners do not advance a pen. node-x11 expects offX/offY in 26.6
|
|
182
|
+
// fixed point and mutates the objects it is given, which is why
|
|
183
|
+
// these are fresh one-shot objects (see AGENTS.md)
|
|
184
|
+
offX: 0,
|
|
185
|
+
offY: 0,
|
|
186
|
+
image
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
167
190
|
/**
|
|
168
191
|
* Server-side cache of corner glyphs — one page per app, all radii and
|
|
169
192
|
* border widths together, mirroring `GlyphPage` for text (text/glyphs.js).
|
|
@@ -171,19 +194,28 @@ export function orientCorner(master, corner) {
|
|
|
171
194
|
* Local ids are compact and sequential so CompositeGlyphs uses the 8-bit
|
|
172
195
|
* encoding for the first 256 distinct corners (a shape population never
|
|
173
196
|
* leaves it in practice); new corners upload lazily in one AddGlyphs batch
|
|
174
|
-
* per draw that introduces any
|
|
197
|
+
* per draw that introduces any — and, when the display's shared glyph
|
|
198
|
+
* directory is live (docs/shared-glyphs.md), re-bind to the display-wide
|
|
199
|
+
* corner page so every ntk app shares four glyphs per (radius, band width).
|
|
175
200
|
*/
|
|
176
201
|
export class ShapeGlyphPage {
|
|
177
202
|
constructor(app) {
|
|
178
203
|
this.app = app;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
204
|
+
// private from birth when shared glyphs are off; minted lazily when on
|
|
205
|
+
// (a page resolving entirely from the directory never creates one) —
|
|
206
|
+
// exactly the arrangement GlyphPage has (text/glyphs.js)
|
|
207
|
+
this.glyphset = sharedGlyphsFor(app) ? null : new GlyphSet(app);
|
|
208
|
+
this.entries = new Map(); // cornerKey -> { lid, gs }
|
|
209
|
+
this.bytes = 0; // privately uploaded bitmap bytes, for the cache budget
|
|
210
|
+
this._lids = 0; // next private lid
|
|
211
|
+
this._maxLid = -1; // widest id this page composites, private or shared
|
|
212
|
+
this._privateCount = 0; // entries still bound to the private set
|
|
213
|
+
this._shared = undefined; // SharedPage binding, resolved on first ensure
|
|
182
214
|
}
|
|
183
215
|
|
|
184
216
|
/** bits per glyph id needed to address this page */
|
|
185
217
|
get bits() {
|
|
186
|
-
return this.
|
|
218
|
+
return this._maxLid > 255 ? 16 : 8;
|
|
187
219
|
}
|
|
188
220
|
|
|
189
221
|
/**
|
|
@@ -192,13 +224,21 @@ export class ShapeGlyphPage {
|
|
|
192
224
|
* a Map hit and nothing else. Within one batch the top-left master of each
|
|
193
225
|
* (kind, rx, ry, bw) family rasterizes once and the other corners mirror
|
|
194
226
|
* it (see `orientCorner`).
|
|
227
|
+
*
|
|
228
|
+
* With the shared directory live, new corners are also asked about — the
|
|
229
|
+
* `cornerKey()` string goes on the wire verbatim as the member key
|
|
230
|
+
* (docs/shared-glyphs.md) — and their entries re-bind to the display-wide
|
|
231
|
+
* page on the reply, so every ntk app's boxes of one radius share four
|
|
232
|
+
* glyphs per (r, bw) across the whole display.
|
|
195
233
|
*/
|
|
196
234
|
ensure(specs) {
|
|
197
235
|
let batch = null;
|
|
198
236
|
let masters = null;
|
|
237
|
+
let ask = null;
|
|
238
|
+
const shared = this._sharedBinding();
|
|
199
239
|
for (const spec of specs) {
|
|
200
240
|
if (this.entries.has(spec.key)) continue;
|
|
201
|
-
const lid = this.
|
|
241
|
+
const lid = this._lids++;
|
|
202
242
|
if (lid >= 65536) {
|
|
203
243
|
// 2^16 corners on one connection — unreachable (the cache budget
|
|
204
244
|
// resets the page long before), but fail loudly rather than corrupt
|
|
@@ -212,39 +252,67 @@ export class ShapeGlyphPage {
|
|
|
212
252
|
masters.set(mkey, master);
|
|
213
253
|
}
|
|
214
254
|
const image = orientCorner(master, spec.corner);
|
|
215
|
-
this.
|
|
255
|
+
if (!this.glyphset) this.glyphset = new GlyphSet(this.app);
|
|
256
|
+
if (lid > this._maxLid) this._maxLid = lid;
|
|
257
|
+
this.entries.set(spec.key, { lid, gs: this.glyphset.id, private: true });
|
|
258
|
+
this._privateCount++;
|
|
216
259
|
if (!batch) batch = [];
|
|
217
|
-
batch.push(
|
|
218
|
-
id: lid,
|
|
219
|
-
// width is the padded stride, like the text page: node-x11 then
|
|
220
|
-
// ships the buffer as-is instead of re-padding a copy, and the spare
|
|
221
|
-
// zero columns composite as no-ops under Over
|
|
222
|
-
width: master.stride,
|
|
223
|
-
height: master.h,
|
|
224
|
-
// x/y 0: the glyph's top-left lands exactly on its elt position
|
|
225
|
-
x: 0,
|
|
226
|
-
y: 0,
|
|
227
|
-
// corners do not advance a pen. node-x11 expects offX/offY in 26.6
|
|
228
|
-
// fixed point and mutates the objects it is given, which is why
|
|
229
|
-
// these are fresh one-shot objects (see AGENTS.md)
|
|
230
|
-
offX: 0,
|
|
231
|
-
offY: 0,
|
|
232
|
-
image
|
|
233
|
-
});
|
|
260
|
+
batch.push(uploadCorner(lid, image, master.stride, master.h));
|
|
234
261
|
this.bytes += image.length;
|
|
262
|
+
if (shared && shared.open) {
|
|
263
|
+
if (!ask) ask = [];
|
|
264
|
+
ask.push({ key: spec.key, payload: { image, stride: master.stride, height: master.h } });
|
|
265
|
+
}
|
|
235
266
|
}
|
|
236
267
|
if (batch) this.glyphset.addGlyphs(batch);
|
|
268
|
+
if (ask) shared.ask(ask);
|
|
237
269
|
}
|
|
238
270
|
|
|
239
271
|
entry(key) {
|
|
240
272
|
return this.entries.get(key);
|
|
241
273
|
}
|
|
242
274
|
|
|
243
|
-
/**
|
|
275
|
+
/** the shared side of this page; null when the feature is off */
|
|
276
|
+
_sharedBinding() {
|
|
277
|
+
if (this._shared !== undefined) return this._shared;
|
|
278
|
+
const client = sharedGlyphsFor(this.app);
|
|
279
|
+
this._shared = client
|
|
280
|
+
? client.bindPage({
|
|
281
|
+
token: SHAPE_PAGE_TOKEN,
|
|
282
|
+
indices: false, // member keys are cornerKey() strings, verbatim
|
|
283
|
+
makeGlyph: (key, payload, lid) =>
|
|
284
|
+
uploadCorner(lid, payload.image, payload.stride, payload.height),
|
|
285
|
+
adopt: (key, lid, gsid) => this._adoptShared(key, lid, gsid)
|
|
286
|
+
})
|
|
287
|
+
: null;
|
|
288
|
+
return this._shared;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** re-bind one corner to the shared set; drop the private set once
|
|
292
|
+
* nothing composites from it any more */
|
|
293
|
+
_adoptShared(key, lid, gsid) {
|
|
294
|
+
const prev = this.entries.get(key);
|
|
295
|
+
this.entries.set(key, { lid, gs: gsid });
|
|
296
|
+
if (lid > this._maxLid) this._maxLid = lid;
|
|
297
|
+
if (prev && prev.private && --this._privateCount === 0 && this.glyphset) {
|
|
298
|
+
this.glyphset.destroy();
|
|
299
|
+
this.glyphset = null;
|
|
300
|
+
this.bytes = 0;
|
|
301
|
+
this._lids = 0;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** free the server-side glyphset and shared alias (budget reset / shutdown) */
|
|
244
306
|
destroy() {
|
|
245
|
-
this.glyphset.destroy();
|
|
307
|
+
if (this.glyphset) this.glyphset.destroy();
|
|
308
|
+
this.glyphset = null;
|
|
309
|
+
if (this._shared) this._shared.destroy();
|
|
310
|
+
this._shared = undefined;
|
|
246
311
|
this.entries.clear();
|
|
247
312
|
this.bytes = 0;
|
|
313
|
+
this._lids = 0;
|
|
314
|
+
this._maxLid = -1;
|
|
315
|
+
this._privateCount = 0;
|
|
248
316
|
}
|
|
249
317
|
}
|
|
250
318
|
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// Shared glyphs across processes — the client half (docs/shared-glyphs.md).
|
|
2
|
+
//
|
|
3
|
+
// A display-wide "glyph directory" (lib/glyphdirectory.js) owns the
|
|
4
|
+
// `_NTK_GLYPHD` manager selection and is the single allocator of compact
|
|
5
|
+
// local ids inside shared RENDER glyphsets. This module is everything an app
|
|
6
|
+
// needs to *use* it: discovery (GetSelectionOwner; self-election when nobody
|
|
7
|
+
// owns it yet), the ensure/added property RPC, the XID-reuse fence around
|
|
8
|
+
// ReferenceGlyphSet, and the per-page state machine `GlyphPage` and
|
|
9
|
+
// `ShapeGlyphPage` bind to.
|
|
10
|
+
//
|
|
11
|
+
// The feature is a pure overlay: any failure — no owner, a dead owner, a
|
|
12
|
+
// refused request, a hardened server — closes the shared side of the page it
|
|
13
|
+
// happened to, and glyphs keep rendering through today's private per-process
|
|
14
|
+
// path. Entries already bound to a shared set stay bound: aliases survive
|
|
15
|
+
// the directory's death (fenced by test/shared-glyphs-assumptions.test.js),
|
|
16
|
+
// so pages merely freeze rather than break.
|
|
17
|
+
//
|
|
18
|
+
// The one rule everything here enforces: a client never composites a glyph
|
|
19
|
+
// id it has not confirmed present — Xorg silently skips unknown ids without
|
|
20
|
+
// advancing the pen, which corrupts layout rather than erroring.
|
|
21
|
+
|
|
22
|
+
import x11 from 'x11';
|
|
23
|
+
|
|
24
|
+
import { variationKey } from './text/font.js';
|
|
25
|
+
import { builtin } from './builtin.js';
|
|
26
|
+
import GlyphSet from './glyphset.js';
|
|
27
|
+
import GlyphDirectory from './glyphdirectory.js';
|
|
28
|
+
import { GLYPHD, encodeGlyphdRequest, parseGlyphdReply } from './glyphdwire.js';
|
|
29
|
+
|
|
30
|
+
export { GLYPHD, RPC_VERSION, encodeGlyphdRequest, parseGlyphdRequest, encodeGlyphdReply, parseGlyphdReply } from './glyphdwire.js';
|
|
31
|
+
|
|
32
|
+
/** shape pages: one display-wide page for every corner glyph, as today's
|
|
33
|
+
* per-process page is one page for all corners (lib/shapeglyphs.js) */
|
|
34
|
+
export const SHAPE_PAGE_TOKEN = 'ntks1:a8';
|
|
35
|
+
|
|
36
|
+
// How long to wait for the directory's DONE before declaring it dead. The
|
|
37
|
+
// answer is one property read and one write away, so a healthy directory
|
|
38
|
+
// answers in a round trip or two; this is a liveness fence, not a pace.
|
|
39
|
+
const RPC_TIMEOUT = 2000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The token naming one shared font page: content-addressed, because the
|
|
43
|
+
* generator's input is a font file far too big to name literally.
|
|
44
|
+
*
|
|
45
|
+
* ntkg1:<sha1 of font file bytes>:<variation coords>:<px size>:<format>
|
|
46
|
+
*
|
|
47
|
+
* The `ntkg1` version prefix is load-bearing, not hygiene: advances are
|
|
48
|
+
* baked into glyphs at AddGlyphs time and idempotent duplicate uploads
|
|
49
|
+
* assume byte-identical rasterization, both of which hold only among
|
|
50
|
+
* processes running the same rasterizer and rounding. Change either — bump
|
|
51
|
+
* the version, and two ntk versions on one display simply share nothing.
|
|
52
|
+
*
|
|
53
|
+
* The sha1 is computed from the loaded font buffer, lazily on first shared
|
|
54
|
+
* use, and cached on the base face (a variation instance hashes the same
|
|
55
|
+
* file its base was cut from). `null` — no buffer to hash, or no crypto in
|
|
56
|
+
* this environment — means this page stays private, which is the correct
|
|
57
|
+
* degradation everywhere it can happen.
|
|
58
|
+
*
|
|
59
|
+
* @param {Font} font
|
|
60
|
+
* @param {number} size pixel size
|
|
61
|
+
* @returns {string|null}
|
|
62
|
+
*/
|
|
63
|
+
export function fontPageToken(font, size) {
|
|
64
|
+
const base = font.variationOf ?? font;
|
|
65
|
+
if (base._ntkSha1 === undefined) {
|
|
66
|
+
const buffer = base.fk?.stream?.buffer;
|
|
67
|
+
const crypto = buffer ? builtin('node:crypto') : undefined;
|
|
68
|
+
base._ntkSha1 = crypto ? crypto.createHash('sha1').update(buffer).digest('hex') : null;
|
|
69
|
+
}
|
|
70
|
+
if (!base._ntkSha1) return null;
|
|
71
|
+
return `ntkg1:${base._ntkSha1}:${variationKey(font.variationCoords)}:${size}:a8`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// The per-app client
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The shared-glyph client of one connection — `app.sharedGlyphs`, `null`
|
|
80
|
+
* when the feature is off (`NTK_NO_SHARED_GLYPHS`, or
|
|
81
|
+
* `createClient({ sharedGlyphs: false })`).
|
|
82
|
+
*
|
|
83
|
+
* Everything here is driven lazily by the glyph pages: nothing touches the
|
|
84
|
+
* server until the first page binds. Discovery self-elects when no directory
|
|
85
|
+
* owns the selection — the first ntk app on a display becomes the directory,
|
|
86
|
+
* embedded behind this same code path.
|
|
87
|
+
*/
|
|
88
|
+
export class SharedGlyphs {
|
|
89
|
+
constructor(app) {
|
|
90
|
+
this.app = app;
|
|
91
|
+
this.X = app.X;
|
|
92
|
+
/** 'idle' | 'resolving' | 'active' | 'dead' (dead = waiting for MANAGER) */
|
|
93
|
+
this.state = 'idle';
|
|
94
|
+
/** bumps whenever the owner changes or dies; bindings pin the epoch a
|
|
95
|
+
* reply was fenced under, so a stale await cannot adopt across owners */
|
|
96
|
+
this.epoch = 0;
|
|
97
|
+
this.ownerWid = 0;
|
|
98
|
+
this._atoms = null;
|
|
99
|
+
this._mailbox = null;
|
|
100
|
+
this._directory = null; // GlyphDirectory when this app self-elected
|
|
101
|
+
this._resolving = null;
|
|
102
|
+
this._serial = 0;
|
|
103
|
+
this._waiters = new Map(); // serial -> resolve(status)
|
|
104
|
+
this._chain = Promise.resolve(); // RPCs are serialized: one property pair
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Bind one glyph page to the shared cache; see SharedPage. */
|
|
108
|
+
bindPage(adapter) {
|
|
109
|
+
return new SharedPage(this, adapter);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve discovery once: 'idle' -> 'active' | 'dead'. After death only a
|
|
114
|
+
* MANAGER announcement (a new owner) re-activates; pages ask again then.
|
|
115
|
+
* @returns {Promise<boolean>} whether the directory is usable
|
|
116
|
+
*/
|
|
117
|
+
_ready() {
|
|
118
|
+
if (this.state === 'active') return Promise.resolve(true);
|
|
119
|
+
if (this.state === 'dead') return Promise.resolve(false);
|
|
120
|
+
if (!this._resolving) this._resolving = this._resolve();
|
|
121
|
+
return this._resolving;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async _resolve() {
|
|
125
|
+
this.state = 'resolving';
|
|
126
|
+
try {
|
|
127
|
+
const names = [GLYPHD.selection, GLYPHD.ensure, GLYPHD.added, GLYPHD.done, GLYPHD.property, GLYPHD.manager];
|
|
128
|
+
const ids = await Promise.all(names.map((n) => this._atom(n)));
|
|
129
|
+
this._atoms = {
|
|
130
|
+
selection: ids[0],
|
|
131
|
+
ensure: ids[1],
|
|
132
|
+
added: ids[2],
|
|
133
|
+
done: ids[3],
|
|
134
|
+
property: ids[4],
|
|
135
|
+
manager: ids[5]
|
|
136
|
+
};
|
|
137
|
+
// the mailbox: selection-style helper window — request/reply properties
|
|
138
|
+
// live on it and DONE ClientMessages are addressed to it
|
|
139
|
+
this._mailbox = this.app.createWindow({
|
|
140
|
+
width: 1,
|
|
141
|
+
height: 1,
|
|
142
|
+
eventMask: x11.eventMask.PropertyChange
|
|
143
|
+
});
|
|
144
|
+
this._mailbox.on('message', (ev) => this._onDone(ev));
|
|
145
|
+
// handover/new-owner announcements arrive as MANAGER ClientMessages on
|
|
146
|
+
// the root; StructureNotify is the mask ICCCM says they are sent with.
|
|
147
|
+
// A server that refuses the selection just costs us handover detection.
|
|
148
|
+
const root = this.app.rootWindow();
|
|
149
|
+
root.on('message', (ev) => this._onRootMessage(ev));
|
|
150
|
+
await root.selectInput(x11.eventMask.StructureNotify).catch(() => {});
|
|
151
|
+
|
|
152
|
+
let owner = await this._selectionOwner();
|
|
153
|
+
if (!owner) {
|
|
154
|
+
// nobody home: self-elect. claim() resolves the race for us — it
|
|
155
|
+
// returns whoever actually owns the selection afterwards.
|
|
156
|
+
this._directory = new GlyphDirectory(this.app, this.app.options.sharedGlyphs || undefined);
|
|
157
|
+
owner = await this._directory.claim();
|
|
158
|
+
if (owner !== this._directory.wid) this._directory = null;
|
|
159
|
+
}
|
|
160
|
+
// a MANAGER announcement racing this discovery may have adopted a
|
|
161
|
+
// newer owner already — never clobber it with what we found first
|
|
162
|
+
if (this.state === 'resolving') {
|
|
163
|
+
if (!owner) {
|
|
164
|
+
this.state = 'dead';
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
this.ownerWid = owner;
|
|
168
|
+
this.state = 'active';
|
|
169
|
+
}
|
|
170
|
+
return this.state === 'active';
|
|
171
|
+
} catch {
|
|
172
|
+
if (this.state === 'resolving') this.state = 'dead';
|
|
173
|
+
return this.state === 'active';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** the directory stopped answering (or its window is gone): freeze. New
|
|
178
|
+
* pages go private until a MANAGER announcement names a successor. */
|
|
179
|
+
_die() {
|
|
180
|
+
if (this.state === 'dead') return;
|
|
181
|
+
this.state = 'dead';
|
|
182
|
+
this.epoch++;
|
|
183
|
+
for (const resolve of this._waiters.values()) resolve(0);
|
|
184
|
+
this._waiters.clear();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_onRootMessage(ev) {
|
|
188
|
+
if (!this._atoms || ev.message_type !== this._atoms.manager) return;
|
|
189
|
+
if ((ev.data[1] >>> 0) !== this._atoms.selection) return;
|
|
190
|
+
const wid = ev.data[2] >>> 0;
|
|
191
|
+
if (!wid || (this.state === 'active' && wid === this.ownerWid)) return;
|
|
192
|
+
// a new owner (first announcement after a death, or a live handover):
|
|
193
|
+
// adopt it for pages bound from here on; already-bound pages notice the
|
|
194
|
+
// epoch change and freeze instead of mixing generations.
|
|
195
|
+
this.epoch++;
|
|
196
|
+
this.ownerWid = wid;
|
|
197
|
+
this.state = 'active';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
_onDone(ev) {
|
|
201
|
+
if (!this._atoms || ev.message_type !== this._atoms.done) return;
|
|
202
|
+
const resolve = this._waiters.get(ev.data[0]);
|
|
203
|
+
if (!resolve) return;
|
|
204
|
+
this._waiters.delete(ev.data[0]);
|
|
205
|
+
resolve(ev.data[1]);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* One RPC: write the request property on the mailbox, poke the directory,
|
|
210
|
+
* await DONE, read back the reply property (ensure only). Serialized so a
|
|
211
|
+
* single property pair serves every page; batching keeps this rare (one
|
|
212
|
+
* ensure per draw that met new glyphs, mirroring today's one AddGlyphs).
|
|
213
|
+
* Resolves `null` on any failure — a dead directory kills the client, a
|
|
214
|
+
* refusal only this request.
|
|
215
|
+
*/
|
|
216
|
+
_rpc(kindAtom, payload, wantReply) {
|
|
217
|
+
const run = () => this._rpcNow(kindAtom, payload, wantReply);
|
|
218
|
+
const result = this._chain.then(run, run);
|
|
219
|
+
this._chain = result.catch(() => {});
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async _rpcNow(kindAtom, payload, wantReply) {
|
|
224
|
+
if (this.state !== 'active') return null;
|
|
225
|
+
const epoch = this.epoch;
|
|
226
|
+
const a = this._atoms;
|
|
227
|
+
const serial = ++this._serial;
|
|
228
|
+
const X = this.X;
|
|
229
|
+
X.ChangeProperty(0, this._mailbox.id, a.property, a.property, 8, payload);
|
|
230
|
+
const status = await new Promise((resolve) => {
|
|
231
|
+
const timer = setTimeout(() => {
|
|
232
|
+
// no answer: the owner is gone or wedged — same consequence
|
|
233
|
+
this._waiters.delete(serial);
|
|
234
|
+
this._die();
|
|
235
|
+
resolve(0);
|
|
236
|
+
}, RPC_TIMEOUT);
|
|
237
|
+
timer.unref?.();
|
|
238
|
+
this._waiters.set(serial, (st) => {
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
resolve(st);
|
|
241
|
+
});
|
|
242
|
+
// wid = the directory's own window: ClientMessages route to a Window
|
|
243
|
+
// wrapper by their wid field, and the mailbox rides in data[0]
|
|
244
|
+
X.SendClientMessage(this.ownerWid, this.ownerWid, kindAtom, 32, [this._mailbox.id, serial, a.property], 0, (err) => {
|
|
245
|
+
// BadWindow: the owner's window is gone — fail fast, not by timeout
|
|
246
|
+
if (err) this._die();
|
|
247
|
+
return true;
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
if (!status || this.epoch !== epoch) return null;
|
|
251
|
+
if (!wantReply) return { status };
|
|
252
|
+
const prop = await new Promise((resolve) =>
|
|
253
|
+
X.GetProperty(1, this._mailbox.id, a.property, 0, 0, 0x1fffffff, (err, res) => resolve(err ? null : res))
|
|
254
|
+
);
|
|
255
|
+
if (!prop || !prop.data || this.epoch !== epoch) return null;
|
|
256
|
+
const reply = parseGlyphdReply(prop.data);
|
|
257
|
+
return reply && reply.serial === serial ? reply : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
_ensure(token, indices, keys) {
|
|
261
|
+
return this._rpc(this._atoms.ensure, encodeGlyphdRequest({ token, indices, keys }), true);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
_added(token, indices, keys, bytes) {
|
|
265
|
+
return this._rpc(this._atoms.added, encodeGlyphdRequest({ token, indices, keys, bytes }), false);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
_selectionOwner() {
|
|
269
|
+
return new Promise((resolve) =>
|
|
270
|
+
this.X.GetSelectionOwner(this._atoms.selection, (err, wid) => resolve(err ? 0 : wid))
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
_atom(name) {
|
|
275
|
+
return new Promise((resolve, reject) =>
|
|
276
|
+
this.X.InternAtom(false, name, (err, atom) => (err ? reject(err) : resolve(atom)))
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* One page's shared side, driven by `GlyphPage`/`ShapeGlyphPage` through an
|
|
283
|
+
* adapter:
|
|
284
|
+
*
|
|
285
|
+
* token the page's name on the wire (fontPageToken / SHAPE_PAGE_TOKEN)
|
|
286
|
+
* indices member keys are u32 font glyph indices rather than strings
|
|
287
|
+
* makeGlyph(key, payload, lid) a fresh AddGlyphs object for a glyph the
|
|
288
|
+
* directory reported absent (node-x11 mutates its input, so
|
|
289
|
+
* fresh per call); may rasterize when the payload carries no
|
|
290
|
+
* bitmap (the warm path)
|
|
291
|
+
* adopt(key, lid, gsid, payload) bind the page entry to the shared set —
|
|
292
|
+
* called only for ids confirmed present or just uploaded by us
|
|
293
|
+
*
|
|
294
|
+
* A binding binds to exactly one (owner epoch, gsid, generation). Any
|
|
295
|
+
* disagreement afterwards — new generation, new owner, a failed RPC — closes
|
|
296
|
+
* it: entries already adopted keep compositing from the alias (frozen), new
|
|
297
|
+
* glyphs stay on the page's private path, and a fresh page binds fresh.
|
|
298
|
+
*/
|
|
299
|
+
export class SharedPage {
|
|
300
|
+
constructor(client, adapter) {
|
|
301
|
+
this.client = client;
|
|
302
|
+
this.adapter = adapter;
|
|
303
|
+
this.open = true;
|
|
304
|
+
this.bound = false;
|
|
305
|
+
this.alias = null; // GlyphSet referencing the directory's set
|
|
306
|
+
this.gsid = 0;
|
|
307
|
+
this.generation = 0;
|
|
308
|
+
this.pending = new Map(); // key -> payload, in flight
|
|
309
|
+
this._chain = Promise.resolve(); // asks are serialized per page
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Ask the directory about keys this page has not confirmed yet.
|
|
314
|
+
* `items` is [{ key, payload }]; keys already in flight are skipped.
|
|
315
|
+
* Never rejects; resolves once the batch is adopted, uploaded, or dropped.
|
|
316
|
+
*/
|
|
317
|
+
ask(items) {
|
|
318
|
+
if (!this.open) return Promise.resolve();
|
|
319
|
+
const keys = [];
|
|
320
|
+
for (const { key, payload } of items) {
|
|
321
|
+
if (this.pending.has(key)) continue;
|
|
322
|
+
this.pending.set(key, payload);
|
|
323
|
+
keys.push(key);
|
|
324
|
+
}
|
|
325
|
+
if (keys.length === 0) return this._chain;
|
|
326
|
+
const run = () => this._ask(keys).catch(() => this._drop(keys));
|
|
327
|
+
this._chain = this._chain.then(run, run);
|
|
328
|
+
return this._chain;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async _ask(keys) {
|
|
332
|
+
if (!(await this.client._ready()) || !this.open) return this._drop(keys);
|
|
333
|
+
const epoch = this.client.epoch;
|
|
334
|
+
const reply = await this.client._ensure(this.adapter.token, this.adapter.indices, keys);
|
|
335
|
+
if (!this.open) return this._drop(keys);
|
|
336
|
+
if (!reply || reply.entries.length !== keys.length) {
|
|
337
|
+
this._drop(keys);
|
|
338
|
+
this._close();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (!this.bound) {
|
|
342
|
+
// The XID-reuse fence. The reply's gsid must not be referenced into a
|
|
343
|
+
// void: the directory could be gone and the id recycled. Reference and
|
|
344
|
+
// GetSelectionOwner go out back to back; replies are processed in
|
|
345
|
+
// order, and the directory's contract is that it frees nothing while
|
|
346
|
+
// it owns the selection — so if the owner still is the window that
|
|
347
|
+
// advertised this gsid *after* the reference executed, the reference
|
|
348
|
+
// bound the intended set. Anything else: drop the alias and close (a
|
|
349
|
+
// reference that raced the death itself fails with a GLYPHSET error —
|
|
350
|
+
// an error, never wrong pixels — surfacing as one benign warning).
|
|
351
|
+
const alias = GlyphSet.referenceTo(this.client.app, reply.gsid);
|
|
352
|
+
const owner = await this.client._selectionOwner();
|
|
353
|
+
if (owner !== this.client.ownerWid || this.client.epoch !== epoch || !this.open) {
|
|
354
|
+
alias.destroy();
|
|
355
|
+
this._drop(keys);
|
|
356
|
+
this._close();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
this.alias = alias;
|
|
360
|
+
this.gsid = reply.gsid;
|
|
361
|
+
this.generation = reply.generation;
|
|
362
|
+
this.bound = true;
|
|
363
|
+
} else if (reply.gsid !== this.gsid || reply.generation !== this.generation || this.client.epoch !== epoch) {
|
|
364
|
+
// the directory opened a new generation (or changed hands): this page
|
|
365
|
+
// freezes on the set it has; a future page binds to the new one
|
|
366
|
+
this._drop(keys);
|
|
367
|
+
this._close();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const uploads = [];
|
|
371
|
+
const uploadedKeys = [];
|
|
372
|
+
let bytes = 0;
|
|
373
|
+
for (let i = 0; i < keys.length; i++) {
|
|
374
|
+
const key = keys[i];
|
|
375
|
+
const payload = this.pending.get(key);
|
|
376
|
+
this.pending.delete(key);
|
|
377
|
+
if (payload === undefined) continue;
|
|
378
|
+
const { lid, present } = reply.entries[i];
|
|
379
|
+
if (!present) {
|
|
380
|
+
// ours to upload: the directory told everyone racing on this glyph
|
|
381
|
+
// the same, and duplicate uploads are idempotent (identical bytes,
|
|
382
|
+
// AddGlyphs replaces by id, Xorg dedupes storage by content hash)
|
|
383
|
+
const glyph = this.adapter.makeGlyph(key, payload, lid);
|
|
384
|
+
if (!glyph) continue; // cannot produce the bitmap: leave it private
|
|
385
|
+
uploads.push(glyph);
|
|
386
|
+
uploadedKeys.push(key);
|
|
387
|
+
bytes += glyph.image.length;
|
|
388
|
+
}
|
|
389
|
+
this.adapter.adopt(key, lid, this.alias.id, payload);
|
|
390
|
+
}
|
|
391
|
+
if (uploads.length) {
|
|
392
|
+
// upload through our own alias, then flip the directory's presence
|
|
393
|
+
// bits. Our own composites are ordered after the upload on this
|
|
394
|
+
// connection, so adopting before the AddGlyphs flushes is safe.
|
|
395
|
+
this.alias.addGlyphs(uploads);
|
|
396
|
+
await this.client._added(this.adapter.token, this.adapter.indices, uploadedKeys, bytes);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
_drop(keys) {
|
|
401
|
+
for (const key of keys) this.pending.delete(key);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
_close() {
|
|
405
|
+
this.open = false;
|
|
406
|
+
this.pending.clear();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** the page is going away: drop the alias (a server-side refcount) */
|
|
410
|
+
destroy() {
|
|
411
|
+
this._close();
|
|
412
|
+
if (this.alias) {
|
|
413
|
+
this.alias.destroy();
|
|
414
|
+
this.alias = null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The app's shared-glyph client, or `null` when the feature is off. Decided
|
|
421
|
+
* once per app: `createClient({ sharedGlyphs: false })`, or the
|
|
422
|
+
* `NTK_NO_SHARED_GLYPHS` environment kill switch. Off means not a byte of
|
|
423
|
+
* this machinery runs — pages behave exactly as they did before it existed.
|
|
424
|
+
*/
|
|
425
|
+
export function sharedGlyphsFor(app) {
|
|
426
|
+
if (app._sharedGlyphs === undefined) {
|
|
427
|
+
const disabled =
|
|
428
|
+
app.options.sharedGlyphs === false ||
|
|
429
|
+
(typeof process !== 'undefined' && process.env && process.env.NTK_NO_SHARED_GLYPHS);
|
|
430
|
+
app._sharedGlyphs = disabled ? null : new SharedGlyphs(app);
|
|
431
|
+
}
|
|
432
|
+
return app._sharedGlyphs;
|
|
433
|
+
}
|