node-gtk 2.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -161
- package/bin/node-gtk.js +12 -1
- package/lib/esm/hooks.mjs +49 -0
- package/lib/esm/register.mjs +17 -0
- package/lib/index.js +1 -2
- package/lib/index.mjs +25 -0
- package/lib/inspect.js +1 -1
- package/lib/loop.js +5 -0
- package/lib/module.js +8 -2
- package/lib/register-class.js +86 -3
- package/lib/styles.d.ts +81 -0
- package/lib/styles.js +428 -0
- package/package.json +15 -1
- package/src/closure.cc +19 -6
- package/src/closure.h +8 -4
- package/src/function.cc +47 -0
- package/src/gi.cc +10 -0
- package/src/gobject.cc +170 -3
- package/src/gobject.h +3 -0
- package/tools/README.md +52 -2
- package/tools/create-app.js +246 -0
- package/tools/generate-types.js +80 -3
- package/tools/list-libraries.js +125 -0
- package/tools/templates/app/README.md.tmpl +97 -0
- package/tools/templates/app/gitignore.tmpl +10 -0
- package/tools/templates/app/package.json.tmpl +26 -0
- package/tools/templates/app/src/main.ts.tmpl +110 -0
- package/tools/templates/app/src/welcome.ts.tmpl +41 -0
- package/tools/templates/app/style.css.tmpl +19 -0
- package/tools/templates/app/tsconfig.json.tmpl +19 -0
- package/lib/binding/node-v127-linux-x64/node_gtk.node +0 -0
- /package/{COPYING → LICENSE} +0 -0
package/lib/styles.js
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* styles.js — a small StyleManager for node-gtk apps. Applies CSS (inline via
|
|
3
|
+
* styles.add, a `.css` file via styles.addFile) and hot-reloads it in
|
|
4
|
+
* development. Adapted from zym's style-manager; see doc/styles.md.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const Path = require('node:path')
|
|
8
|
+
const { pathToFileURL, fileURLToPath } = require('node:url')
|
|
9
|
+
|
|
10
|
+
const internal = require('./native.js')
|
|
11
|
+
const Module = require('./module.js')
|
|
12
|
+
|
|
13
|
+
// Lazily resolved: the app loads Gtk/Gdk/GLib/Gio, not this module. GLib/Gio
|
|
14
|
+
// drive hot-reload (watchDir) and arrive with Gtk.
|
|
15
|
+
const moduleCache = internal.GetModuleCache()
|
|
16
|
+
const gtk = () => Module.require('Gtk')
|
|
17
|
+
const gdk = () => Module.require('Gdk')
|
|
18
|
+
const glib = () => Module.require('GLib')
|
|
19
|
+
const gio = () => Module.require('Gio')
|
|
20
|
+
|
|
21
|
+
// Dev-only; opt out with a falsy NODE_GTK_STYLE_HOT_RELOAD (0/false/no/off).
|
|
22
|
+
const HOT_RELOAD =
|
|
23
|
+
process.env.NODE_ENV === 'development' &&
|
|
24
|
+
!/^(0|false|no|off)$/i.test(process.env.NODE_GTK_STYLE_HOT_RELOAD ?? '')
|
|
25
|
+
|
|
26
|
+
const OWN_FILE = __filename // skipped during caller detection
|
|
27
|
+
|
|
28
|
+
const DEBOUNCE_MS = 40 // coalesce the burst an editor's atomic save emits
|
|
29
|
+
|
|
30
|
+
function defaultPriority() {
|
|
31
|
+
return gtk().STYLE_PROVIDER_PRIORITY_APPLICATION ?? 600
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The default display, or null before it exists (e.g. before app activation). */
|
|
35
|
+
function defaultDisplay() {
|
|
36
|
+
if (!moduleCache['Gdk']) return null // don't force-load Gdk just to ask
|
|
37
|
+
return gdk().Display.getDefault()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Normalise a path / `file:` URL string / URL to an absolute path. */
|
|
41
|
+
function toPath(p) {
|
|
42
|
+
if (p instanceof URL) return fileURLToPath(p)
|
|
43
|
+
if (typeof p === 'string' && p.startsWith('file:')) return fileURLToPath(p)
|
|
44
|
+
return Path.resolve(p)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function loadCss(provider, css) {
|
|
48
|
+
// loadFromString is GTK 4.12+; fall back on older 4.x.
|
|
49
|
+
if (typeof provider.loadFromString === 'function') provider.loadFromString(css)
|
|
50
|
+
else provider.loadFromData(css)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Local path of a GFile from a monitor event, or null. GFile is a GInterface,
|
|
54
|
+
* so its methods live on the prototype, not the instance. */
|
|
55
|
+
function gioPath(file) {
|
|
56
|
+
if (!file) return null
|
|
57
|
+
try { return gio().File.prototype.getPath.call(file) } catch { return null }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Absolute path of the first source file above this module on the stack. */
|
|
61
|
+
function callerFile() {
|
|
62
|
+
const stack = new Error().stack
|
|
63
|
+
if (!stack) return null
|
|
64
|
+
for (const line of stack.split('\n').slice(1)) {
|
|
65
|
+
const file = frameFile(line)
|
|
66
|
+
if (file && file !== OWN_FILE) return file
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Pull the absolute file path out of one V8 stack frame, or null. */
|
|
72
|
+
function frameFile(frame) {
|
|
73
|
+
// Matches "(file:line:col)", bare "at file:line:col", and the "at async
|
|
74
|
+
// file:..." form V8 uses for top-level ESM code.
|
|
75
|
+
const m = frame.match(/\(([^()]+):\d+:\d+\)\s*$/) || frame.match(/\bat\s+(?:async\s+)?([^()\s]+):\d+:\d+\s*$/)
|
|
76
|
+
if (!m) return null
|
|
77
|
+
let loc = m[1]
|
|
78
|
+
const q = loc.indexOf('?') // drop the cache-buster query, if any
|
|
79
|
+
if (q !== -1) loc = loc.slice(0, q)
|
|
80
|
+
if (loc.startsWith('file://')) {
|
|
81
|
+
try { return Path.resolve(fileURLToPath(loc)) } catch { return null }
|
|
82
|
+
}
|
|
83
|
+
if (loc.startsWith('node:') || loc.includes('node_modules')) return null
|
|
84
|
+
return Path.resolve(loc)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A handle to an installed (or queued) stylesheet.
|
|
89
|
+
* @typedef {object} StyleSheet
|
|
90
|
+
* @property {(next: string) => void} update Replace with fixed CSS (drops any render fn).
|
|
91
|
+
* @property {() => void} refresh Re-apply from source (re-run render / re-read file).
|
|
92
|
+
* @property {() => void} remove Remove the sheet from the display.
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
class StyleManager {
|
|
96
|
+
constructor() {
|
|
97
|
+
this.ready = false
|
|
98
|
+
this.queued = [] // entries awaiting the display
|
|
99
|
+
|
|
100
|
+
// File sheets by path, for per-path dedup and reload. path -> Set<entry>.
|
|
101
|
+
this.cssEntries = new Map()
|
|
102
|
+
|
|
103
|
+
// ---- Hot-reload state (only used when HOT_RELOAD is on) ----
|
|
104
|
+
this.fileProviders = new Map() // srcFile -> Set<provider>, so a reload drops the old
|
|
105
|
+
this.watchedFiles = new Set()
|
|
106
|
+
this.dirWatchers = new Map() // dir -> Gio.FileMonitor
|
|
107
|
+
this.reloadSeq = 0
|
|
108
|
+
this.reloadTimers = new Map()
|
|
109
|
+
this.reloading = new Set()
|
|
110
|
+
this.reloadPending = new Set()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Inline CSS, hot-reloaded by re-importing its source module. Queued until the
|
|
115
|
+
* display exists.
|
|
116
|
+
*
|
|
117
|
+
* `css` may be a `() => string` *render* function instead of a string, for
|
|
118
|
+
* dynamic stylesheets built from live state (theme, fonts, …). The render runs
|
|
119
|
+
* now and again on every hot-reload of its module (so editing the
|
|
120
|
+
* CSS-generating code re-applies it), and on demand via the handle's
|
|
121
|
+
* `refresh()` — call it whenever the state it reads changes. Keep such a module
|
|
122
|
+
* side-effect-free at its top level (a re-import re-runs it); a stateful
|
|
123
|
+
* singleton it owns must survive re-import (e.g. guarded on `globalThis`).
|
|
124
|
+
*
|
|
125
|
+
* Pass `watch: false` to install without watching the caller for hot-reload —
|
|
126
|
+
* for programmatic sheets whose source can't be re-imported safely (created
|
|
127
|
+
* inside a method, owned by a stateful module). The handle still supports
|
|
128
|
+
* `update`/`refresh`/`remove`.
|
|
129
|
+
* @param {string | (() => string)} css Inline CSS, or a render function.
|
|
130
|
+
* @param {{ priority?: number, watch?: boolean }} [options]
|
|
131
|
+
* @returns {StyleSheet}
|
|
132
|
+
*/
|
|
133
|
+
add(css, options = {}) {
|
|
134
|
+
const render = typeof css === 'function' ? css : null
|
|
135
|
+
const watch = HOT_RELOAD && (options.watch ?? true)
|
|
136
|
+
const file = watch ? callerFile() : null
|
|
137
|
+
const entry = {
|
|
138
|
+
kind: 'inline',
|
|
139
|
+
render,
|
|
140
|
+
css: render ? null : css,
|
|
141
|
+
file,
|
|
142
|
+
priority: options.priority,
|
|
143
|
+
provider: null,
|
|
144
|
+
cancelled: false,
|
|
145
|
+
}
|
|
146
|
+
this.place(entry)
|
|
147
|
+
if (file) this.watch(file)
|
|
148
|
+
return this.handle(entry)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A `.css` file, hot-reloaded by re-reading it (unless `watch` is false).
|
|
153
|
+
* Idempotent per path.
|
|
154
|
+
* @param {string|URL} path A path, a `file://` URL string, or a URL.
|
|
155
|
+
* @param {{ priority?: number, watch?: boolean }} [options]
|
|
156
|
+
* @returns {StyleSheet}
|
|
157
|
+
*/
|
|
158
|
+
addFile(path, options = {}) {
|
|
159
|
+
const file = toPath(path)
|
|
160
|
+
const existing = this.cssEntries.get(file)
|
|
161
|
+
if (existing && existing.size) {
|
|
162
|
+
// Refresh the existing sheet rather than stacking a second provider.
|
|
163
|
+
const entry = existing.values().next().value
|
|
164
|
+
if (entry.provider) entry.provider.loadFromPath(file)
|
|
165
|
+
return this.handle(entry)
|
|
166
|
+
}
|
|
167
|
+
const watch = options.watch ?? HOT_RELOAD
|
|
168
|
+
const entry = { kind: 'file', path: file, priority: options.priority, provider: null, cancelled: false, watch }
|
|
169
|
+
this.trackCssEntry(entry) // register by path now, so re-adds dedup even in production
|
|
170
|
+
this.place(entry)
|
|
171
|
+
if (watch) this.watch(file)
|
|
172
|
+
return this.handle(entry)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Install everything queued before the display existed, and start watching.
|
|
177
|
+
* Call once from your `activate` handler. Safe to call more than once.
|
|
178
|
+
*/
|
|
179
|
+
install() {
|
|
180
|
+
if (this.ready) return
|
|
181
|
+
// Before the display exists, providers would never be shown — fail loudly
|
|
182
|
+
// rather than silently drop them. (place()'s auto-flush only runs once a
|
|
183
|
+
// display exists, so it never trips this.)
|
|
184
|
+
if (!defaultDisplay())
|
|
185
|
+
throw new Error('styles.install() called before the display exists — call it from your app\'s "activate" handler')
|
|
186
|
+
this.ready = true
|
|
187
|
+
const pending = this.queued
|
|
188
|
+
this.queued = []
|
|
189
|
+
for (const entry of pending) this.installEntry(entry)
|
|
190
|
+
if (HOT_RELOAD) this.startWatcher()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// -------------------------------------------------------------------------
|
|
194
|
+
// installation
|
|
195
|
+
// -------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
place(entry) {
|
|
198
|
+
if (!this.ready && defaultDisplay()) this.install() // auto-flush on first post-display call
|
|
199
|
+
if (this.ready) this.installEntry(entry)
|
|
200
|
+
else this.queued.push(entry)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
installEntry(entry) {
|
|
204
|
+
if (entry.cancelled) return
|
|
205
|
+
if (entry.kind === 'file') {
|
|
206
|
+
const provider = this.newProvider()
|
|
207
|
+
provider.loadFromPath(entry.path)
|
|
208
|
+
this.addProvider(provider, entry.priority)
|
|
209
|
+
entry.provider = provider
|
|
210
|
+
} else {
|
|
211
|
+
const provider = this.newProvider()
|
|
212
|
+
loadCss(provider, entry.render ? entry.render() : entry.css)
|
|
213
|
+
this.addProvider(provider, entry.priority)
|
|
214
|
+
entry.provider = provider
|
|
215
|
+
if (entry.file) this.trackFileProvider(entry.file, provider)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
newProvider() {
|
|
220
|
+
const provider = new (gtk().CssProvider)()
|
|
221
|
+
// Surface parse errors with a friendly line instead of a bare GTK critical.
|
|
222
|
+
try {
|
|
223
|
+
provider.on('parsing-error', (section, error) => {
|
|
224
|
+
let message = 'CSS parse error'
|
|
225
|
+
try { if (error && error.message) message += `: ${error.message}` } catch {}
|
|
226
|
+
console.warn(`[node-gtk:styles] ${message}`)
|
|
227
|
+
})
|
|
228
|
+
} catch {}
|
|
229
|
+
return provider
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
addProvider(provider, priority) {
|
|
233
|
+
const display = defaultDisplay()
|
|
234
|
+
if (display) gtk().StyleContext.addProviderForDisplay(display, provider, priority ?? defaultPriority())
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
removeProvider(provider) {
|
|
238
|
+
const display = defaultDisplay()
|
|
239
|
+
if (display) gtk().StyleContext.removeProviderForDisplay(display, provider)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// -------------------------------------------------------------------------
|
|
243
|
+
// handles
|
|
244
|
+
// -------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
handle(entry) {
|
|
247
|
+
return {
|
|
248
|
+
update: (next) => {
|
|
249
|
+
if (entry.kind === 'file') {
|
|
250
|
+
const nextPath = toPath(next)
|
|
251
|
+
if (nextPath !== entry.path) {
|
|
252
|
+
// Re-key tracking + watching to the new path.
|
|
253
|
+
this.untrackCssEntry(entry)
|
|
254
|
+
entry.path = nextPath
|
|
255
|
+
this.trackCssEntry(entry)
|
|
256
|
+
if (entry.watch) this.watch(nextPath)
|
|
257
|
+
}
|
|
258
|
+
if (entry.provider) entry.provider.loadFromPath(entry.path)
|
|
259
|
+
} else {
|
|
260
|
+
// A literal replaces a render function: from now on this is fixed CSS.
|
|
261
|
+
entry.render = null
|
|
262
|
+
entry.css = next
|
|
263
|
+
if (entry.provider) loadCss(entry.provider, next)
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
// Re-apply the sheet from its current source: re-run the render function
|
|
267
|
+
// (inline) or re-read the path (file). For a fixed-string inline sheet this
|
|
268
|
+
// just re-loads the same CSS. Use it when the state a render reads changes.
|
|
269
|
+
refresh: () => {
|
|
270
|
+
if (!entry.provider) return
|
|
271
|
+
if (entry.kind === 'file') entry.provider.loadFromPath(entry.path)
|
|
272
|
+
else loadCss(entry.provider, entry.render ? entry.render() : entry.css)
|
|
273
|
+
},
|
|
274
|
+
remove: () => {
|
|
275
|
+
if (entry.cancelled) return
|
|
276
|
+
entry.cancelled = true
|
|
277
|
+
if (entry.provider) this.removeProvider(entry.provider)
|
|
278
|
+
if (entry.kind === 'file') this.untrackCssEntry(entry)
|
|
279
|
+
else this.untrackFileProvider(entry)
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// -------------------------------------------------------------------------
|
|
285
|
+
// hot-reload (dev only)
|
|
286
|
+
// -------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
trackFileProvider(file, provider) {
|
|
289
|
+
let set = this.fileProviders.get(file)
|
|
290
|
+
if (!set) this.fileProviders.set(file, (set = new Set()))
|
|
291
|
+
set.add(provider)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
trackCssEntry(entry) {
|
|
295
|
+
let set = this.cssEntries.get(entry.path)
|
|
296
|
+
if (!set) this.cssEntries.set(entry.path, (set = new Set()))
|
|
297
|
+
set.add(entry)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// When the last sheet for a path is removed, stop watching it — otherwise a
|
|
301
|
+
// later edit would be misrouted through reloadModule and import() the .css.
|
|
302
|
+
untrackCssEntry(entry) {
|
|
303
|
+
const set = this.cssEntries.get(entry.path)
|
|
304
|
+
if (!set) return
|
|
305
|
+
set.delete(entry)
|
|
306
|
+
if (set.size === 0) {
|
|
307
|
+
this.cssEntries.delete(entry.path)
|
|
308
|
+
this.unwatch(entry.path)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Drop the provider so the next module reload doesn't re-remove it. Keep
|
|
313
|
+
// watching: re-running the module re-installs its add() calls.
|
|
314
|
+
untrackFileProvider(entry) {
|
|
315
|
+
if (!entry.file || !entry.provider) return
|
|
316
|
+
this.fileProviders.get(entry.file)?.delete(entry.provider)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
watch(file) {
|
|
320
|
+
if (this.watchedFiles.has(file)) return
|
|
321
|
+
this.watchedFiles.add(file)
|
|
322
|
+
if (this.ready) this.watchDir(Path.dirname(file)) // else startWatcher picks it up
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Stop watching a file; cancel the directory monitor once its last file goes.
|
|
326
|
+
unwatch(file) {
|
|
327
|
+
if (!this.watchedFiles.delete(file)) return
|
|
328
|
+
const timer = this.reloadTimers.get(file)
|
|
329
|
+
if (timer) { glib().sourceRemove(timer); this.reloadTimers.delete(file) }
|
|
330
|
+
const dir = Path.dirname(file)
|
|
331
|
+
for (const f of this.watchedFiles) if (Path.dirname(f) === dir) return
|
|
332
|
+
const monitor = this.dirWatchers.get(dir)
|
|
333
|
+
if (monitor) { try { monitor.cancel() } catch {} ; this.dirWatchers.delete(dir) }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
startWatcher() {
|
|
337
|
+
for (const file of this.watchedFiles) this.watchDir(Path.dirname(file))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Watch with a GLib GFileMonitor, not node's fs.watch: it is driven by the
|
|
341
|
+
// GLib loop the app already runs (an fs.watch, a libuv handle, silently stops
|
|
342
|
+
// firing once that loop is the only one running) and it does not keep Node
|
|
343
|
+
// alive on exit. We watch the directory, not the file, so atomic-save renames
|
|
344
|
+
// survive — WATCH_MOVES surfaces them.
|
|
345
|
+
watchDir(dir) {
|
|
346
|
+
if (this.dirWatchers.has(dir)) return
|
|
347
|
+
let monitor
|
|
348
|
+
try {
|
|
349
|
+
const Gio = gio()
|
|
350
|
+
const gfile = Gio.File.newForPath(dir)
|
|
351
|
+
// GFile is a GInterface: its methods live on the prototype, not the instance.
|
|
352
|
+
monitor = Gio.File.prototype.monitorDirectory.call(gfile, Gio.FileMonitorFlags.WATCH_MOVES, null)
|
|
353
|
+
} catch {
|
|
354
|
+
return // Gio unavailable / monitor failed — hot-reload just won't fire
|
|
355
|
+
}
|
|
356
|
+
monitor.on('changed', (file, otherFile) => {
|
|
357
|
+
// Map the event back to a watched file (a rename's target is in otherFile);
|
|
358
|
+
// if neither matches, re-check the dir's files — the debounce coalesces.
|
|
359
|
+
const a = gioPath(file)
|
|
360
|
+
const b = gioPath(otherFile)
|
|
361
|
+
if (a && this.watchedFiles.has(a)) this.onFileChanged(a)
|
|
362
|
+
else if (b && this.watchedFiles.has(b)) this.onFileChanged(b)
|
|
363
|
+
else for (const f of this.watchedFiles) if (Path.dirname(f) === dir) this.onFileChanged(f)
|
|
364
|
+
})
|
|
365
|
+
this.dirWatchers.set(dir, monitor)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// GLib timeout, not setTimeout, so the debounce is loop-driven too.
|
|
369
|
+
onFileChanged(file) {
|
|
370
|
+
const pending = this.reloadTimers.get(file)
|
|
371
|
+
if (pending) glib().sourceRemove(pending)
|
|
372
|
+
this.reloadTimers.set(file, glib().timeoutAdd(0, DEBOUNCE_MS, () => {
|
|
373
|
+
this.reloadTimers.delete(file)
|
|
374
|
+
void this.reloadFile(file)
|
|
375
|
+
return false // GLib.SOURCE_REMOVE
|
|
376
|
+
}))
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async reloadFile(file) {
|
|
380
|
+
// Dispatch by which registry the file is in; one in neither (e.g. its sheets
|
|
381
|
+
// were removed) is ignored, so a stray .css edit is never import()ed.
|
|
382
|
+
const cssEntries = this.cssEntries.get(file)
|
|
383
|
+
if (cssEntries && cssEntries.size) {
|
|
384
|
+
for (const entry of cssEntries) {
|
|
385
|
+
if (entry.provider) {
|
|
386
|
+
try { entry.provider.loadFromPath(file) } catch {}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
console.info(`[node-gtk:styles] reloaded ${Path.relative(process.cwd(), file)}`)
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
if (this.fileProviders.has(file)) await this.reloadModule(file)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Re-import a source module (cache-busted) so its add() calls reinstall the
|
|
397
|
+
* new CSS, then drop the previous run's providers — new sheets up before old
|
|
398
|
+
* come down. A load error rolls back to the previously working sheets.
|
|
399
|
+
*/
|
|
400
|
+
async reloadModule(file) {
|
|
401
|
+
if (this.reloading.has(file)) { this.reloadPending.add(file); return }
|
|
402
|
+
this.reloading.add(file)
|
|
403
|
+
|
|
404
|
+
const previous = this.fileProviders.get(file) ?? new Set()
|
|
405
|
+
const fresh = new Set()
|
|
406
|
+
this.fileProviders.set(file, fresh) // the re-run's tracking collects into here
|
|
407
|
+
try {
|
|
408
|
+
await import(`${pathToFileURL(file).href}?node-gtk-style=${++this.reloadSeq}`)
|
|
409
|
+
for (const provider of previous) this.removeProvider(provider)
|
|
410
|
+
console.info(`[node-gtk:styles] reloaded ${Path.relative(process.cwd(), file)}`)
|
|
411
|
+
} catch (error) {
|
|
412
|
+
for (const provider of fresh) this.removeProvider(provider)
|
|
413
|
+
this.fileProviders.set(file, previous) // keep the working sheets installed
|
|
414
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
415
|
+
console.warn(`[node-gtk:styles] hot-reload failed for ${Path.relative(process.cwd(), file)}: ${message}`)
|
|
416
|
+
} finally {
|
|
417
|
+
this.reloading.delete(file)
|
|
418
|
+
if (this.reloadPending.delete(file)) void this.reloadModule(file)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const styles = new StyleManager()
|
|
424
|
+
|
|
425
|
+
module.exports = {
|
|
426
|
+
StyleManager,
|
|
427
|
+
styles,
|
|
428
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-gtk",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "GNOME Gtk+ bindings for NodeJS",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./lib/index.mjs",
|
|
9
|
+
"require": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./register": "./lib/esm/register.mjs",
|
|
12
|
+
"./hooks": "./lib/esm/hooks.mjs",
|
|
13
|
+
"./styles": {
|
|
14
|
+
"types": "./lib/styles.d.ts",
|
|
15
|
+
"default": "./lib/styles.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json",
|
|
18
|
+
"./*": "./*"
|
|
19
|
+
},
|
|
6
20
|
"bin": {
|
|
7
21
|
"node-gtk": "bin/node-gtk.js"
|
|
8
22
|
},
|
package/src/closure.cc
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
#include "closure.h"
|
|
5
5
|
#include "error.h"
|
|
6
|
+
#include "gobject.h"
|
|
6
7
|
#include "macros.h"
|
|
7
8
|
#include "loop.h"
|
|
8
9
|
#include "type.h"
|
|
@@ -54,9 +55,9 @@ static void LoadGIArgumentFromPointer (GITypeInfo *type_info, GIArgument *arg) {
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
GClosure *Closure::New (
|
|
58
|
+
GClosure *Closure::New (guint handlerIndex, GICallableInfo* info, guint signalId) {
|
|
58
59
|
Closure *closure = (Closure *) g_closure_new_simple (sizeof (*closure), GUINT_TO_POINTER(signalId));
|
|
59
|
-
closure->
|
|
60
|
+
closure->handlerIndex = handlerIndex;
|
|
60
61
|
if (info) {
|
|
61
62
|
closure->info = g_base_info_ref(info);
|
|
62
63
|
} else {
|
|
@@ -69,11 +70,18 @@ GClosure *Closure::New (Local<Function> function, GICallableInfo* info, guint si
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
void Closure::Execute(GICallableInfo *info, guint signal_id,
|
|
72
|
-
|
|
73
|
+
GObject *instance, guint handlerIndex,
|
|
73
74
|
GValue *g_return_value, guint n_param_values,
|
|
74
75
|
const GValue *param_values) {
|
|
75
76
|
Nan::HandleScope scope;
|
|
76
|
-
|
|
77
|
+
|
|
78
|
+
/* The handler lives in a JS array on the instance's wrapper (#375). If the
|
|
79
|
+
* wrapper has been collected (the object was dropped from JS) there is
|
|
80
|
+
* nothing to call. */
|
|
81
|
+
Local<Value> handlerValue = GetSignalHandler(instance, handlerIndex);
|
|
82
|
+
if (handlerValue.IsEmpty() || !handlerValue->IsFunction())
|
|
83
|
+
return;
|
|
84
|
+
auto func = handlerValue.As<Function>();
|
|
77
85
|
|
|
78
86
|
GSignalQuery signal_query = { 0, };
|
|
79
87
|
|
|
@@ -242,16 +250,21 @@ void Closure::Marshal(GClosure *base,
|
|
|
242
250
|
|
|
243
251
|
auto closure = (Closure *) base;
|
|
244
252
|
auto signal_id = GPOINTER_TO_UINT(marshal_data);
|
|
253
|
+
auto handlerIndex = closure->handlerIndex;
|
|
254
|
+
|
|
255
|
+
/* param_values[0] is always the instance the signal was emitted on, which
|
|
256
|
+
* is the object whose wrapper holds this handler (#375). */
|
|
257
|
+
GObject *instance = (GObject *) g_value_get_object(¶m_values[0]);
|
|
245
258
|
|
|
246
259
|
AsyncCallEnvironment* env = reinterpret_cast<AsyncCallEnvironment *>(AsyncCallEnvironment::asyncHandle.data);
|
|
247
260
|
|
|
248
261
|
if (env->IsSameThread()) {
|
|
249
262
|
/* Case 1: same thread */
|
|
250
|
-
Closure::Execute(closure->info, signal_id,
|
|
263
|
+
Closure::Execute(closure->info, signal_id, instance, handlerIndex, g_return_value, n_param_values, param_values);
|
|
251
264
|
} else {
|
|
252
265
|
/* Case 2: different thread */
|
|
253
266
|
env->Call([&]() {
|
|
254
|
-
Closure::Execute(closure->info, signal_id,
|
|
267
|
+
Closure::Execute(closure->info, signal_id, instance, handlerIndex, g_return_value, n_param_values, param_values);
|
|
255
268
|
});
|
|
256
269
|
}
|
|
257
270
|
}
|
package/src/closure.h
CHANGED
|
@@ -16,20 +16,24 @@ namespace GNodeJS {
|
|
|
16
16
|
|
|
17
17
|
struct Closure {
|
|
18
18
|
GClosure base;
|
|
19
|
-
|
|
19
|
+
/* The handler function is NOT held here. It lives in a JS array on the
|
|
20
|
+
* wrapper object (reachable only through the wrapper), and we keep just its
|
|
21
|
+
* index. This breaks the wrapper <-> handler reference loop that a strong
|
|
22
|
+
* Nan::Persistent used to create and leak (#375); see
|
|
23
|
+
* doc/signal-handler-gc.md. */
|
|
24
|
+
guint handlerIndex;
|
|
20
25
|
GICallableInfo* info;
|
|
21
26
|
|
|
22
27
|
~Closure() {
|
|
23
|
-
persistent.Reset();
|
|
24
28
|
if (info) g_base_info_unref (info);
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
static GClosure *New(
|
|
31
|
+
static GClosure *New(guint handlerIndex,
|
|
28
32
|
GICallableInfo* info,
|
|
29
33
|
guint signalId);
|
|
30
34
|
|
|
31
35
|
static void Execute(GICallableInfo *info, guint signal_id,
|
|
32
|
-
|
|
36
|
+
GObject *instance, guint handlerIndex,
|
|
33
37
|
GValue *g_return_value, guint n_param_values,
|
|
34
38
|
const GValue *param_values);
|
|
35
39
|
|
package/src/function.cc
CHANGED
|
@@ -134,6 +134,43 @@ bool IsDestroyNotify (GIBaseInfo *info) {
|
|
|
134
134
|
&& strcmp(g_base_info_get_namespace(info), "GLib") == 0;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/*
|
|
138
|
+
* A transfer-full GObject return value hands node-gtk an *owning* reference. The
|
|
139
|
+
* JS wrapper only needs node-gtk's toggle reference, so that extra reference has
|
|
140
|
+
* to be released — otherwise the refcount never falls back to 1, ToggleNotify
|
|
141
|
+
* never flips the V8 handle to weak, and the GObject (plus its wrapper) leaks for
|
|
142
|
+
* the lifetime of the process. This is the leak reported in #446: objects from
|
|
143
|
+
* GI function returns (`Type.new()`, transfer-full getters, …) were never GC'd,
|
|
144
|
+
* while `new Type()` — which drops its construction ref in GObjectConstructor —
|
|
145
|
+
* was. It is the return-value counterpart of OUT GObject args, which
|
|
146
|
+
* FreeGIArgument already balances; the return value is otherwise never freed on
|
|
147
|
+
* the success path. (The IN counterpart is RefObjectForTransferFullIn, #439.)
|
|
148
|
+
*
|
|
149
|
+
* Must be sampled *before* the value is wrapped: associating the wrapper sinks a
|
|
150
|
+
* floating reference (consuming it), after which a floating incoming ref (nothing
|
|
151
|
+
* extra to drop) is indistinguishable from a real owned one. G_IS_OBJECT excludes
|
|
152
|
+
* GParamSpec (wrapped via ParamSpec::FromGParamSpec) and boxed/fundamental
|
|
153
|
+
* interface types.
|
|
154
|
+
*/
|
|
155
|
+
static bool OwnsExtraGObjectReturnRef(GITypeInfo *return_type, GITransfer transfer, gpointer ptr) {
|
|
156
|
+
if (transfer != GI_TRANSFER_EVERYTHING || ptr == NULL)
|
|
157
|
+
return false;
|
|
158
|
+
|
|
159
|
+
if (g_type_info_get_tag(return_type) != GI_TYPE_TAG_INTERFACE)
|
|
160
|
+
return false;
|
|
161
|
+
|
|
162
|
+
GIBaseInfo *iface = g_type_info_get_interface(return_type);
|
|
163
|
+
GIInfoType itype = g_base_info_get_type(iface);
|
|
164
|
+
|
|
165
|
+
bool result =
|
|
166
|
+
(itype == GI_INFO_TYPE_OBJECT || itype == GI_INFO_TYPE_INTERFACE)
|
|
167
|
+
&& G_IS_OBJECT(ptr)
|
|
168
|
+
&& !g_object_is_floating(ptr);
|
|
169
|
+
|
|
170
|
+
g_base_info_unref(iface);
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
137
174
|
|
|
138
175
|
/**
|
|
139
176
|
* The constructor just stores the GIBaseInfo ref. The rest of the
|
|
@@ -549,6 +586,13 @@ Local<Value> FunctionCall (
|
|
|
549
586
|
|
|
550
587
|
} else if (!use_return_value) {
|
|
551
588
|
|
|
589
|
+
// A transfer-full GObject return hands us an extra owning reference on
|
|
590
|
+
// top of the wrapper's toggle ref; drop it once wrapped so the object
|
|
591
|
+
// isn't pinned alive forever (#446). Sampled now, before wrapping sinks
|
|
592
|
+
// any floating reference.
|
|
593
|
+
bool release_return_ref =
|
|
594
|
+
OwnsExtraGObjectReturnRef(&return_type, return_transfer, return_value_stack.v_pointer);
|
|
595
|
+
|
|
552
596
|
// Value transferred to jsReturnValue
|
|
553
597
|
jsReturnValue = func->JsReturnValue (
|
|
554
598
|
info.This(),
|
|
@@ -556,6 +600,9 @@ Local<Value> FunctionCall (
|
|
|
556
600
|
&return_value_stack,
|
|
557
601
|
callable_arg_values,
|
|
558
602
|
return_transfer);
|
|
603
|
+
|
|
604
|
+
if (release_return_ref)
|
|
605
|
+
g_object_unref (return_value_stack.v_pointer);
|
|
559
606
|
} else {
|
|
560
607
|
|
|
561
608
|
// Value returned in return_value
|
package/src/gi.cc
CHANGED
|
@@ -405,6 +405,10 @@ NAN_METHOD(GetModuleCache) {
|
|
|
405
405
|
info.GetReturnValue().Set(Nan::New<Object>(GNodeJS::moduleCache));
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
NAN_METHOD(SetLazyClassRegister) {
|
|
409
|
+
GNodeJS::ObjectClass::SetLazyClassRegister(info);
|
|
410
|
+
}
|
|
411
|
+
|
|
408
412
|
NAN_METHOD(RegisterClass) {
|
|
409
413
|
GNodeJS::ObjectClass::RegisterClass(info);
|
|
410
414
|
}
|
|
@@ -413,6 +417,10 @@ NAN_METHOD(RegisterVFunc) {
|
|
|
413
417
|
GNodeJS::ObjectClass::RegisterVFunc(info);
|
|
414
418
|
}
|
|
415
419
|
|
|
420
|
+
NAN_METHOD(CallVFunc) {
|
|
421
|
+
GNodeJS::ObjectClass::CallVFunc(info);
|
|
422
|
+
}
|
|
423
|
+
|
|
416
424
|
void InitModule(Local<Object> exports, Local<Value> module, void *priv) {
|
|
417
425
|
GNodeJS::AsyncCallEnvironment::Initialize();
|
|
418
426
|
|
|
@@ -431,8 +439,10 @@ void InitModule(Local<Object> exports, Local<Value> module, void *priv) {
|
|
|
431
439
|
Nan::Export(exports, "StartLoop", StartLoop);
|
|
432
440
|
Nan::Export(exports, "IsRunningMicrotasks", IsRunningMicrotasks);
|
|
433
441
|
Nan::Export(exports, "GetLoopStack", GetLoopStack);
|
|
442
|
+
Nan::Export(exports, "SetLazyClassRegister", SetLazyClassRegister);
|
|
434
443
|
Nan::Export(exports, "RegisterClass", RegisterClass);
|
|
435
444
|
Nan::Export(exports, "RegisterVFunc", RegisterVFunc);
|
|
445
|
+
Nan::Export(exports, "CallVFunc", CallVFunc);
|
|
436
446
|
|
|
437
447
|
Nan::Set(exports, UTF8("System"), GNodeJS::System::GetModule());
|
|
438
448
|
Nan::Set(exports, UTF8("Cairo"), GNodeJS::Cairo::GetModule());
|