Skip to content

Commit 88afb7b

Browse files
romgrkclaude
andcommitted
feat(windows): auto-wire bundled GTK runtime + publish prebuilts
- lib/native.js: on win32, point PATH / GI_TYPELIB_PATH / XDG_DATA_DIRS / GSETTINGS_SCHEMA_DIR / GDK_PIXBUF_MODULE_FILE at the runtime bundled next to the .node, before the addon loads. Plain `npm install node-gtk` now works on Windows with no MSYS2/compiler and no manual env setup. - windows-bundle-runtime.sh: rewrite gdk-pixbuf loaders.cache to portable (bare) names so image loaders work on the user's machine. - main.yaml: install the GTK4 ship-stack alongside the GTK3 test deps, bundle the runtime, and publish the prebuilt to S3 on [publish binary]. - smoke test now sets NO env vars (relies on native.js) and runs with the runner's MinGW stripped from PATH; also decodes a PNG via bundled loaders. - doc: Windows prebuilt binaries are now available. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 45b61a4 commit 88afb7b

6 files changed

Lines changed: 136 additions & 44 deletions

File tree

.github/workflows/main.yaml

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,19 @@ jobs:
3939
mingw-w64-x86_64-gcc
4040
mingw-w64-x86_64-pkgconf
4141
mingw-w64-x86_64-python
42+
mingw-w64-x86_64-ntldd-git
4243
mingw-w64-x86_64-gobject-introspection
43-
mingw-w64-x86_64-gtk3
4444
mingw-w64-x86_64-cairo
45+
mingw-w64-x86_64-gtk3
4546
mingw-w64-x86_64-gstreamer
4647
mingw-w64-x86_64-gst-plugins-good
4748
mingw-w64-x86_64-gst-plugins-bad
4849
mingw-w64-x86_64-libsoup3
50+
mingw-w64-x86_64-gtk4
51+
mingw-w64-x86_64-libadwaita
52+
mingw-w64-x86_64-gtksourceview5
53+
mingw-w64-x86_64-graphene
54+
mingw-w64-x86_64-adwaita-icon-theme
4955
5056
- name: Install & Build
5157
env:
@@ -64,6 +70,27 @@ jobs:
6470
--skip=callback \
6571
tests/__run__.js
6672
73+
# Make the freshly-built addon a self-contained prebuilt: bundle the GTK4
74+
# runtime (DLLs + GI typelibs + data) next to the .node so Windows users
75+
# can `npm install node-gtk` with no MSYS2/compiler. lib/native.js wires
76+
# this bundle up automatically at load time.
77+
- name: Bundle GTK4 runtime
78+
run: |
79+
ABI=$(node -p "process.versions.modules")
80+
./scripts/windows-bundle-runtime.sh "lib/binding/node-v${ABI}-win32-x64"
81+
82+
# Publish the prebuilt to S3 on a `[publish binary]` commit, like the
83+
# Linux/macOS jobs do via scripts/ci.sh.
84+
- name: Publish prebuilt
85+
if: contains(github.event.head_commit.message, '[publish binary]')
86+
env:
87+
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
88+
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
89+
run: |
90+
npx node-pre-gyp package
91+
npx node-pre-gyp publish
92+
npx node-pre-gyp info
93+
6794
build:
6895
name: ${{ matrix.os }} - nodejs ${{ matrix.node }}
6996
runs-on: ${{ matrix.os }}

.github/workflows/test-windows-prebuilt.yaml

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,18 +128,29 @@ jobs:
128128
echo "---- bundled prebuilt ----"
129129
ls -la lib/binding/*/ || true
130130
131-
# Negative control: without the bundled DLLs on PATH the addon must fail to
132-
# load. Documents WHY bundling is required. Allowed to fail.
131+
# Negative control: move the bundled typelibs aside so native.js cannot
132+
# auto-wire; the addon must then fail to load. Proves the pass below comes
133+
# from the bundle (not the runner's GTK), then restores it. Allowed to fail.
133134
- name: Negative control (no bundled runtime — expected to fail)
134135
continue-on-error: true
135136
shell: bash
136137
run: |
138+
export PATH=$(echo "$PATH" | tr ':' '\n' | grep -viE 'mingw|msys|chocolatey' | paste -sd ':' -)
139+
DIR=$(echo lib/binding/node-v*-win32-x64)
140+
mv "$DIR/girepository-1.0" "$DIR/girepository-1.0.off"
137141
if node -e "require('.')"; then
138142
echo "UNEXPECTED - loaded without the bundled GTK runtime"
139143
else
140144
echo "expected failure - addon needs the bundled GTK runtime/typelibs"
141145
fi
146+
mv "$DIR/girepository-1.0.off" "$DIR/girepository-1.0"
142147
143-
- name: Smoke test (bundled runtime — expected to pass)
148+
# The real test: NO env vars set here, and the runner's own MinGW/MSYS is
149+
# stripped from PATH to simulate a clean user machine. A pass proves
150+
# lib/native.js auto-wires the bundled runtime by itself.
151+
- name: Smoke test (auto-wired, simulated clean machine)
144152
shell: bash
145-
run: node scripts/windows-smoke-test.js
153+
run: |
154+
export PATH=$(echo "$PATH" | tr ':' '\n' | grep -viE 'mingw|msys|chocolatey' | paste -sd ':' -)
155+
echo "gcc on PATH? $(which gcc || echo no)"
156+
node scripts/windows-smoke-test.js

doc/installation.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ Note that prebuilt binaries are available for common systems, in those cases bui
1919

2020
- **Linux**: prebuilt binaries available
2121
- **macOS**: prebuilt binaries available
22-
- **Windows**: no prebuilt binaries
22+
- **Windows**: prebuilt binaries available (the GTK4/Adwaita runtime is bundled
23+
with the binary, so no MSYS2/compiler is needed to *use* node-gtk; the terminal
24+
widget Vte is the one exception — it has no Windows port). Building from source
25+
still requires MSYS2 (see below).
2326

2427
### Requirements
2528

lib/native.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,61 @@
44

55
const binary = require('@mapbox/node-pre-gyp')
66
const path = require('path')
7+
const fs = require('fs')
78

89
const packagePath = path.resolve(path.join(__dirname,'../package.json'))
910
const bindingPath = binary.find(packagePath)
1011

12+
// On Windows, the prebuilt binary ships with its whole GTK runtime bundled in
13+
// the same directory as the .node (DLLs, GObject-Introspection typelibs, and
14+
// runtime data). Wire the process environment to that bundle BEFORE the addon
15+
// is loaded, so a plain `npm install node-gtk` works with no MSYS2, no compiler
16+
// and no manual PATH setup. Done in this file specifically because it must run
17+
// before `require(bindingPath)` (the addon's DLL imports are resolved at load
18+
// time) and before bootstrap.js requires the GIRepository typelib.
19+
if (process.platform === 'win32')
20+
setupBundledRuntime(path.dirname(bindingPath))
21+
22+
function setupBundledRuntime(bundleDir) {
23+
// A self-contained prebuilt always bundles its typelibs; if that marker is
24+
// absent the addon was built from source against a system GTK, so leave the
25+
// environment alone.
26+
const typelibDir = path.join(bundleDir, 'girepository-1.0')
27+
if (!fs.existsSync(typelibDir))
28+
return
29+
30+
const prepend = (name, value) => {
31+
const cur = process.env[name]
32+
process.env[name] = cur ? value + path.delimiter + cur : value
33+
}
34+
const exists = p => { try { return fs.existsSync(p) } catch (e) { return false } }
35+
36+
// 1) DLLs — both the addon's own imports and the namespace shared libraries
37+
// GObject-Introspection loads at runtime via g_module_open().
38+
prepend('PATH', bundleDir)
39+
40+
// 2) GI typelibs.
41+
prepend('GI_TYPELIB_PATH', typelibDir)
42+
43+
// 3) Runtime data: icon themes, GtkSourceView languages/styles, schemas.
44+
const share = path.join(bundleDir, 'share')
45+
if (exists(share))
46+
prepend('XDG_DATA_DIRS', share)
47+
const schemas = path.join(share, 'glib-2.0', 'schemas')
48+
if (exists(schemas) && !process.env.GSETTINGS_SCHEMA_DIR)
49+
process.env.GSETTINGS_SCHEMA_DIR = schemas
50+
51+
// 4) gdk-pixbuf image loaders (made path-portable at bundle time). The
52+
// loaders dir goes on PATH so g_module_open() of a bare loader name from
53+
// the cache resolves.
54+
const loadersDir = path.join(bundleDir, 'lib', 'gdk-pixbuf-2.0', '2.10.0', 'loaders')
55+
if (exists(loadersDir))
56+
prepend('PATH', loadersDir)
57+
const loaderCache = path.join(bundleDir, 'lib', 'gdk-pixbuf-2.0', '2.10.0', 'loaders.cache')
58+
if (exists(loaderCache) && !process.env.GDK_PIXBUF_MODULE_FILE)
59+
process.env.GDK_PIXBUF_MODULE_FILE = loaderCache
60+
}
61+
1162
const binding = require(bindingPath)
1263

1364
module.exports = binding

scripts/windows-bundle-runtime.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,16 @@ copy_tree /mingw64/share/icons/hicolor "$BINDING_DIR/share/icons/hicolor"
133133
# GtkSourceView language definitions + style schemes
134134
copy_tree /mingw64/share/gtksourceview-5 "$BINDING_DIR/share/gtksourceview-5"
135135

136+
# Make the gdk-pixbuf loaders cache path-portable: the build machine bakes in
137+
# absolute paths to each loader DLL, which don't exist on the user's machine.
138+
# Rewrite each loader path to its bare file name; lib/native.js puts the loaders
139+
# dir on PATH so g_module_open() resolves the bare name at run time.
140+
LOADER_CACHE="$BINDING_DIR/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache"
141+
if [ -f "$LOADER_CACHE" ]; then
142+
sed -i -E 's#^"[^"]*[\\/]([^"\\/]+\.dll)"#"\1"#' "$LOADER_CACHE"
143+
echo "## Rewrote $LOADER_CACHE to portable (bare) loader names"
144+
fi
145+
136146
echo
137147
echo "## ===== SIZE BREAKDOWN ====="
138148
size() { # label dir-or-glob

scripts/windows-smoke-test.js

Lines changed: 28 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
/*
22
* windows-smoke-test.js
33
*
4-
* Verifies that a Windows prebuilt + bundled GTK runtime (DLLs + typelibs) can
5-
* be loaded and used on a clean machine that has NO MSYS2/MinGW and NO compiler
6-
* — i.e. exactly what a user gets from `npm install node-gtk` if we ship a
7-
* self-contained Windows prebuilt.
4+
* Verifies that a Windows prebuilt + bundled GTK runtime can be loaded and used
5+
* on a clean machine that has NO MSYS2/MinGW and NO compiler — i.e. exactly what
6+
* a user gets from `npm install node-gtk`.
87
*
9-
* It deliberately does NOT depend on anything from MSYS2; the only GTK bits it
10-
* touches are the ones bundled next to the .node by windows-bundle-runtime.sh.
8+
* CRITICAL: this test sets NO environment variables. Everything (DLL search
9+
* path, GI typelib path, icon/schema/loader data) is wired up automatically by
10+
* lib/native.js when it loads the bundled prebuilt. The workflow runs this with
11+
* the runner's own MinGW stripped from PATH, so a pass proves the bundle is
12+
* fully self-sufficient via the auto-wiring alone.
1113
*/
1214

1315
const path = require('path')
@@ -26,39 +28,11 @@ if (fs.existsSync(bindingDir)) {
2628
console.log(`bundled DLLs: ${dlls.length}`)
2729
}
2830

29-
// 1) Make the bundled GTK DLLs discoverable. This covers BOTH:
30-
// - the addon's own static imports (resolved when node loads the .node)
31-
// - GObject-Introspection's g_module_open() of each namespace's shared lib
32-
// We REPLACE the PATH (rather than prepend) with the bundle dir + only the
33-
// Windows system dirs. The GitHub windows-latest runner ships its own
34-
// C:\mingw64; isolating the PATH proves the test uses ONLY the bundled
35-
// runtime, not whatever GTK happens to be on the machine.
36-
const sysRoot = process.env.SystemRoot || 'C:\\Windows'
37-
process.env.PATH = [
38-
bindingDir,
39-
path.dirname(process.execPath), // node.exe dir
40-
path.join(sysRoot, 'System32'),
41-
sysRoot,
42-
].join(path.delimiter)
43-
// 2) Point GI at the bundled typelibs.
44-
process.env.GI_TYPELIB_PATH =
45-
typelibDir + (process.env.GI_TYPELIB_PATH ? path.delimiter + process.env.GI_TYPELIB_PATH : '')
46-
47-
// Require the local package (its lib/native.js resolves the prebuilt via
48-
// node-pre-gyp's binary.find, i.e. the same path users hit after install).
31+
// Require the local package. lib/native.js resolves the prebuilt via
32+
// node-pre-gyp's binary.find and auto-wires the bundled runtime — NO manual
33+
// PATH / GI_TYPELIB_PATH / XDG_DATA_DIRS setup here on purpose.
4934
const gi = require(path.join(__dirname, '..'))
50-
console.log('OK: require(node-gtk) — prebuilt + bundled DLLs loaded')
51-
52-
// Belt and suspenders: also register the typelib dir through GI's own API.
53-
try { gi.prependSearchPath(typelibDir) } catch (e) { /* ignore */ }
54-
55-
// Point GTK4/Adwaita at the bundled runtime data so a real app could run.
56-
const bundledShare = path.join(bindingDir, 'share')
57-
if (fs.existsSync(bundledShare)) {
58-
process.env.XDG_DATA_DIRS = bundledShare + (process.env.XDG_DATA_DIRS ? path.delimiter + process.env.XDG_DATA_DIRS : '')
59-
const schemas = path.join(bundledShare, 'glib-2.0', 'schemas')
60-
if (fs.existsSync(schemas)) process.env.GSETTINGS_SCHEMA_DIR = schemas
61-
}
35+
console.log('OK: require(node-gtk) — prebuilt loaded and runtime auto-wired by native.js')
6236

6337
// The full quilx namespace set. Vte (3.91) has no Windows port, so it is
6438
// expected to be unavailable; everything else must load.
@@ -111,4 +85,20 @@ try {
11185
}
11286
console.log('live GTK4/Adwaita widgets:', appOk ? 'ok' : 'skipped (no display)')
11387

88+
// Exercise the bundled gdk-pixbuf image loaders + (portable) loaders.cache by
89+
// decoding a real PNG. This proves the loader subsystem works from the bundle.
90+
const PNG_1x1 = Buffer.from(
91+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
92+
'base64')
93+
const pngPath = path.join(__dirname, '..', 'smoke-test-pixel.png')
94+
fs.writeFileSync(pngPath, PNG_1x1)
95+
try {
96+
const pixbuf = loaded.GdkPixbuf.Pixbuf.newFromFile(pngPath)
97+
if (pixbuf.getWidth() !== 1 || pixbuf.getHeight() !== 1)
98+
throw new Error(`unexpected pixbuf size ${pixbuf.getWidth()}x${pixbuf.getHeight()}`)
99+
console.log('OK: GdkPixbuf decoded a PNG via the bundled loaders')
100+
} finally {
101+
try { fs.unlinkSync(pngPath) } catch (e) { /* ignore */ }
102+
}
103+
114104
console.log('\n=== SMOKE TEST PASSED: GTK4/Adwaita prebuilt usable with NO compiler/MSYS2 ===')

0 commit comments

Comments
 (0)