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/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": "2.2.0",
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 (Local<Function> function, GICallableInfo* info, guint signalId) {
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->persistent.Reset(function);
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
- const Nan::Persistent<v8::Function> &persFn,
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
- auto func = Nan::New<Function>(persFn);
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(&param_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, closure->persistent, g_return_value, n_param_values, param_values);
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, closure->persistent, g_return_value, n_param_values, param_values);
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
- Nan::Persistent<v8::Function> persistent;
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(Local<Function> function,
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
- const Nan::Persistent<v8::Function> &persFn,
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());