From 411ad83f7fcb51c8e39cd53068b4baf3152ff6a0 Mon Sep 17 00:00:00 2001 From: Rom Grk Date: Sun, 5 Jul 2026 14:10:14 -0400 Subject: [PATCH 1/2] feat: node-gtk bundle for Windows and macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the two remaining platform modules behind the bundle interface (HANDOFF.md mission; Linux shipped in #489): - platform-win32.js: port of the CI-proven windows-bundle-runtime.sh — ntldd -R DLL closure from the MinGW prefix (System32 stays on the host), typelibs, pixbuf loaders with bare-name cache rewrite, CRLF .cmd launcher wiring PATH/GI/GLib env, Compress-Archive zip. Requires an MSYS2 MINGW64 shell at bundle time only. - platform-darwin.js: otool -L transitive closure from Homebrew (@loader_path/@rpath resolution), install_name_tool -change to @loader_path-relative names + ad-hoc re-sign (mandatory on arm64, no -add_rpath), DYLD_FALLBACK_LIBRARY_PATH launcher as the safety net for typelib-baked absolute paths, .app/Info.plist layout, hdiutil UDZO dmg, strip -x + re-sign for node. - shared: copyTypelibs/copyRuntimeData/pkgConfigVar hoisted into runtime-data.js; node stripping is now a per-platform stripNode hook; app-tree trims the binding dir to node_gtk.node only (the Windows self-contained prebuilt carries a whole GTK runtime there — 250MB that must not be duplicated into app/). - CI: bundle smoke test on build-windows (after the prebuilt step, to exercise the binding-dir trim) and on the macOS leg; the smoke test's platform gate is gone. - docs: per-platform notes in doc/bundling.md (incl. macOS signing/notarization caveat), roadmap updated, HANDOFF.md retired. Co-Authored-By: Claude Fable 5 --- .github/workflows/main.yaml | 20 +- HANDOFF.md | 164 ---------------- README.md | 4 +- doc/bundling.md | 118 ++++++++---- scripts/bundle-smoke-test.js | 10 +- tools/bundle.js | 16 +- tools/bundle/app-tree.js | 13 +- tools/bundle/platform-darwin.js | 318 ++++++++++++++++++++++++++++++++ tools/bundle/platform-linux.js | 48 +---- tools/bundle/platform-win32.js | 177 ++++++++++++++++++ tools/bundle/runtime-data.js | 50 +++++ 11 files changed, 676 insertions(+), 262 deletions(-) delete mode 100644 HANDOFF.md create mode 100644 tools/bundle/platform-darwin.js create mode 100644 tools/bundle/platform-win32.js create mode 100644 tools/bundle/runtime-data.js diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 481224a4..665b2e0c 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -80,6 +80,16 @@ jobs: ABI=$(node -p "process.versions.modules") ./scripts/windows-bundle-runtime.sh "lib/binding/node-v${ABI}-win32-x64" + # `node-gtk bundle` end-to-end: bundle a smoke app against this checkout + # (inside the MINGW64 environment), then run the produced .cmd launcher. + # Deliberately after the prebuilt step: the binding dir then carries the + # self-contained runtime, exercising the bundler's trim of it. One node + # version suffices (the bundler is ABI-generic JS). The runner session + # has a desktop, so no virtual display is needed. + - name: Bundle smoke test + if: matrix.node == 24 + run: node scripts/bundle-smoke-test.js + # Publish the prebuilt to S3 on a `[publish binary]` commit, like the # Linux/macOS jobs do via scripts/ci.sh. - name: Publish prebuilt @@ -147,13 +157,17 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} # `node-gtk bundle` end-to-end: bundle a smoke app against this checkout, - # then run the produced launcher. Linux-only for now; one node version - # suffices (the bundler is ABI-generic JS, tested against the matrix's - # addon build anyway). + # then run the produced launcher. One node version suffices (the bundler + # is ABI-generic JS, tested against the matrix's addon build anyway). + # Linux needs a virtual display; the macOS runner has a window server. - name: Bundle smoke test if: matrix.os == 'ubuntu-latest' && matrix.node == 24 run: xvfb-run -a node scripts/bundle-smoke-test.js + - name: Bundle smoke test (macOS) + if: matrix.os == 'macos-latest' && matrix.node == 24 + run: node scripts/bundle-smoke-test.js + # `node-gtk flatpak` generation: manifest + offline sources must be # complete and buildable. The full sandbox build is not run per-CI-run # (it downloads the ~1GB GNOME SDK); this check needs no flatpak at all. diff --git a/HANDOFF.md b/HANDOFF.md deleted file mode 100644 index e9f51a48..00000000 --- a/HANDOFF.md +++ /dev/null @@ -1,164 +0,0 @@ -# HANDOFF — `node-gtk bundle` for Windows and macOS - -**Mission**: implement the Windows and macOS platform modules for -`node-gtk bundle`, so the portable-bundle story covers all three desktop -platforms. Linux (`bundle` + `flatpak`) shipped in PR #489 (master `e9b9c4e`); -read `doc/bundling.md` first — its Roadmap section is this document's summary. - -Everything below was learned or designed while building the Linux path; -Windows/macOS module drafts existed during that work and were validated in -design (not in execution), so treat the specifics as strong hints, not gospel. - -## Architecture you are plugging into - -`tools/bundle.js` orchestrates; per-OS work lives behind one interface in -`tools/bundle/platform-.js`: - -```js -module.exports = { - NODE_BINARY, // 'node' | 'node.exe' - assembleRuntime(ctx), // libraries + typelibs + schemas/icons/loaders → ctx.runtimeDir - writeLauncher(ctx), // returns launcher path - archive(ctx), // .tar.gz / .zip / .dmg -} -``` - -Register the module in the `PLATFORMS` map in `tools/bundle.js`. Everything -else is shared and already works cross-platform: - -- `tools/bundle/app-tree.js` — app files + production node_modules - (pnpm-safe), node-gtk trimmed to `package.json` + `lib/` + the one - `lib/binding/node-v--` matching the bundling node. - `ctx.bindingPath` is the copied `.node` — seed your closure walk from it. -- `tools/bundle/seeds.js` — the GI namespace libraries to bundle, **already - carrying the win32/darwin file names** (they mirror - `windows/runtime-libraries.txt`). Every seed is skip-if-missing. -- Config (`loadConfig`), output layout, marker/overwrite safety, node-binary - copy + strip, size report, `bundle.json` manifest. -- The launcher must include `--import node-gtk/register` when - `config.register` (default true) — see the Linux/flatpak launchers. - -The `ctx` fields you get: `config, appDir, outBase, runtimeDir, appOutDir, -bindingName, bindingPath, log` (+ on darwin you must set/use `ctx.contentsDir` -— `tools/bundle.js` already computes the `.app/Contents` layout for darwin; -check the `process.platform === 'darwin'` branch). - -## Windows (`platform-win32.js`) — mostly a port of proven code - -`scripts/windows-bundle-runtime.sh` is the **CI-proven reference** (it makes -the npm prebuilt self-contained; test-windows-prebuilt.yaml validated a -no-MSYS2 clean machine). Port its logic to JS; the app bundler is a superset -(adds node.exe + app tree + launcher). - -- **Environment**: runs inside an MSYS2 MINGW64 shell (where GTK is installed - and node-gtk was built) but under the *Windows* node. Resolve the prefix: - `cygpath -m ${MINGW_PREFIX:-/mingw64}` → `C:/msys64/mingw64`; error with a - "run from MINGW64" message if absent. -- **DLL closure**: `ntldd -R ` per entry (the `.node` + seeds from - `seedNames('win32', config.libraries, config.gtk)` resolved against - `/bin` + the gdk-pixbuf loader DLLs). Parse `=> C:\...` lines, keep - only paths under the prefix (System32 stays on the host), copy to - `runtime/lib` keyed by lowercased basename. -- **Typelibs**: `/lib/girepository-1.0/*.typelib` → `runtime/lib/girepository-1.0`. -- **gdk-pixbuf loaders**: `/lib/gdk-pixbuf-2.0/2.10.0/loaders/*.dll`; - rewrite `loaders.cache` loader paths to **bare file names** - (`.replace(/^"[^"]*[\\/]([^"\\/]+\.dll)"/gm, '"$1"')`) and put the loaders - dir on PATH in the launcher — this is exactly what `lib/native.js` + - `windows-bundle-runtime.sh` already do for the prebuilt. -- **Runtime data**: `/share/{glib-2.0/schemas/gschemas.compiled,icons/Adwaita,icons/hicolor}`. -- **node**: copy `process.execPath` → `runtime/node.exe` (MSVC node + MinGW - GTK DLLs interop fine — C ABI; the prebuilt already proves it). -- **Launcher** `.cmd` (CRLF): - ``` - @echo off - setlocal - set "HERE=%~dp0" - set "RT=%HERE%runtime" - set "PATH=%RT%\lib;%RT%\lib\gdk-pixbuf-2.0\2.10.0\loaders;%PATH%" - set "GI_TYPELIB_PATH=%RT%\lib\girepository-1.0" - set "XDG_DATA_DIRS=%RT%\share" - set "GSETTINGS_SCHEMA_DIR=%RT%\share\glib-2.0\schemas" - set "GDK_PIXBUF_MODULE_FILE=%RT%\lib\gdk-pixbuf-2.0\2.10.0\loaders.cache" - cd /d "%HERE%app" - "%RT%\node.exe" --import node-gtk/register ".\" %* - ``` - (`cd` into app/ so bare-specifier `--import`s resolve — same reasoning as - the Linux launcher.) -- **Archive**: `powershell.exe -NoProfile -Command Compress-Archive ...` (zip). -- **CI**: main.yaml's `build-windows` job already builds in MINGW64 with GTK4 - installed. Add a bundle-smoke step for one node version; - `scripts/bundle-smoke-test.js` already handles win32 (junction symlink, - `cmd.exe /c` launcher spawn) — just delete its early platform gate as you - add support. Runners have a desktop session, no xvfb needed. - -## macOS (`platform-darwin.js`) — the genuinely new work - -GTK comes from Homebrew (CI installs gtk3 via brew today; gtk4 works too). -Two-layer relocation design: - -1. **Closure**: walk `otool -L` transitively from the `.node` + seeds - (`/lib/`, realpath'd) + pixbuf loaders. Resolve - `@loader_path/...` against the referencing lib's dir; `@rpath/x` → - `/lib/x` heuristic; drop `/usr/lib` + `/System`. Keep only realpaths - under the brew prefix. Copy flat (basename) into `runtime/lib`. -2. **Rewrite** (`install_name_tool`): for every copied dylib, loader, and the - `.node`, `-change /` where relative is - `@loader_path` for dylibs (all siblings), `@loader_path/../../..` for - loaders, and `@loader_path/` for the `.node` - (compute with `path.relative`). Do **not** add LC_RPATH entries — - `-add_rpath` needs header padding and can fail; `-change` to a *shorter* - string always fits. **Re-sign every touched file ad-hoc** - (`codesign --force --sign -`) — mandatory on arm64. Where a rewrite fails, - warn and rely on layer 3. -3. **Launcher env fallback**: `DYLD_FALLBACK_LIBRARY_PATH="$RT/lib:/usr/local/lib:/usr/lib"`. - Crucial detail: brew **typelibs bake absolute dylib paths**; when GI - `g_module_open`s a missing absolute path, dyld retries the leaf name - against the fallback path — that's what makes the bundle work on machines - without brew. SIP note: DYLD_* vars set *inside* the launcher script - survive into our (unprotected) node binary; vars inherited *into* a - protected shell do not — so set them in the script, never rely on the - caller's env. -- **Layout**: `/.app/Contents/{MacOS/,Info.plist,Resources/{runtime,app}}` - (bundle.js already computes these ctx paths). Minimal Info.plist: - CFBundlePackageType APPL, Name/DisplayName/Identifier/Executable, - ShortVersionString, NSHighResolutionCapable, LSMinimumSystemVersion 11.0. -- **Runtime data**: `/share/{glib-2.0/schemas,icons/...}`; if - `gschemas.compiled` is missing run `glib-compile-schemas`. pixbuf loaders - under `/lib/gdk-pixbuf-2.0/2.10.0/loaders` (`.so`/`.dylib`); use the - same `@LOADERS_DIR@` cache-template + launcher-sed trick as Linux - (`platform-linux.js`). -- **Archive**: `hdiutil create -volname -srcfolder -ov -format UDZO .dmg`. -- **node strip**: `strip -x` on darwin (see ci.sh), then ad-hoc re-sign. -- **CI**: the macos job has brew GTK3; the smoke fixture already falls back - Gtk4→Gtk3. Caveat: the runner *has* brew, so the DYLD-fallback path isn't - truly exercised there — a follow-up "consume on clean machine" job (rename - the brew prefix, or a second runner without brew) is the honest test, - mirroring test-windows-prebuilt.yaml's two-job pattern. -- **Distribution reality**: output is unsigned; document that shipping needs - Developer ID signing + notarization (out of scope for the module itself). -- Per-arch bundles (arm64 + x86_64 separately) — don't attempt universal. - -## Shared follow-ups (not yours, but don't preclude them) - -AppImage wrapper around the Linux tree; shared-runtime install (`runtime/` is -already content-addressed-friendly); Flathub submission of a first real app. - -## Validation bar (what "done" meant for Linux) - -1. `scripts/bundle-smoke-test.js` green on the target OS in CI (it asserts - the app ran under the **bundled** node). -2. A real app bundled and launched by hand at least once - (`~/src/mariner` is the guinea pig; its node_modules/node-gtk is a symlink - — point it at your worktree). -3. Size report sane; no host-path leakage (Linux used `LD_DEBUG=libs`; use - `DYLD_PRINT_LIBRARIES=1` / Process Monitor equivalents). -4. Docs: extend `doc/bundling.md` (platform sections + caveats), drop the - "Linux today" banners, update the Roadmap. - -## Process notes - -- Worktrees in `~/worktrees`, `pnpm install` (no node_modules symlinks), - explicit `git add` (never `-A`), Conventional Commits, PR against master, - merge only on `gh run view --json conclusion` == success. -- Project memory (`~/.claude/projects/-home-romgrk-src-node-gtk/memory/`) - has the full Linux war story under `project_bundle_cli.md`. diff --git a/README.md b/README.md index 8974aedc..ad2465a8 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,11 @@ npx node-gtk create Also see our [hello world](./examples/hello-world.mjs), [web browser](./examples/browser.mjs) or [system monitor](./examples/system-monitor.mjs) examples. -When it's time to ship (Linux for now — see [doc/bundling.md](./doc/bundling.md)): +When it's time to ship (see [doc/bundling.md](./doc/bundling.md)): ```sh npx node-gtk flatpak # a Flatpak users install with one click (Flathub-ready) -npx node-gtk bundle # a self-contained portable directory / tar.gz +npx node-gtk bundle # self-contained portable app: Linux, macOS and Windows ``` ## Installing diff --git a/doc/bundling.md b/doc/bundling.md index d10ce429..fdee4628 100644 --- a/doc/bundling.md +++ b/doc/bundling.md @@ -14,10 +14,10 @@ Two commands cover shipping, for different audiences: Both read the same `"bundle"` configuration from your package.json. -> **Platform support: Linux today.** macOS and Windows are planned; the -> platform-specific work is isolated behind one module interface -> (`tools/bundle/platform-*.js`), and the Windows DLL-closure logic is already -> proven by the self-contained npm prebuilt (`scripts/windows-bundle-runtime.sh`). +> **Platform support:** `node-gtk bundle` produces Linux, macOS and Windows +> bundles — always for the platform/architecture it runs on (no +> cross-bundling). `node-gtk flatpak` is Linux. See the +> [per-platform notes](#platform-notes) for what each bundler machine needs. # Portable bundles @@ -25,20 +25,21 @@ Both read the same `"bundle"` configuration from your package.json. ```sh cd my-app -npx node-gtk bundle # → dist/MyApp-linux-x64/ -npx node-gtk bundle --archive # → dist/MyApp-linux-x64.tar.gz too +npx node-gtk bundle # → dist/MyApp-linux-x64/ (or -darwin-arm64, -win32-x64…) +npx node-gtk bundle --archive # → …plus .tar.gz / .dmg / .zip ``` Requirements at bundle time: the app is installed (`node_modules` present, any package manager — pnpm symlink layouts are handled), node-gtk has a compiled addon for the running node, and the GTK stack you target is -installed. **The node that runs the bundler is the node that ships.** +installed ([per-platform notes](#platform-notes)). **The node that runs the +bundler is the node that ships.** Users run the launcher — no installation: ```sh tar xzf MyApp-linux-x64.tar.gz -./MyApp-linux-x64/MyApp +./MyApp-linux-x64/MyApp # macOS: open MyApp.app Windows: MyApp.cmd ``` ## Output layout @@ -55,6 +56,10 @@ MyApp-linux-x64/ └── app/ ← yours: files from `include` + production node_modules ``` +The same shape everywhere: Windows swaps the launcher for `MyApp.cmd` and the +node binary for `runtime/node.exe`; macOS wraps it into +`MyApp.app/Contents/{MacOS/MyApp, Info.plist, Resources/{runtime,app}}`. + The `runtime/` vs `app/` split is deliberate: `runtime/` is content-identical for every app built with the same node-gtk/GTK/node versions, so a future shared-runtime install (or content-addressed stores like Flatpak's) can @@ -96,27 +101,18 @@ CLI flags `--out`, `--name`, `--entry`, `--archive` override the config. ### What gets bundled, what stays on the host -The shared-library closure is walked with `ldd`, seeded from the addon, the -GI namespace libraries (GTK, Adwaita, Pango, GdkPixbuf, …— see -`tools/bundle/seeds.js`; missing ones are skipped, `libraries` adds more) and -the gdk-pixbuf loaders. Host-tied libraries are **excluded**, following the -[AppImage community excludelist](https://github.com/AppImageCommunity/pkg2appimage/blob/master/excludelist): -glibc, the GPU/OpenGL stack, X11/Wayland client libraries, and the font stack -(fontconfig/freetype/harfbuzz), which every desktop provides. +The shared-library closure is walked with the platform's dependency walker +(`ldd` / `otool -L` / `ntldd`), seeded from the addon, the GI namespace +libraries (GTK, Adwaita, Pango, GdkPixbuf, …— see `tools/bundle/seeds.js`; +missing ones are skipped, `libraries` adds more) and the gdk-pixbuf loaders. +OS-tied libraries are **excluded** and resolve against the host — see the +[per-platform notes](#platform-notes). Node packages: the production dependency closure of your app, symlinks dereferenced. node-gtk itself is trimmed to `package.json` + `lib/` with only -the target ABI's compiled addon; its build-time deps (node-pre-gyp, node-gyp, -nan) never ship. - -### Compatibility baseline - -A bundle runs on distros whose **glibc is at least as new** as the build -machine's (the classic portable-Linux constraint — the excluded libraries -resolve against the host). Build on the oldest distribution you want to -support; a GitHub Actions `ubuntu-latest` runner is a reasonable baseline. -`bundle.node` exists so you can ship a node built against an older glibc than -your machine's. +the target ABI's compiled addon (without the self-contained prebuilt's own +GTK runtime, on Windows); its build-time deps (node-pre-gyp, node-gyp, nan) +never ship. ### Size expectations @@ -125,16 +121,66 @@ GTK4 + Adwaita + typelibs + icons ≈ 135 MB, node ≈ 110 MB, ~100 MB as a machine that has both; `"icons": false` saves ~15 MB if you rely on host themes. +## Platform notes + +### Linux + +Excluded (host-provided) libraries follow the +[AppImage community excludelist](https://github.com/AppImageCommunity/pkg2appimage/blob/master/excludelist): +glibc, the GPU/OpenGL stack, X11/Wayland client libraries, and the font stack +(fontconfig/freetype/harfbuzz), which every desktop provides. Consequently a +bundle runs on distros whose **glibc is at least as new** as the build +machine's — build on the oldest distribution you want to support (a GitHub +Actions `ubuntu-latest` runner is a reasonable baseline). `bundle.node` +exists so you can ship a node built against an older glibc than your +machine's. Launcher wiring is `LD_LIBRARY_PATH` + `GI_TYPELIB_PATH` + +`XDG_DATA_DIRS`; `--archive` makes a `.tar.gz`. + +### Windows + +Bundle from an **MSYS2 MINGW64 shell** (GTK installed, node-gtk built there), +running the *Windows* node — the same environment that builds node-gtk. The +produced bundle has no such requirement: only `C:\Windows\System32` DLLs stay +on the host, everything MinGW ships in `runtime/lib`. The `MyApp.cmd` +launcher puts `runtime\lib` (and the pixbuf loaders dir) on `PATH` — the +Windows DLL search path — and runs `runtime\node.exe`. The MSVC node and +MinGW GTK DLLs interop fine (plain C ABI; the self-contained npm prebuilt has +proven this in CI for a while). `--archive` makes a `.zip`. + +### macOS + +Bundle on a machine with the **Homebrew** GTK stack (`brew install gtk4 +gobject-introspection` — or gtk3). Output is a per-architecture +`MyApp.app` (build arm64 and x86_64 separately; no universal binaries). +Relocation is two-layered: every bundled Mach-O is rewritten with +`install_name_tool` to `@loader_path`-relative names and ad-hoc re-signed, +and the launcher additionally exports +`DYLD_FALLBACK_LIBRARY_PATH=…/runtime/lib` — the net that also resolves the +absolute Homebrew paths baked into typelibs on machines without Homebrew. +`--archive` makes a compressed `.dmg`. + +**Distribution reality:** the `.app` is ad-hoc signed only. It runs locally +and on machines that clear the quarantine attribute +(`xattr -dr com.apple.quarantine MyApp.app`), but Gatekeeper-friendly +distribution needs Developer ID signing + notarization on top — out of scope +for the bundler. + ## Verifying a bundle -CI runs `scripts/bundle-smoke-test.js` (bundle a minimal app, run its -launcher, assert the app executed under the bundled node). To check where -libraries resolve from on your machine: +CI runs `scripts/bundle-smoke-test.js` on all three platforms (bundle a +minimal app, run its launcher, assert the app executed under the bundled +node). To check where libraries resolve from on your machine: ```sh +# Linux LD_DEBUG=libs ./dist/MyApp-linux-x64/MyApp 2>&1 | grep 'trying file.*libgtk' +# macOS +DYLD_PRINT_LIBRARIES=1 ./dist/MyApp-darwin-arm64/MyApp.app/Contents/MacOS/MyApp 2>&1 | grep libgtk ``` +(On Windows, use Process Monitor or `listdlls` to confirm DLLs load from +`runtime\lib`.) + # Flatpak ```sh @@ -251,14 +297,14 @@ you keep update delivery, minus the store discoverability. # Roadmap -- **Windows**: same `bundle` architecture; DLL closure via `ntldd` (already - CI-proven for the npm prebuilt), `.cmd` launcher, `.zip` archive. Needs an - MSYS2 MINGW64 environment at bundle time. -- **macOS**: dylib closure via `otool -L` from Homebrew, - `install_name_tool` relocation + ad-hoc re-signing, `.app`/`.dmg` output, - `DYLD_FALLBACK_LIBRARY_PATH` launcher. Distribution additionally requires - codesigning + notarization (Apple developer account). - **AppImage**: single-file wrapper around exactly the portable tree. - **Shared runtime** (portable bundles): install `runtime/` once per machine (`~/.local/share/node-gtk/runtime/`), apps resolve it relative-first — the layout already supports this. +- **macOS clean-machine CI**: the macOS smoke test runs on a runner that + *has* Homebrew, so the `DYLD_FALLBACK_LIBRARY_PATH` relocation path isn't + truly exercised there; an honest consume-side job (brew prefix renamed, or + a brew-less runner) would mirror `test-windows-prebuilt.yaml`'s two-job + pattern. +- **macOS signing helper**: optional Developer ID codesign + notarization + pass over the produced `.app`. diff --git a/scripts/bundle-smoke-test.js b/scripts/bundle-smoke-test.js index 0c3bfdbe..b9baa976 100644 --- a/scripts/bundle-smoke-test.js +++ b/scripts/bundle-smoke-test.js @@ -6,8 +6,9 @@ * checks that the app's GTK code actually executed inside the bundle. * * The app tries Gtk 4.0 and falls back to Gtk 3.0, so the test runs - * regardless of which GTK is installed. It needs a display: run under - * `xvfb-run -a` in headless environments. + * regardless of which GTK is installed. Runs on all bundling platforms: + * Linux (needs a display — use `xvfb-run -a` headless), macOS (Homebrew GTK) + * and Windows (run from an MSYS2 MINGW64 shell, under the Windows node). * * Usage: node scripts/bundle-smoke-test.js */ @@ -17,11 +18,6 @@ const os = require('os') const path = require('path') const child_process = require('child_process') -if (process.platform !== 'linux') { - console.log(`bundle-smoke-test: \`node-gtk bundle\` only supports linux for now, skipping on ${process.platform}`) - process.exit(0) -} - const repoRoot = path.resolve(__dirname, '..') const bindingName = `node-v${process.versions.modules}-${process.platform}-${process.arch}` diff --git a/tools/bundle.js b/tools/bundle.js index 74578fd9..c52cafcf 100644 --- a/tools/bundle.js +++ b/tools/bundle.js @@ -22,14 +22,15 @@ const fs = require('fs') const path = require('path') -const { exists, mkdirp, formatSize, dirSize, tryExec } = require('./bundle/util.js') +const { exists, mkdirp, formatSize, dirSize } = require('./bundle/util.js') const appTree = require('./bundle/app-tree.js') -// macOS and Windows are planned; the per-platform work is isolated here (a -// module provides NODE_BINARY, assembleRuntime, writeLauncher, archive). -// Windows closure logic to port already exists in scripts/windows-bundle-runtime.sh. +// The per-platform work is isolated here: a module provides NODE_BINARY, +// assembleRuntime, writeLauncher, archive, and optionally stripNode. const PLATFORMS = { linux: () => require('./bundle/platform-linux.js'), + darwin: () => require('./bundle/platform-darwin.js'), + win32: () => require('./bundle/platform-win32.js'), } const HELP = `Usage: node-gtk bundle [app-directory] [options] @@ -58,7 +59,7 @@ function run(argv) { } const platformModule = PLATFORMS[process.platform] if (platformModule === undefined) - throw new Error(`only Linux is supported for now (macOS and Windows are planned) — cannot bundle on '${process.platform}'`) + throw new Error(`cannot bundle on '${process.platform}' — supported platforms: linux, darwin, win32`) bundle(flags, platformModule()) } catch (e) { console.error(`node-gtk bundle: ${e.message}`) @@ -112,9 +113,8 @@ function bundle(flags, platform) { const nodeDest = path.join(ctx.runtimeDir, platform.NODE_BINARY) fs.copyFileSync(nodeSrc, nodeDest) fs.chmodSync(nodeDest, 0o755) - // Distro node binaries carry debug symbols (Arch's is ~140MB, ~110MB of it - // symbols); strip the shipped copy, like ci.sh strips the prebuilt addon. - tryExec(`strip --strip-unneeded ${JSON.stringify(nodeDest)}`) + if (platform.stripNode !== undefined) + platform.stripNode(nodeDest) log(`node: ${process.version} (${formatSize(fs.statSync(nodeDest).size)})`) const launcherPath = platform.writeLauncher(ctx) diff --git a/tools/bundle/app-tree.js b/tools/bundle/app-tree.js index d3e790a1..1b5be37e 100644 --- a/tools/bundle/app-tree.js +++ b/tools/bundle/app-tree.js @@ -158,9 +158,16 @@ function copyNodeGtk(ctx, srcDir, destDir) { return false if (ALWAYS_EXCLUDED_DIRS.has(parts[parts.length - 1])) return false - if (parts[0] === 'lib' && parts[1] === 'binding' - && (ctx.rebuildAddon || (parts.length >= 3 && parts[2] !== bindingName))) - return false + if (parts[0] === 'lib' && parts[1] === 'binding') { + if (ctx.rebuildAddon || (parts.length >= 3 && parts[2] !== bindingName)) + return false + // The binding dir may carry a whole GTK runtime beside the addon (the + // self-contained Windows prebuilt: DLLs, typelibs, icons — see + // windows-bundle-runtime.sh). Only the addon ships; the bundle's + // runtime/ tree replaces the rest. + if (parts.length >= 4 && parts[3] !== 'node_gtk.node') + return false + } return true }, }) diff --git a/tools/bundle/platform-darwin.js b/tools/bundle/platform-darwin.js new file mode 100644 index 00000000..27665e0e --- /dev/null +++ b/tools/bundle/platform-darwin.js @@ -0,0 +1,318 @@ +/* + * platform-darwin.js — macOS runtime assembly, as a .app bundle. + * + * The GTK stack comes from Homebrew. Making it run on machines without + * Homebrew is a two-layer relocation: + * + * 1. install_name_tool rewrites. The dylib closure is walked transitively + * with `otool -L` (resolving @loader_path/@rpath references) and copied + * flat into runtime/lib; then every copied Mach-O gets its Homebrew load + * commands rewritten to @loader_path-relative names, and is ad-hoc + * re-signed (any header edit invalidates the signature, and unsigned + * code does not run on arm64). No LC_RPATH entries are added: -add_rpath + * needs free header padding and can fail, while -change to a shorter + * string always fits — Homebrew paths are long, @loader_path/ is + * short. + * + * 2. DYLD_FALLBACK_LIBRARY_PATH in the launcher. Typelibs bake the build + * machine's *absolute* shared-library paths, so GI g_module_open()s + * /opt/homebrew/... on the target; when that path does not exist, dyld + * retries the leaf name against the fallback path — which points at + * runtime/lib. The same net catches any load command a -change could not + * rewrite. SIP note: DYLD_* variables must be set *inside* the launcher + * script — they survive into our (unprotected) node binary, whereas + * variables inherited into a SIP-protected shell are silently dropped. + * + * The output is unsigned (ad-hoc): fine to run locally or distribute to + * machines that clear quarantine, but real distribution needs Developer ID + * signing + notarization on top (see doc/bundling.md). Bundles are + * per-architecture — build arm64 and x86_64 separately, no universal. + */ + +const fs = require('fs') +const path = require('path') + +const { exec, tryExec, exists, mkdirp, copyFile, formatSize } = require('./util.js') +const { seedNames } = require('./seeds.js') +const { copyTypelibs, copyRuntimeData } = require('./runtime-data.js') + +const NODE_BINARY = 'node' + +// Apple strip has no --strip-unneeded; -x drops local symbols (~half the +// binary for a release node). Stripping invalidates the signature — re-sign +// ad hoc or the binary won't start on arm64. +function stripNode(nodeDest) { + tryExec(`strip -x ${JSON.stringify(nodeDest)}`) + tryExec(`codesign --force --sign - ${JSON.stringify(nodeDest)}`) +} + +function brewPrefix() { + const out = tryExec('brew --prefix') + const guess = out !== undefined ? out.trim() + : (process.arch === 'arm64' ? '/opt/homebrew' : '/usr/local') + if (!exists(path.join(guess, 'lib', 'girepository-1.0'))) + throw new Error(`no GObject-Introspection typelibs under ${guess} — install the GTK stack with Homebrew (brew install gtk4 gobject-introspection)`) + return fs.realpathSync(guess) +} + +function assembleRuntime(ctx) { + const { config, runtimeDir, log } = ctx + const brew = brewPrefix() + const libDir = path.join(runtimeDir, 'lib') + mkdirp(libDir) + + // --- typelibs ------------------------------------------------------------ + copyTypelibs(path.join(brew, 'lib', 'girepository-1.0'), + path.join(libDir, 'girepository-1.0'), log) + + // --- dylib closure --------------------------------------------------------- + const seeds = [] + for (const name of seedNames('darwin', config.libraries, config.gtk)) { + const p = path.join(brew, 'lib', name) + if (exists(p)) + seeds.push(fs.realpathSync(p)) + else + log(` (skip missing seed ${name})`) + } + + const loaders = pixbufLoaders(brew) + const closure = otoolClosure([ctx.bindingPath, ...seeds, ...loaders.files], brew) + + // The seeds themselves are part of the runtime. + for (const p of seeds) + closure.set(path.basename(p), p) + + for (const [name, src] of [...closure].sort()) + copyFile(src, path.join(libDir, name)) + log(`libraries: ${closure.size} dylibs bundled (system libraries stay on the host)`) + + // --- gdk-pixbuf loaders --------------------------------------------------- + const loadersDst = path.join(libDir, 'gdk-pixbuf-2.0', '2.10.0', 'loaders') + if (loaders.files.length > 0) { + for (const file of loaders.files) + copyFile(file, path.join(loadersDst, path.basename(file))) + // Same trick as Linux: the cache format only supports absolute paths, so + // ship a template the launcher materializes for the install location. + if (loaders.cache !== undefined) { + const template = fs.readFileSync(loaders.cache, 'utf8') + .replace(/^"[^"]*[\\/]([^"\\/]+\.(?:so|dylib))"/gm, '"@LOADERS_DIR@/$1"') + fs.writeFileSync(path.join(loadersDst, '..', 'loaders.cache.in'), template) + } + log(`gdk-pixbuf: ${loaders.files.length} loaders`) + } + + // --- relocation (layer 1) -------------------------------------------------- + relocate(ctx, libDir, loadersDst, log) + + // --- GSettings schemas + icon themes -------------------------------------- + copyRuntimeData(ctx, path.join(brew, 'share')) + ensureCompiledSchemas(ctx, brew) +} + +// Union of the transitive dependency closures of `files`: basename -> +// realpath, Homebrew libraries only (/usr/lib and /System always exist on the +// target and stay on the host). otool -L lists one level, so walk a queue. +function otoolClosure(files, brew) { + const closure = new Map() + const queue = files.map(f => fs.realpathSync(f)) + const visited = new Set(queue) + while (queue.length > 0) { + const file = queue.shift() + for (const dep of otoolDeps(file, brew)) { + let real + try { real = fs.realpathSync(dep) } catch (e) { continue } + if (real === file || !real.startsWith(brew + '/')) + continue + if (!closure.has(path.basename(real))) + closure.set(path.basename(real), real) + if (!visited.has(real)) { + visited.add(real) + queue.push(real) + } + } + } + return closure +} + +function otoolDeps(file, brew) { + const out = tryExec(`otool -L ${JSON.stringify(file)}`) + if (out === undefined) + return [] + const deps = [] + // First line is the file itself; a dylib then lists its own install name + // before its dependencies (filtered by the real === file check upstream). + for (const line of out.split('\n').slice(1)) { + const m = /^\s+(\S+)\s+\(compatibility/.exec(line) + if (m === null) + continue + const dep = resolveInstallName(m[1], path.dirname(file), brew) + if (dep !== undefined) + deps.push(dep) + } + return deps +} + +// Turn an install name into a checkable path. @loader_path is relative to +// the *referencing* library; @rpath almost always means /lib for +// Homebrew-built libraries (their LC_RPATH points there). +function resolveInstallName(name, loaderDir, brew) { + if (name.startsWith('@loader_path/')) + return path.resolve(loaderDir, name.slice('@loader_path/'.length)) + if (name.startsWith('@rpath/')) + return path.join(brew, 'lib', path.basename(name)) + if (name.startsWith('@executable_path/')) + return undefined + if (name.startsWith('/usr/lib') || name.startsWith('/System')) + return undefined + return name +} + +// Layer 1: rewrite every copied Mach-O (dylibs, pixbuf loaders, the .node) so +// its bundled dependencies load @loader_path-relative. A failed rewrite (e.g. +// a grown @rpath string exceeding the header padding) is only a warning: the +// launcher's DYLD_FALLBACK_LIBRARY_PATH resolves the leaf name at run time. +function relocate(ctx, libDir, loadersDst, log) { + const bundled = new Set(fs.readdirSync(libDir)) + + const targets = fs.readdirSync(libDir) + .filter(f => f.endsWith('.dylib')) + .map(f => ({ file: path.join(libDir, f), toLib: '@loader_path' })) + if (exists(loadersDst)) { + for (const f of fs.readdirSync(loadersDst)) + targets.push({ file: path.join(loadersDst, f), toLib: '@loader_path/../../..' }) + } + const bindingRel = path.relative(path.dirname(ctx.bindingPath), libDir) + targets.push({ file: ctx.bindingPath, toLib: `@loader_path/${bindingRel}` }) + + let rewritten = 0, failed = 0 + for (const { file, toLib } of targets) { + const out = tryExec(`otool -L ${JSON.stringify(file)}`) + if (out === undefined) + continue + const changes = [] + for (const line of out.split('\n').slice(1)) { + const m = /^\s+(\S+)\s+\(compatibility/.exec(line) + if (m === null) + continue + const name = m[1] + const base = path.basename(name) + if (name.startsWith('/usr/lib') || name.startsWith('/System')) + continue + if (base === path.basename(file) || !bundled.has(base)) + continue + const relocated = `${toLib}/${base}` + if (name !== relocated) + changes.push(`-change ${JSON.stringify(name)} ${JSON.stringify(relocated)}`) + } + if (changes.length === 0) + continue + if (tryExec(`install_name_tool ${changes.join(' ')} ${JSON.stringify(file)}`) !== undefined) + rewritten += 1 + else + failed += 1 + tryExec(`codesign --force --sign - ${JSON.stringify(file)}`) + } + log(`relocation: ${rewritten} Mach-O files rewritten to @loader_path` + + (failed > 0 ? ` (${failed} failed — the launcher's DYLD fallback covers them)` : '')) +} + +// Homebrew's glib compiles schemas post-install, but not every machine has a +// current gschemas.compiled; build one into the bundle when it is missing. +function ensureCompiledSchemas(ctx, brew) { + const dst = path.join(ctx.runtimeDir, 'share', 'glib-2.0', 'schemas') + if (exists(path.join(dst, 'gschemas.compiled'))) + return + const src = path.join(brew, 'share', 'glib-2.0', 'schemas') + if (!exists(src)) + return + mkdirp(dst) + if (tryExec(`glib-compile-schemas ${JSON.stringify(src)} --targetdir ${JSON.stringify(dst)}`) === undefined) + ctx.log(' (could not compile GSettings schemas — GTK apps may warn at startup)') +} + +function pixbufLoaders(brew) { + const moduleDir = path.join(brew, 'lib', 'gdk-pixbuf-2.0', '2.10.0', 'loaders') + if (!exists(moduleDir)) + return { files: [] } + const files = fs.readdirSync(moduleDir) + .filter(f => f.endsWith('.so') || f.endsWith('.dylib')) + .map(f => fs.realpathSync(path.join(moduleDir, f))) + const cache = path.join(moduleDir, '..', 'loaders.cache') + return { files, cache: exists(cache) ? cache : undefined } +} + +function writeLauncher(ctx) { + const { config, contentsDir } = ctx + writeInfoPlist(ctx) + + const macosDir = path.join(contentsDir, 'MacOS') + mkdirp(macosDir) + const args = [...(config.register ? ['--import', 'node-gtk/register'] : []), ...config.nodeArgs] + const nodeArgs = args.length > 0 ? args.join(' ') + ' ' : '' + const launcherPath = path.join(macosDir, config.name) + const entry = config.entry.split(path.sep).join('/') + + fs.writeFileSync(launcherPath, `#!/bin/sh +# ${config.name} launcher — generated by \`node-gtk bundle\`. +# Wires the bundled GTK runtime, then execs the bundled node on the app entry. +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +RES=$(CDPATH= cd -- "$HERE/../Resources" && pwd) +RT="$RES/runtime" + +# DYLD_* must be exported HERE: SIP strips them from protected parent +# processes, but they survive from this script into the (unprotected) bundled +# node. The fallback path is what resolves the absolute Homebrew paths baked +# into typelibs — and any unrewritten load command — down to runtime/lib. +export DYLD_FALLBACK_LIBRARY_PATH="$RT/lib:/usr/local/lib:/usr/lib" +export GI_TYPELIB_PATH="$RT/lib/girepository-1.0\${GI_TYPELIB_PATH:+:$GI_TYPELIB_PATH}" +export XDG_DATA_DIRS="$RT/share:\${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" +export GSETTINGS_SCHEMA_DIR="$RT/share/glib-2.0/schemas" + +# The loader cache format only supports absolute paths; materialize one for +# this install location in the user's cache dir. +LOADERS_DIR="$RT/lib/gdk-pixbuf-2.0/2.10.0/loaders" +if [ -f "$LOADERS_DIR/../loaders.cache.in" ]; then + GDK_PIXBUF_MODULE_FILE="\${XDG_CACHE_HOME:-$HOME/Library/Caches}/${config.id}/loaders.cache" + mkdir -p "$(dirname "$GDK_PIXBUF_MODULE_FILE")" + sed "s|@LOADERS_DIR@|$LOADERS_DIR|g" "$LOADERS_DIR/../loaders.cache.in" > "$GDK_PIXBUF_MODULE_FILE" + export GDK_PIXBUF_MODULE_FILE +fi + +# Run from app/ so bare-specifier node args (--import node-gtk/register) and +# the app's own relative paths resolve regardless of the invoking cwd. +cd "$RES/app" || exit 1 +exec "$RT/node" ${nodeArgs}"./${entry}" "$@" +`) + fs.chmodSync(launcherPath, 0o755) + return launcherPath +} + +function writeInfoPlist(ctx) { + const { config, contentsDir } = ctx + mkdirp(contentsDir) + fs.writeFileSync(path.join(contentsDir, 'Info.plist'), ` + + + + CFBundlePackageTypeAPPL + CFBundleName${config.name} + CFBundleDisplayName${config.name} + CFBundleIdentifier${config.id} + CFBundleExecutable${config.name} + CFBundleShortVersionString${config.version} + NSHighResolutionCapable + LSMinimumSystemVersion11.0 + + +`) +} + +function archive(ctx) { + const { config, outBase, log } = ctx + const archivePath = `${outBase}.dmg` + exec(`hdiutil create -volname ${JSON.stringify(config.name)} -srcfolder ${JSON.stringify(outBase)} -ov -format UDZO ${JSON.stringify(archivePath)}`) + log(`archive: ${archivePath} (${formatSize(fs.statSync(archivePath).size)})`) + return archivePath +} + +module.exports = { NODE_BINARY, stripNode, assembleRuntime, writeLauncher, archive } diff --git a/tools/bundle/platform-linux.js b/tools/bundle/platform-linux.js index 271a9147..b7249278 100644 --- a/tools/bundle/platform-linux.js +++ b/tools/bundle/platform-linux.js @@ -21,11 +21,18 @@ const fs = require('fs') const path = require('path') -const { exec, tryExec, exists, mkdirp, copyFile, copyTree, formatSize } = require('./util.js') +const { exec, tryExec, exists, mkdirp, copyFile, formatSize } = require('./util.js') const { seedNames, isExcludedLinux } = require('./seeds.js') +const { copyTypelibs, copyRuntimeData, pkgConfigVar } = require('./runtime-data.js') const NODE_BINARY = 'node' +// Distro node binaries carry debug symbols (Arch's is ~140MB, ~110MB of it +// symbols); strip the shipped copy, like ci.sh strips the prebuilt addon. +function stripNode(nodeDest) { + tryExec(`strip --strip-unneeded ${JSON.stringify(nodeDest)}`) +} + function assembleRuntime(ctx) { const { config, runtimeDir, log } = ctx const libDir = path.join(runtimeDir, 'lib') @@ -87,37 +94,6 @@ function assembleRuntime(ctx) { copyRuntimeData(ctx, '/usr/share') } -function copyTypelibs(src, dst, log) { - if (!exists(src)) - throw new Error(`typelib directory not found: ${src} — is gobject-introspection installed?`) - mkdirp(dst) - const typelibs = fs.readdirSync(src).filter(f => f.endsWith('.typelib')) - for (const f of typelibs) - copyFile(path.join(src, f), path.join(dst, f)) - log(`typelibs: ${typelibs.length} from ${src}`) -} - -// Shared runtime data (identical logic for all prefixes): compiled GSettings -// schemas and the Adwaita/hicolor icon themes. -function copyRuntimeData(ctx, sharePrefix) { - const { config, runtimeDir, log } = ctx - const shareDst = path.join(runtimeDir, 'share') - - const schemas = path.join(sharePrefix, 'glib-2.0', 'schemas', 'gschemas.compiled') - if (exists(schemas)) - copyFile(schemas, path.join(shareDst, 'glib-2.0', 'schemas', 'gschemas.compiled')) - else - log(` (no compiled GSettings schemas at ${schemas})`) - - if (config.icons) { - for (const theme of ['Adwaita', 'hicolor']) { - const src = path.join(sharePrefix, 'icons', theme) - if (exists(src)) - copyTree(src, path.join(shareDst, 'icons', theme)) - } - } -} - // name -> path for every library in the loader cache, matching the current // architecture when ldconfig tags one. function ldconfigCache() { @@ -167,12 +143,6 @@ function pixbufLoaders() { return { files, cache: exists(cache) ? cache : undefined } } -function pkgConfigVar(pkg, variable) { - const out = tryExec(`pkg-config --variable=${variable} ${pkg}`) - const value = out !== undefined ? out.trim() : '' - return value !== '' ? value : undefined -} - function writeLauncher(ctx) { const { config, outBase } = ctx const args = [...(config.register ? ['--import', 'node-gtk/register'] : []), ...config.nodeArgs] @@ -218,4 +188,4 @@ function archive(ctx) { return archivePath } -module.exports = { NODE_BINARY, assembleRuntime, writeLauncher, archive } +module.exports = { NODE_BINARY, stripNode, assembleRuntime, writeLauncher, archive } diff --git a/tools/bundle/platform-win32.js b/tools/bundle/platform-win32.js new file mode 100644 index 00000000..173ce102 --- /dev/null +++ b/tools/bundle/platform-win32.js @@ -0,0 +1,177 @@ +/* + * platform-win32.js — Windows runtime assembly. + * + * Bundling requires an MSYS2 MINGW64 environment (where GTK is installed and + * node-gtk was built), running under the *Windows* node — the same setup that + * builds node-gtk itself. The produced bundle has no such requirement: it + * runs on a stock Windows machine. + * + * The DLL closure is computed with `ntldd -R` (transitive), seeded from the + * compiled addon, the GI namespace libraries (seeds.js) and the gdk-pixbuf + * loaders — a port of scripts/windows-bundle-runtime.sh, the logic that makes + * the npm prebuilt self-contained (CI-proven by test-windows-prebuilt.yaml). + * OS DLLs (System32) stay on the host; everything under the MinGW prefix + * ships. MSVC node.exe + MinGW GTK DLLs interop fine — plain C ABI. + * + * The launcher is a .cmd batch file that puts runtime/lib (and the pixbuf + * loaders dir) on PATH — the Windows DLL search path — wires the GI/GLib + * environment, then runs the bundled node.exe on the app entry. + */ + +const fs = require('fs') +const path = require('path') + +const { exec, tryExec, exists, mkdirp, copyFile, formatSize } = require('./util.js') +const { seedNames } = require('./seeds.js') +const { copyTypelibs, copyRuntimeData } = require('./runtime-data.js') + +const NODE_BINARY = 'node.exe' + +// No stripNode: node.exe is an MSVC binary; binutils strip would only +// invalidate its signature/debug directory for no size win. + +// The MinGW prefix as a Windows path (C:/msys64/mingw64). MINGW_PREFIX is the +// POSIX-style prefix exported by MSYS2 shells; cygpath converts it — and its +// availability doubles as the "are we in MSYS2?" check. +function mingwPrefix() { + const posix = process.env.MINGW_PREFIX || '/mingw64' + const out = tryExec(`cygpath -m ${posix}`) + const prefix = out !== undefined ? out.trim() : '' + if (prefix === '' || !exists(prefix)) + throw new Error('MinGW environment not found — run the bundler from an MSYS2 MINGW64 shell (with GTK installed and node-gtk built)') + return prefix +} + +function assembleRuntime(ctx) { + const { config, runtimeDir, log } = ctx + const prefix = mingwPrefix() + const libDir = path.join(runtimeDir, 'lib') + mkdirp(libDir) + + // --- typelibs ------------------------------------------------------------ + copyTypelibs(path.join(prefix, 'lib', 'girepository-1.0'), + path.join(libDir, 'girepository-1.0'), log) + + // --- DLL closure ----------------------------------------------------------- + const seeds = [] + for (const name of seedNames('win32', config.libraries, config.gtk)) { + const p = path.join(prefix, 'bin', name) + if (exists(p)) + seeds.push(p) + else + log(` (skip missing seed ${name})`) + } + + const loaders = pixbufLoaders(prefix) + const closure = ntlddClosure([ctx.bindingPath, ...seeds, ...loaders.files], prefix) + + // The seeds themselves are part of the runtime. + for (const p of seeds) + closure.set(path.basename(p).toLowerCase(), p) + + for (const [, src] of [...closure].sort()) + copyFile(src, path.join(libDir, path.basename(src))) + log(`libraries: ${closure.size} DLLs bundled (OS DLLs stay on the host)`) + + // --- gdk-pixbuf loaders --------------------------------------------------- + if (loaders.files.length > 0) { + const loadersDst = path.join(libDir, 'gdk-pixbuf-2.0', '2.10.0', 'loaders') + for (const file of loaders.files) + copyFile(file, path.join(loadersDst, path.basename(file))) + // The cache references loaders by absolute build-machine path; rewrite + // them to bare file names — the launcher puts the loaders dir on PATH, so + // g_module_open() resolves the bare name at run time (exactly what + // lib/native.js + windows-bundle-runtime.sh do for the prebuilt). + if (loaders.cache !== undefined) { + const cache = fs.readFileSync(loaders.cache, 'utf8') + .replace(/^"[^"]*[\\/]([^"\\/]+\.dll)"/gm, '"$1"') + fs.writeFileSync(path.join(loadersDst, '..', 'loaders.cache'), cache) + } + log(`gdk-pixbuf: ${loaders.files.length} loaders`) + } + + // --- GSettings schemas + icon themes -------------------------------------- + copyRuntimeData(ctx, path.join(prefix, 'share')) +} + +// Union of the transitive DLL closures of `files`: lowercased basename -> +// source path. Only MinGW-provided DLLs are kept — anything outside the +// prefix (System32, the host node) must come from the target machine. +function ntlddClosure(files, prefix) { + const prefixKey = normalize(prefix) + '/' + const closure = new Map() + for (const file of files) { + const out = tryExec(`ntldd -R ${JSON.stringify(file)}`) + if (out === undefined) + continue + for (const line of out.split('\n')) { + const m = /=>\s+(.+?)\s+\(0x[0-9a-fA-F]+\)/.exec(line) + if (m === null) + continue + const dllPath = m[1].trim() + const key = normalize(dllPath) + if (!key.startsWith(prefixKey)) + continue + const name = path.basename(key) + if (!closure.has(name)) + closure.set(name, dllPath) + } + } + return closure +} + +// Windows paths compare case-insensitively and ntldd mixes \ and /. +function normalize(p) { + return p.replace(/\\/g, '/').toLowerCase() +} + +function pixbufLoaders(prefix) { + const moduleDir = path.join(prefix, 'lib', 'gdk-pixbuf-2.0', '2.10.0', 'loaders') + if (!exists(moduleDir)) + return { files: [] } + const files = fs.readdirSync(moduleDir) + .filter(f => f.endsWith('.dll')) + .map(f => path.join(moduleDir, f)) + const cache = path.join(moduleDir, '..', 'loaders.cache') + return { files, cache: exists(cache) ? cache : undefined } +} + +function writeLauncher(ctx) { + const { config, outBase } = ctx + const args = [...(config.register ? ['--import', 'node-gtk/register'] : []), ...config.nodeArgs] + const nodeArgs = args.length > 0 ? args.join(' ') + ' ' : '' + const launcherPath = path.join(outBase, `${config.name}.cmd`) + const entry = config.entry.split(path.sep).join('\\') + + const lines = [ + '@echo off', + `rem ${config.name} launcher - generated by \`node-gtk bundle\`.`, + 'rem Wires the bundled GTK runtime (PATH is the Windows DLL search path),', + 'rem then runs the bundled node.exe on the app entry.', + 'setlocal', + 'set "HERE=%~dp0"', + 'set "RT=%HERE%runtime"', + 'set "PATH=%RT%\\lib;%RT%\\lib\\gdk-pixbuf-2.0\\2.10.0\\loaders;%PATH%"', + 'set "GI_TYPELIB_PATH=%RT%\\lib\\girepository-1.0"', + 'set "XDG_DATA_DIRS=%RT%\\share"', + 'set "GSETTINGS_SCHEMA_DIR=%RT%\\share\\glib-2.0\\schemas"', + 'set "GDK_PIXBUF_MODULE_FILE=%RT%\\lib\\gdk-pixbuf-2.0\\2.10.0\\loaders.cache"', + 'rem Run from app\\ so bare-specifier node args (--import node-gtk/register)', + 'rem and the app\'s own relative paths resolve regardless of the invoking cwd.', + 'cd /d "%HERE%app"', + `"%RT%\\node.exe" ${nodeArgs}".\\${entry}" %*`, + ] + // CRLF: cmd.exe misparses batch files with bare-LF line endings. + fs.writeFileSync(launcherPath, lines.join('\r\n') + '\r\n') + return launcherPath +} + +function archive(ctx) { + const { outBase, log } = ctx + const archivePath = `${outBase}.zip` + exec(`powershell.exe -NoProfile -Command "Compress-Archive -LiteralPath '${outBase}' -DestinationPath '${archivePath}' -Force"`) + log(`archive: ${archivePath} (${formatSize(fs.statSync(archivePath).size)})`) + return archivePath +} + +module.exports = { NODE_BINARY, assembleRuntime, writeLauncher, archive } diff --git a/tools/bundle/runtime-data.js b/tools/bundle/runtime-data.js new file mode 100644 index 00000000..4339978f --- /dev/null +++ b/tools/bundle/runtime-data.js @@ -0,0 +1,50 @@ +/* + * runtime-data.js — GTK runtime pieces every platform bundles the same way: + * the GObject-Introspection typelibs, compiled GSettings schemas and the + * Adwaita/hicolor icon themes. Only the source prefix differs per platform + * (/usr, the MinGW prefix, the Homebrew prefix). + */ + +const fs = require('fs') +const path = require('path') + +const { tryExec, exists, mkdirp, copyFile, copyTree } = require('./util.js') + +function copyTypelibs(src, dst, log) { + if (!exists(src)) + throw new Error(`typelib directory not found: ${src} — is gobject-introspection installed?`) + mkdirp(dst) + const typelibs = fs.readdirSync(src).filter(f => f.endsWith('.typelib')) + for (const f of typelibs) + copyFile(path.join(src, f), path.join(dst, f)) + log(`typelibs: ${typelibs.length} from ${src}`) +} + +// Shared runtime data (identical logic for all prefixes): compiled GSettings +// schemas and the Adwaita/hicolor icon themes. +function copyRuntimeData(ctx, sharePrefix) { + const { config, runtimeDir, log } = ctx + const shareDst = path.join(runtimeDir, 'share') + + const schemas = path.join(sharePrefix, 'glib-2.0', 'schemas', 'gschemas.compiled') + if (exists(schemas)) + copyFile(schemas, path.join(shareDst, 'glib-2.0', 'schemas', 'gschemas.compiled')) + else + log(` (no compiled GSettings schemas at ${schemas})`) + + if (config.icons) { + for (const theme of ['Adwaita', 'hicolor']) { + const src = path.join(sharePrefix, 'icons', theme) + if (exists(src)) + copyTree(src, path.join(shareDst, 'icons', theme)) + } + } +} + +function pkgConfigVar(pkg, variable) { + const out = tryExec(`pkg-config --variable=${variable} ${pkg}`) + const value = out !== undefined ? out.trim() : '' + return value !== '' ? value : undefined +} + +module.exports = { copyTypelibs, copyRuntimeData, pkgConfigVar } From afe72130ea7f04523c9165f210f53f6c6f26b7d3 Mon Sep 17 00:00:00 2001 From: Rom Grk Date: Sun, 5 Jul 2026 16:19:15 -0400 Subject: [PATCH 2/2] fix: darwin bundle copies must be writable and deduped Homebrew installs Cellar files read-only (0444). copyFile preserves the source mode, so a) the pixbuf loaders dir's multiple symlink entries for one Cellar file made the second copy EACCES on the read-only first one, and b) install_name_tool/codesign would have failed to rewrite every copied dylib in place. Copy libraries with 0755 and dedupe the realpath'd loader list. Co-Authored-By: Claude Fable 5 --- tools/bundle/platform-darwin.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tools/bundle/platform-darwin.js b/tools/bundle/platform-darwin.js index 27665e0e..34f8eea5 100644 --- a/tools/bundle/platform-darwin.js +++ b/tools/bundle/platform-darwin.js @@ -83,14 +83,14 @@ function assembleRuntime(ctx) { closure.set(path.basename(p), p) for (const [name, src] of [...closure].sort()) - copyFile(src, path.join(libDir, name)) + copyLibrary(src, path.join(libDir, name)) log(`libraries: ${closure.size} dylibs bundled (system libraries stay on the host)`) // --- gdk-pixbuf loaders --------------------------------------------------- const loadersDst = path.join(libDir, 'gdk-pixbuf-2.0', '2.10.0', 'loaders') if (loaders.files.length > 0) { for (const file of loaders.files) - copyFile(file, path.join(loadersDst, path.basename(file))) + copyLibrary(file, path.join(loadersDst, path.basename(file))) // Same trick as Linux: the cache format only supports absolute paths, so // ship a template the launcher materializes for the install location. if (loaders.cache !== undefined) { @@ -109,6 +109,14 @@ function assembleRuntime(ctx) { ensureCompiledSchemas(ctx, brew) } +// Homebrew installs Cellar files read-only (0444), a mode copyFile would +// preserve; the copies must stay writable — install_name_tool and codesign +// rewrite them in place (and re-copying over a read-only file is EACCES). +function copyLibrary(src, dest) { + copyFile(src, dest) + fs.chmodSync(dest, 0o755) +} + // Union of the transitive dependency closures of `files`: basename -> // realpath, Homebrew libraries only (/usr/lib and /System always exist on the // target and stay on the host). otool -L lists one level, so walk a queue. @@ -234,9 +242,11 @@ function pixbufLoaders(brew) { const moduleDir = path.join(brew, 'lib', 'gdk-pixbuf-2.0', '2.10.0', 'loaders') if (!exists(moduleDir)) return { files: [] } - const files = fs.readdirSync(moduleDir) + // Dedupe after realpath: Homebrew's loaders dir can hold several directory + // entries (symlinks) for one Cellar file. + const files = [...new Set(fs.readdirSync(moduleDir) .filter(f => f.endsWith('.so') || f.endsWith('.dylib')) - .map(f => fs.realpathSync(path.join(moduleDir, f))) + .map(f => fs.realpathSync(path.join(moduleDir, f))))] const cache = path.join(moduleDir, '..', 'loaders.cache') return { files, cache: exists(cache) ? cache : undefined } }