|
| 1 | +# HANDOFF — `node-gtk bundle` for Windows and macOS |
| 2 | + |
| 3 | +**Mission**: implement the Windows and macOS platform modules for |
| 4 | +`node-gtk bundle`, so the portable-bundle story covers all three desktop |
| 5 | +platforms. Linux (`bundle` + `flatpak`) shipped in PR #489 (master `e9b9c4e`); |
| 6 | +read `doc/bundling.md` first — its Roadmap section is this document's summary. |
| 7 | + |
| 8 | +Everything below was learned or designed while building the Linux path; |
| 9 | +Windows/macOS module drafts existed during that work and were validated in |
| 10 | +design (not in execution), so treat the specifics as strong hints, not gospel. |
| 11 | + |
| 12 | +## Architecture you are plugging into |
| 13 | + |
| 14 | +`tools/bundle.js` orchestrates; per-OS work lives behind one interface in |
| 15 | +`tools/bundle/platform-<os>.js`: |
| 16 | + |
| 17 | +```js |
| 18 | +module.exports = { |
| 19 | + NODE_BINARY, // 'node' | 'node.exe' |
| 20 | + assembleRuntime(ctx), // libraries + typelibs + schemas/icons/loaders → ctx.runtimeDir |
| 21 | + writeLauncher(ctx), // returns launcher path |
| 22 | + archive(ctx), // .tar.gz / .zip / .dmg |
| 23 | +} |
| 24 | +``` |
| 25 | + |
| 26 | +Register the module in the `PLATFORMS` map in `tools/bundle.js`. Everything |
| 27 | +else is shared and already works cross-platform: |
| 28 | + |
| 29 | +- `tools/bundle/app-tree.js` — app files + production node_modules |
| 30 | + (pnpm-safe), node-gtk trimmed to `package.json` + `lib/` + the one |
| 31 | + `lib/binding/node-v<abi>-<platform>-<arch>` matching the bundling node. |
| 32 | + `ctx.bindingPath` is the copied `.node` — seed your closure walk from it. |
| 33 | +- `tools/bundle/seeds.js` — the GI namespace libraries to bundle, **already |
| 34 | + carrying the win32/darwin file names** (they mirror |
| 35 | + `windows/runtime-libraries.txt`). Every seed is skip-if-missing. |
| 36 | +- Config (`loadConfig`), output layout, marker/overwrite safety, node-binary |
| 37 | + copy + strip, size report, `bundle.json` manifest. |
| 38 | +- The launcher must include `--import node-gtk/register` when |
| 39 | + `config.register` (default true) — see the Linux/flatpak launchers. |
| 40 | + |
| 41 | +The `ctx` fields you get: `config, appDir, outBase, runtimeDir, appOutDir, |
| 42 | +bindingName, bindingPath, log` (+ on darwin you must set/use `ctx.contentsDir` |
| 43 | +— `tools/bundle.js` already computes the `.app/Contents` layout for darwin; |
| 44 | +check the `process.platform === 'darwin'` branch). |
| 45 | + |
| 46 | +## Windows (`platform-win32.js`) — mostly a port of proven code |
| 47 | + |
| 48 | +`scripts/windows-bundle-runtime.sh` is the **CI-proven reference** (it makes |
| 49 | +the npm prebuilt self-contained; test-windows-prebuilt.yaml validated a |
| 50 | +no-MSYS2 clean machine). Port its logic to JS; the app bundler is a superset |
| 51 | +(adds node.exe + app tree + launcher). |
| 52 | + |
| 53 | +- **Environment**: runs inside an MSYS2 MINGW64 shell (where GTK is installed |
| 54 | + and node-gtk was built) but under the *Windows* node. Resolve the prefix: |
| 55 | + `cygpath -m ${MINGW_PREFIX:-/mingw64}` → `C:/msys64/mingw64`; error with a |
| 56 | + "run from MINGW64" message if absent. |
| 57 | +- **DLL closure**: `ntldd -R <file>` per entry (the `.node` + seeds from |
| 58 | + `seedNames('win32', config.libraries, config.gtk)` resolved against |
| 59 | + `<prefix>/bin` + the gdk-pixbuf loader DLLs). Parse `=> C:\...` lines, keep |
| 60 | + only paths under the prefix (System32 stays on the host), copy to |
| 61 | + `runtime/lib` keyed by lowercased basename. |
| 62 | +- **Typelibs**: `<prefix>/lib/girepository-1.0/*.typelib` → `runtime/lib/girepository-1.0`. |
| 63 | +- **gdk-pixbuf loaders**: `<prefix>/lib/gdk-pixbuf-2.0/2.10.0/loaders/*.dll`; |
| 64 | + rewrite `loaders.cache` loader paths to **bare file names** |
| 65 | + (`.replace(/^"[^"]*[\\/]([^"\\/]+\.dll)"/gm, '"$1"')`) and put the loaders |
| 66 | + dir on PATH in the launcher — this is exactly what `lib/native.js` + |
| 67 | + `windows-bundle-runtime.sh` already do for the prebuilt. |
| 68 | +- **Runtime data**: `<prefix>/share/{glib-2.0/schemas/gschemas.compiled,icons/Adwaita,icons/hicolor}`. |
| 69 | +- **node**: copy `process.execPath` → `runtime/node.exe` (MSVC node + MinGW |
| 70 | + GTK DLLs interop fine — C ABI; the prebuilt already proves it). |
| 71 | +- **Launcher** `<Name>.cmd` (CRLF): |
| 72 | + ``` |
| 73 | + @echo off |
| 74 | + setlocal |
| 75 | + set "HERE=%~dp0" |
| 76 | + set "RT=%HERE%runtime" |
| 77 | + set "PATH=%RT%\lib;%RT%\lib\gdk-pixbuf-2.0\2.10.0\loaders;%PATH%" |
| 78 | + set "GI_TYPELIB_PATH=%RT%\lib\girepository-1.0" |
| 79 | + set "XDG_DATA_DIRS=%RT%\share" |
| 80 | + set "GSETTINGS_SCHEMA_DIR=%RT%\share\glib-2.0\schemas" |
| 81 | + set "GDK_PIXBUF_MODULE_FILE=%RT%\lib\gdk-pixbuf-2.0\2.10.0\loaders.cache" |
| 82 | + cd /d "%HERE%app" |
| 83 | + "%RT%\node.exe" --import node-gtk/register ".\<entry>" %* |
| 84 | + ``` |
| 85 | + (`cd` into app/ so bare-specifier `--import`s resolve — same reasoning as |
| 86 | + the Linux launcher.) |
| 87 | +- **Archive**: `powershell.exe -NoProfile -Command Compress-Archive ...` (zip). |
| 88 | +- **CI**: main.yaml's `build-windows` job already builds in MINGW64 with GTK4 |
| 89 | + installed. Add a bundle-smoke step for one node version; |
| 90 | + `scripts/bundle-smoke-test.js` already handles win32 (junction symlink, |
| 91 | + `cmd.exe /c` launcher spawn) — just delete its early platform gate as you |
| 92 | + add support. Runners have a desktop session, no xvfb needed. |
| 93 | + |
| 94 | +## macOS (`platform-darwin.js`) — the genuinely new work |
| 95 | + |
| 96 | +GTK comes from Homebrew (CI installs gtk3 via brew today; gtk4 works too). |
| 97 | +Two-layer relocation design: |
| 98 | + |
| 99 | +1. **Closure**: walk `otool -L` transitively from the `.node` + seeds |
| 100 | + (`<brew>/lib/<name>`, realpath'd) + pixbuf loaders. Resolve |
| 101 | + `@loader_path/...` against the referencing lib's dir; `@rpath/x` → |
| 102 | + `<brew>/lib/x` heuristic; drop `/usr/lib` + `/System`. Keep only realpaths |
| 103 | + under the brew prefix. Copy flat (basename) into `runtime/lib`. |
| 104 | +2. **Rewrite** (`install_name_tool`): for every copied dylib, loader, and the |
| 105 | + `.node`, `-change <brew-path> <relative>/<basename>` where relative is |
| 106 | + `@loader_path` for dylibs (all siblings), `@loader_path/../../..` for |
| 107 | + loaders, and `@loader_path/<computed-rel-to-runtime-lib>` for the `.node` |
| 108 | + (compute with `path.relative`). Do **not** add LC_RPATH entries — |
| 109 | + `-add_rpath` needs header padding and can fail; `-change` to a *shorter* |
| 110 | + string always fits. **Re-sign every touched file ad-hoc** |
| 111 | + (`codesign --force --sign -`) — mandatory on arm64. Where a rewrite fails, |
| 112 | + warn and rely on layer 3. |
| 113 | +3. **Launcher env fallback**: `DYLD_FALLBACK_LIBRARY_PATH="$RT/lib:/usr/local/lib:/usr/lib"`. |
| 114 | + Crucial detail: brew **typelibs bake absolute dylib paths**; when GI |
| 115 | + `g_module_open`s a missing absolute path, dyld retries the leaf name |
| 116 | + against the fallback path — that's what makes the bundle work on machines |
| 117 | + without brew. SIP note: DYLD_* vars set *inside* the launcher script |
| 118 | + survive into our (unprotected) node binary; vars inherited *into* a |
| 119 | + protected shell do not — so set them in the script, never rely on the |
| 120 | + caller's env. |
| 121 | +- **Layout**: `<outBase>/<Name>.app/Contents/{MacOS/<Name>,Info.plist,Resources/{runtime,app}}` |
| 122 | + (bundle.js already computes these ctx paths). Minimal Info.plist: |
| 123 | + CFBundlePackageType APPL, Name/DisplayName/Identifier/Executable, |
| 124 | + ShortVersionString, NSHighResolutionCapable, LSMinimumSystemVersion 11.0. |
| 125 | +- **Runtime data**: `<brew>/share/{glib-2.0/schemas,icons/...}`; if |
| 126 | + `gschemas.compiled` is missing run `glib-compile-schemas`. pixbuf loaders |
| 127 | + under `<brew>/lib/gdk-pixbuf-2.0/2.10.0/loaders` (`.so`/`.dylib`); use the |
| 128 | + same `@LOADERS_DIR@` cache-template + launcher-sed trick as Linux |
| 129 | + (`platform-linux.js`). |
| 130 | +- **Archive**: `hdiutil create -volname <Name> -srcfolder <outBase> -ov -format UDZO <out>.dmg`. |
| 131 | +- **node strip**: `strip -x` on darwin (see ci.sh), then ad-hoc re-sign. |
| 132 | +- **CI**: the macos job has brew GTK3; the smoke fixture already falls back |
| 133 | + Gtk4→Gtk3. Caveat: the runner *has* brew, so the DYLD-fallback path isn't |
| 134 | + truly exercised there — a follow-up "consume on clean machine" job (rename |
| 135 | + the brew prefix, or a second runner without brew) is the honest test, |
| 136 | + mirroring test-windows-prebuilt.yaml's two-job pattern. |
| 137 | +- **Distribution reality**: output is unsigned; document that shipping needs |
| 138 | + Developer ID signing + notarization (out of scope for the module itself). |
| 139 | +- Per-arch bundles (arm64 + x86_64 separately) — don't attempt universal. |
| 140 | + |
| 141 | +## Shared follow-ups (not yours, but don't preclude them) |
| 142 | + |
| 143 | +AppImage wrapper around the Linux tree; shared-runtime install (`runtime/` is |
| 144 | +already content-addressed-friendly); Flathub submission of a first real app. |
| 145 | + |
| 146 | +## Validation bar (what "done" meant for Linux) |
| 147 | + |
| 148 | +1. `scripts/bundle-smoke-test.js` green on the target OS in CI (it asserts |
| 149 | + the app ran under the **bundled** node). |
| 150 | +2. A real app bundled and launched by hand at least once |
| 151 | + (`~/src/mariner` is the guinea pig; its node_modules/node-gtk is a symlink |
| 152 | + — point it at your worktree). |
| 153 | +3. Size report sane; no host-path leakage (Linux used `LD_DEBUG=libs`; use |
| 154 | + `DYLD_PRINT_LIBRARIES=1` / Process Monitor equivalents). |
| 155 | +4. Docs: extend `doc/bundling.md` (platform sections + caveats), drop the |
| 156 | + "Linux today" banners, update the Roadmap. |
| 157 | + |
| 158 | +## Process notes |
| 159 | + |
| 160 | +- Worktrees in `~/worktrees`, `pnpm install` (no node_modules symlinks), |
| 161 | + explicit `git add` (never `-A`), Conventional Commits, PR against master, |
| 162 | + merge only on `gh run view --json conclusion` == success. |
| 163 | +- Project memory (`~/.claude/projects/-home-romgrk-src-node-gtk/memory/`) |
| 164 | + has the full Linux war story under `project_bundle_cli.md`. |
0 commit comments