Skip to content

v1.2.0

Choose a tag to compare

@eugeniodepalo eugeniodepalo released this 15 Aug 18:18
· 71 commits to main since this release
695dc1d

This release adds a future block to gtkx.config.ts, a way to take one of the next major version's behaviors at a time rather than all of them at an upgrade, and its first flag, v2ByteArrays, binds every GIR byte sequence as a Uint8Array instead of a number[], decoded as a single copy in the addon rather than one JavaScript number per byte. One half of that work lands whether or not a project opts in: a byte-sequence parameter takes Uint8Array | number[] everywhere, and a GByteArray argument accepts a typed array for the first time.

The rest of 1.2 is a round of corrections in the generated bindings and in codegen itself. Pointer-typed values are bigints, which is also what lets a GList or GPtrArray of raw pointers marshal at all; connect, on and off accept only a signal the class declares, which is what was letting useToast's handlers run with no toast; property accessors read and write through GObject carrying the property's own declared type; and the reference counting and free methods GIR declares are no longer bound, since the native layer owns those references. Codegen emits the generated stores without building a TypeScript program over them, which makes a store ECMAScript modules whatever the project's package.json declares and makes the cost of writing one track the number of modules emitted. Namespaces that could not be bound at all now bind: one whose static function narrows an inherited one, which is every gdbus-codegen proxy, and one carrying a type whose name starts with a digit. And deploy.flatpak.mode: "source" renders a manifest a pnpm project can actually build.

Changelog

New Features

Future flags, starting with v2ByteArrays

gtkx.config.ts takes a future block, which opts a project into behavior that becomes the default in the next major version, so an upgrade can be taken one change at a time instead of all at once. Every flag is off by default, codegen never warns about one that is not set, and a value that is not a boolean fails config validation.

export default defineConfig({
    applicationId: "com.example.Tasks",
    future: { v2ByteArrays: true },
});

The first flag is v2ByteArrays, which represents GIR byte sequences as Uint8Array rather than number[]. It covers guint8 C arrays and GByteArray wherever they are read: return values, out parameters, record fields and properties. GLib.fileGetContents becomes (filename: string) => [boolean, Uint8Array], GLib.base64Decode returns a Uint8Array and so does the remainder GLib.utf8Validate reports, Gio.File.loadContents returns [boolean, Uint8Array, string | null], GdkPixbuf.Pixbuf.getPixels returns a Uint8Array, and Gio.TlsCertificate's certificate and privateKey read Uint8Array | null, in the @gtkx/gi classes, the @gtkx/jsx props and the matching onNotify handlers alike. The descriptor emitted behind such a value carries isBytes: true, or t.byteArray for a GByteArray, so the bytes cross the boundary as one copy instead of being unpacked element by element. The handwritten Cairo overrides and the OpenGL bindings are untouched, since they already use typed arrays.

Method parameters are the same either way, Uint8Array | number[], so the flag never breaks an imperative call that passes bytes in. What changes is what comes back, plus the construct-time inputs that carry a byte sequence: the @gtkx/jsx props, the @gtkx/gi constructor props, record initializers and writable record field setters all narrow from number[] to Uint8Array. Code that calls .push, .concat, Array.isArray or JSON.stringify on a byte-sequence result has to be updated as well. Flip the flag and run tsc: every site that needs attention is a type error.

The setting is hashed into the generated store's fingerprint, so changing it makes the next gtkx dev, gtkx build or gtkx codegen regenerate @gtkx/gi and @gtkx/jsx on its own, and codegen reports the enabled flags on its own line as codegen: future=v2ByteArrays. gtkx docs and the @gtkx/mcp API reference read the same setting, so the reference pages and gtkx_list_api, gtkx_search_api and gtkx_get_api_docs describe byte sequences exactly the way the installed store carries them.

Flathub source builds for pnpm projects

deploy.flatpak.mode: "source" now renders a manifest a pnpm project can build. The Node SDK extension ships no pnpm and the Flathub sandbox has no network to fetch one, so the module vendors pnpm itself: an archive source pinned to the pnpm tarball's sha512 and unpacked into flatpak-pnpm, a script source whose dest-filename is pnpm and which execs node /run/build/<binaryName>/flatpak-pnpm/bin/pnpm.cjs, and that directory prepended to build-options.append-path ahead of the Node extension's bin. The install command is pnpm install --offline --frozen-lockfile, with --trust-lockfile appended on pnpm 11, whose supply-chain check otherwise reaches the registry. flatpak-node-generator is invoked with --pnpm-store-version v10 or v11 matching the pinned major, so the vendored store layout matches the pnpm that reads it, and the npm-only npm_config_cache and npm_config_offline variables are no longer set on a pnpm build, leaving npm_config_nodedir alone. Before, the same manifest ran a pnpm command that nothing in the sandbox provided, and generated the offline sources without a store version.

The pnpm version comes from packageManager in package.json. Write it with corepack use pnpm@<version>, which records the +sha512. digest every Flathub source has to carry; a project with no packageManager field gets pnpm 11.21.0. gtkx deploy refuses to render when the field names a manager other than pnpm while the build installs with pnpm (a pnpm-lock.yaml in the project root, or deploy.flatpak.packageManager: "pnpm"), when it carries no sha512 digest, and when it pins a pnpm outside 10.x and 11.3.0 or newer, since --trust-lockfile does not exist before 11.3.0. Vendoring also needs a flatpak-node-generator that supports --pnpm-store-version, an option newer than the generator's last tagged release: preflight runs flatpak-node-generator --help, counts a copy without that option as missing, and the tool's install hint gains the pipx install --force command that replaces a copy too old to vendor pnpm. npm and yarn manifests keep the same sources, append-path and install commands as before.

Breaking Changes

These are corrections to the generated bindings and to @gtkx/runtime's descriptor surface. Most of them are type-level, and tsc names every site that has to change once the store is regenerated, which happens on the first gtkx dev, gtkx build or gtkx codegen after the upgrade.

connect, on and off only accept a signal the class declares

Each generated class declared its signal methods twice: once keyed on its own signal map and once over a plain string. The second overload accepted anything, so a misspelled signal name, a signal belonging to a different class, and a handler declaring parameters the signal never supplies all type-checked and then misbehaved at runtime. @gtkx/components hit exactly that: toast.on("button-clicked", onButtonClicked) resolved through the string overload, and the callback ran with undefined where the toast was meant to be.

Every generated class now declares connect, on, once, off, addEventListener and removeEventListener only in the K extends keyof <Class>Signals form; the plain-string overloads are gone. emit(sigName: string, ...args: unknown[]): unknown is unchanged. Code that connected through a widget typed as an ancestor has to narrow it first, so a Gtk.Widget handed back by a query has to be resolved as a Gtk.Entry before connect("delete-text", ...) compiles, and a handler that declares more parameters than the signal passes has to be wrapped.

Detailed notify names are covered by an index signature on GObject.ObjectSignals and GObject.ObjectSignalEmit, so on(`notify::${string}`) type-checks for any property, including one installed through registerClass({ properties }), where 1.1 listed only the properties GIR declares. addEventListener and removeEventListener are also marked @deprecated in favor of on and off, and are to be removed in v2.

gpointer values are bigints

Every GIR type that resolves to a raw pointer, gpointer and gconstpointer, was declared number and marshalled through t.uint64, the unsigned 64-bit descriptor that decodes to a JavaScript number, which cannot represent an address above 2^53 exactly. Both are bound as t.biguint64 and typed bigint now, everywhere they appear: method parameters and returns, record fields, properties, element props, constants, and the userData argument of every generated vfunc signature. GObject.Value.getPointer() answers 0n rather than 0 and setPointer takes bigint | null, GObject.Object.getData, setData and stealData take and return bigint | null, Gtk.TreeIter.userData, userData2 and userData3 read and write bigint, GdkPixbuf.Pixbuf's pixels prop is bigint | null, Gtk.BuildableParseContext.pop() returns bigint | null, GLib.Sequence.append takes bigint | null, and Gio.TlsClientConnection.getAcceptedCas() returns bigint[]. It is also what lets a GList or GPtrArray of pointers marshal at all.

An integer Number inside the 2^53 safe range is still accepted where a pointer is passed in, so the work is on the way out: code that stores a returned pointer in a number, compares it against a number literal or does arithmetic on it has to move to bigint, so value.getPointer() === 0 becomes value.getPointer() === 0n. The API reference pages render the new type as well, since they share the same primitive table.

Reference counting and free methods are no longer bound

Every generated class and record carried the lifetime methods GIR declares on it, so ref, unref, refSink, forceFloating and free type-checked and called straight through to g_object_ref, g_object_unref and each boxed type's own release function. The native layer owns those references: it holds one on every handle it hands out and releases it when the handle is dropped, so a call to unref or free from TypeScript dropped a reference the runtime was still counting on and left the object free to be finalized while live handles still pointed at it.

No class or record member named force_floating, free, ref, ref_sink, take_ref or unref is emitted any more, neither on GObject.Object, which put ref, unref, refSink and forceFloating on every widget and application, nor on records such as GLib.Bytes, GLib.Variant, Pango.AttrList, Gdk.RGBA and Gtk.TextIter. Namespace-level functions are untouched, so GLib.free, which is g_free, is still bound. A project that called one of the removed members has to delete the call: the handle is released when it becomes unreachable, and there is no supported way to release it by hand.

Property accessors carry the declared property type and go through GObject

A generated get/set pair used to delegate to the class's own accessor methods whenever GIR named them, so video.file = f compiled to this.setFile(f) and button.label to this.getLabel(), and each direction took its type from the method behind it, which is the per-direction typing 1.1 introduced. Both the delegation and the per-direction typing are gone. Every accessor reads through g_object_get_property and writes through g_object_set_property, and both directions carry the property's declared GIR type. A property is nullable when GIR gives it default-value="NULL" or when its type resolves to anything other than a primitive or an enum, and a property that is not readable no longer gets a getter just because a getter method exists.

Two things change for a project. Types move, and mostly widen: Gtk.Button.label, whose GIR property carries default-value="NULL", accepts string | null on write where 1.1 accepted only string, while Gtk.Label.label, which carries no default, stays string in both directions. And a write no longer passes through the set<Name> method, so a subclass that overrode setFile no longer sees a write to file, and a test that spied on Gtk.Video.prototype.setFile has to spy on the file setter instead. The get<Name> and set<Name> methods themselves are still emitted and still call the C functions directly.

The runtime's array descriptors take an options object

t.gArray, t.sizedArray, t.cursorArray and t.fixedArray from @gtkx/runtime took the inline element stride as a trailing elementSize?: number argument. That argument is now the ArrayOptions object t.array already took, so t.sizedArray(t.uint8, 1, "borrowed", 24) has to be written t.sizedArray(t.uint8, 1, "borrowed", { elementSize: 24 }), and t.list, t.slist and t.ptrArray gained the same trailing argument.

ArrayOptions carries a new isBytes key. A descriptor marked with it decodes to a Uint8Array rather than to an array of numbers, and the addon refuses to build the codec unless the item descriptor is t.uint8, so t.fn throws when a byte array is declared over a wider element. t.byteArray sets it for itself, which means a hand-written GByteArray descriptor decodes to a Uint8Array whatever the project's future block says. A descriptor that has to keep the numeric representation spells it t.array(t.uint8, "gbytearray", ownership), which is exactly what codegen emits for a project that has not set future.v2ByteArrays.

Only code that writes FFI descriptors by hand is affected: the generated store is rewritten by codegen on the first gtkx dev, gtkx build or gtkx codegen after the upgrade.

Smaller breaking changes

  • StaticBase<C, K> from @gtkx/runtime no longer defaults K to "new", so code that named StaticBase<typeof Foo> has to name the keys it omits. The generated store applies it to every static a class redeclares incompatibly with the one it inherits, rather than to its constructors alone.

Bugfixes

  • Fixed the generated @gtkx/gi and @gtkx/jsx stores being emitted as CommonJS in any project whose package.json does not declare "type": "module". The store was compiled in a staging directory under node_modules before its own manifest was written there, so TypeScript resolved the module format from the nearest enclosing manifest, which is the project's, and emitted "use strict" modules assigning to exports and __esModule into a package that then declared itself ESM, a combination Node rejects. The manifest is written before anything is compiled, codegen drops a {"type": "module"} manifest into any directory it type-checks that has none, and each module is transpiled on its own rather than through a program that consults the surrounding directory. Projects scaffolded by gtkx create were never affected, since that template already sets "type": "module".
  • Fixed the setter generated for a record field that is an array of inline structs writing every element of the array it was handed, however long that array was, straight past the end of the memory the field covers, since the native write applies the byte offset without bounds-checking it against the handle. Assigning to Pango.GlyphString.glyphs, Gio.InputMessage.vectors, GObject.EnumClass.values, GObject.FlagsClass.values or GObject.Value.data an array longer than the field's own length, which is numGlyphs, numVectors, nValues and a fixed 2 respectively, wrote over whatever followed the struct. The generated loop breaks at that length now, so the extra elements are dropped instead of corrupting memory. A field whose inline element struct exposes no readable leaf field also no longer gets a struct-array accessor at all, where it used to emit a getter that returned an array of empty objects and a setter that wrote the array back untouched; such a field falls through to the ordinary field accessor.
  • Fixed a GList, GSList or GPtrArray of raw pointers being unusable in both directions. Decoding one read each element as though the pointer were a returned integer and threw, so Gio.TlsClientConnection.getAcceptedCas, Gio.DtlsClientConnection.getAcceptedCas, the accepted-cas property on both of those interfaces, and the dns-names and ip-addresses properties on Gio.TlsCertificate threw on every call that had anything to return, and answered correctly only when the container was empty. Encoding failed differently per container: a GPtrArray argument ran its elements through the GObject handle check and rejected every one of them, and a GList argument handed the callee a flat buffer of 64-bit words where a chain of list nodes was expected. Pointer elements are read out of the container one word at a time and encoded back into container nodes, so t.list, t.slist and t.ptrArray of t.biguint64 round-trip, and an element that is not a pointer-sized integer is rejected with the index that carries it.
  • Fixed useToast's onButtonClicked and onDismissed handlers being called with no arguments at all, although ToastOptions types both as receiving the Adw.Toast, so the parameter was undefined for the life of the toast. The handlers were connected straight to the button-clicked and dismissed signals, and a signal handler is invoked with the signal's own arguments only, without the emitter. Both are wrapped now and receive the toast they were registered for.
  • Fixed a generated class whose static function narrows one it inherits producing a store TypeScript refuses. The extends clause hid only the class's own constructors behind StaticBase, so a GIR <function> such as new or new_for_bus that a subclass redeclares with a different parameter list, which is what gdbus-codegen emits for every D-Bus proxy, left the subclass's static side incompatible with its parent's and failed the store's type check. Declaring UDisks-2.0 in libraries could not generate at all: 28 of its proxy classes redeclare Gio.DBusProxy.new and newForBus without the DBusInterfaceInfo parameter, and ObjectManagerClient narrows the pair it inherits too, 58 statics in all. The clause now hides every static the class declares, constructor or function alike, and only those whose signature is not assignable to the one it shadows.
  • Fixed a GIR type whose name starts with a digit emitting a TypeScript identifier that starts with a digit, which is a syntax error, so the whole store failed to compile and the namespace could not be bound at all. Identifier escaping covered reserved words but not a leading digit, although the enum member and method name paths already handled it, so NetworkManager's 80211Mode, 80211ApFlags and 80211ApSecurityFlags came out as export enum 80211Mode. Such a name is prefixed with an underscore now, so those types are emitted as _80211Mode and every reference to them resolves.
  • Fixed every generated parameter that takes a guint8 C array or a GByteArray being typed number[], so a Uint8Array already in hand had to be spread into a plain array before it could be passed. Those parameters are Uint8Array | number[] whether or not future.v2ByteArrays is set, so GLib.Bytes.new(surface.getData()) takes what Cairo.ImageSurface.getData returns directly, where it previously needed GLib.Bytes.new([...pixelData]). A GByteArray argument was also refused at the FFI layer, where the codec declared no buffer-view support and threw for any ArrayBufferView; it encodes one now, so GLib.ByteArray.append(new Uint8Array([1, 2]), new Uint8Array([3])) works alongside GLib.ByteArray.append([1, 2], [3]). A view whose element type is not a byte, an Int16Array or a DataView, still throws.
  • Fixed deploy.flatpak.source.tag rendering a git source that carried only the tag, so a Flathub submission tracked a movable ref rather than the fixed tree it was generated from. gtkx deploy now resolves the tag with git rev-parse <tag>^{commit} in the project root and writes both tag and commit into the manifest, and fails with a message pointing at deploy.flatpak.source.commit when the tag does not resolve in the checkout. Configuring commit explicitly behaves as before.
  • Fixed a .gir that is not well-formed XML being reported as a parser message and a position with no sight of the offending line, whose character is by definition unprintable. GTop-2.0.gir ships a raw U+0004 inside <constant name="EOT_STR">, so a project that named GTop-2.0 in libraries, readily reached through libraries: "*", got Illegal control character 0x04 in attribute 'value' value. (line 48, column 30) over a file whose offending byte is invisible in an editor. The offending line is now printed with its control codes escaped, so that constant reads value="[U+0004]", followed by what to do about it: report the file to whoever ships it, leave the library out of libraries, or put a corrected copy in a directory named by girPath, which is searched ahead of /usr/share/gir-1.0.

Performance

  • The generated @gtkx/gi and @gtkx/jsx stores are emitted without building a TypeScript program. Codegen used to create a ts.Program over every generated module, symlink its own node_modules beside them so their imports resolved, and run getPreEmitDiagnostics across the whole store before emitting anything; each module is now transpiled on its own with ts.transpileDeclaration and ts.transpileModule, so the cost of writing a store tracks the number of modules emitted, 52 JavaScript files for @gtkx/gi and 25 for @gtkx/jsx on the default Gtk-4.0, Adw-1, GtkSource-5 and WebKit-6.0 set, rather than the size of the type graph those modules form, and nothing is resolved across module boundaries or loaded from @gtkx/runtime, @gtkx/react and the React and Node type declarations. Errors a single module raises on its own, such as a declaration isolatedDeclarations cannot emit, still fail the run, and the generated OpenGL modules still go through the full type check. A store that would not type-check as a whole is emitted anyway, so an error that used to stop codegen surfaces where a project uses the binding it affects; a failure that does stop the emit is reported as Compiling the generated @gtkx/gi store failed where 1.1 said Type checking. The __gtkx-env__.d.ts reference file the jsx store carried only for the sake of that check is gone.
  • Under future.v2ByteArrays, decoding a byte sequence copies the whole run into a fresh Uint8Array in one pass rather than creating one JavaScript number per byte and setting each into a JS array, so reading an n-byte buffer such as GLib.fileGetContents, GdkPixbuf.Pixbuf.getPixels or a GByteArray return costs a byte copy of the buffer instead of n N-API value creations. Sized, fixed, cursor, zero-terminated, GArray and GByteArray containers all take that path, and an empty or null sequence decodes to an empty Uint8Array without allocating a JS array at all.