From a8a4bc24d220e77aa70a99f53423bad46e19f586 Mon Sep 17 00:00:00 2001 From: Rom Grk Date: Sun, 5 Jul 2026 00:20:15 -0400 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20`node-gtk=20bundle`=20=E2=80=94=20s?= =?UTF-8?q?elf-contained=20Linux=20app=20bundles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Package a node-gtk application into a directory that runs on machines with no node, no GTK and no compiler: app code + production node_modules (pnpm symlinks dereferenced, node-gtk trimmed to runtime files) + stripped node binary + the GTK runtime — ldd shared-library closure walked from the addon, the GI namespace libraries and the gdk-pixbuf loaders (host-tied libraries excluded per the AppImage excludelist), typelibs, compiled GSettings schemas and icon themes. A generated sh launcher wires the environment and execs the bundled node; --archive produces a .tar.gz (~100 MB for a GTK4 app). The runtime/ vs app/ split keeps the runtime content-identical across apps for future sharing; macOS/Windows land later as drop-in platform modules (the Windows DLL closure is already proven by scripts/windows-bundle-runtime.sh). Tested end-to-end by scripts/bundle-smoke-test.js, wired into CI on ubuntu (GTK4-with-GTK3-fallback fixture, since runners only have GTK3). Co-Authored-By: Claude Fable 5 --- .github/workflows/main.yaml | 8 ++ README.md | 8 ++ bin/node-gtk.js | 5 + doc/bundling.md | 144 ++++++++++++++++++++ doc/index.md | 6 + scripts/bundle-smoke-test.js | 103 +++++++++++++++ tools/bundle.js | 235 +++++++++++++++++++++++++++++++++ tools/bundle/app-tree.js | 161 ++++++++++++++++++++++ tools/bundle/platform-linux.js | 220 ++++++++++++++++++++++++++++++ tools/bundle/seeds.js | 106 +++++++++++++++ tools/bundle/util.js | 78 +++++++++++ 11 files changed, 1074 insertions(+) create mode 100644 doc/bundling.md create mode 100644 scripts/bundle-smoke-test.js create mode 100644 tools/bundle.js create mode 100644 tools/bundle/app-tree.js create mode 100644 tools/bundle/platform-linux.js create mode 100644 tools/bundle/seeds.js create mode 100644 tools/bundle/util.js diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 569fef30..ea3215f6 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -145,3 +145,11 @@ jobs: COMMIT_MESSAGE: ${{ github.event.head_commit.message }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 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). + - name: Bundle smoke test + if: matrix.os == 'ubuntu-latest' && matrix.node == 24 + run: xvfb-run -a node scripts/bundle-smoke-test.js diff --git a/README.md b/README.md index 1d74614a..011ba38e 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,14 @@ 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, the **bundle** tool packages your app, node, node-gtk and +the whole GTK runtime into a self-contained directory that runs on machines with +nothing installed (Linux for now — see [doc/bundling.md](./doc/bundling.md)): + +```sh +npx node-gtk bundle --archive +``` + ## Installing There are two steps: diff --git a/bin/node-gtk.js b/bin/node-gtk.js index acf09163..0d88aaa6 100755 --- a/bin/node-gtk.js +++ b/bin/node-gtk.js @@ -6,6 +6,7 @@ * generate-types Generate TypeScript declarations from the installed typelibs. * create Create a new GTK/Adwaita application. * list List the GObject-Introspection libraries available locally. + * bundle Create a self-contained bundle of a node-gtk application. */ const cmd = process.argv[2] @@ -21,6 +22,9 @@ switch (cmd) { case 'list-libraries': require('../tools/list-libraries.js').run(process.argv.slice(3)) break + case 'bundle': + require('../tools/bundle.js').run(process.argv.slice(3)) + break case undefined: case '-h': case '--help': @@ -32,6 +36,7 @@ Commands: create Create a new GTK/Adwaita app generate-types [...] Generate TypeScript types (.d.ts) list [filter] List available libraries & versions + bundle [directory] Create a self-contained app bundle Run \`node-gtk --help\` for details.`) process.exit(cmd ? 0 : 1) diff --git a/doc/bundling.md b/doc/bundling.md new file mode 100644 index 00000000..fbcc0e0f --- /dev/null +++ b/doc/bundling.md @@ -0,0 +1,144 @@ +# Bundling — ship your app to users + +`node-gtk bundle` creates a **self-contained, double-clickable bundle** of a +node-gtk application: your code, a node runtime, the compiled node-gtk addon, +and the entire GTK runtime it needs. The result runs on machines with no +node, no GTK, and no compiler installed. + +> **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`). +> For distribution-grade Linux packaging (Flatpak, AppImage), see +> [Roadmap](#roadmap) — the portable tree this command produces is exactly what +> those formats wrap. + +## Usage + +```sh +cd my-app +npx node-gtk bundle # → dist/MyApp-linux-x64/ +npx node-gtk bundle --archive # → dist/MyApp-linux-x64.tar.gz too +``` + +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.** + +Users run the launcher — no installation: + +```sh +tar xzf MyApp-linux-x64.tar.gz +./MyApp-linux-x64/MyApp +``` + +## Output layout + +``` +MyApp-linux-x64/ +├── MyApp launcher (sh) — wires the runtime env, execs bundled node +├── bundle.json manifest: name, id, versions, launcher path +├── runtime/ ← identical across apps sharing a node-gtk/GTK version +│ ├── node node binary (stripped copy of the bundling node) +│ ├── lib/ shared-library closure + girepository-1.0/ typelibs +│ │ └── gdk-pixbuf-2.0/…/loaders + relocatable loaders.cache.in +│ └── share/ compiled GSettings schemas, Adwaita/hicolor icon themes +└── app/ ← yours: files from `include` + production node_modules +``` + +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 +deduplicate it without a layout change. `app/` is typically a few MB. + +The launcher sets `LD_LIBRARY_PATH`, `GI_TYPELIB_PATH` and `XDG_DATA_DIRS` to +prefer `runtime/`, generates a per-install gdk-pixbuf loader cache (the cache +format needs absolute paths), `cd`s into `app/` and execs `runtime/node` on +your entry. + +## Configuration + +Everything lives under the `"bundle"` key of your `package.json`; every field +is optional: + +```jsonc +{ + "main": "src/main.js", + "bundle": { + "name": "MyApp", // launcher/file name; default: PascalCase package name + "id": "com.example.MyApp", // reverse-DNS id (cache dirs); default derived + "entry": "src/main.js", // default: "main" + "gtk": 4, // bundle only this GTK major (default: all installed) + "include": ["src/**", "assets/**"], // app files; default: "**/*" minus + // node_modules/.git/dist/out/build + "nodeArgs": ["--import", "node-gtk/register"], // e.g. for TS entries + "libraries": ["libgstreamer-1.0.so.0"], // extra closure seeds (GStreamer, libsoup, …) + "omitPackages": ["some-dev-helper"], // npm packages to leave out + "icons": false, // skip Adwaita/hicolor themes (default: true) + "node": "vendor/node", // ship this node binary instead (same ABI!) + "out": "release" // default: dist/-- + } +} +``` + +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. + +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. + +### Size expectations + +GTK4 + Adwaita + typelibs + icons ≈ 135 MB, node ≈ 110 MB, ~100 MB as a +`.tar.gz` — Electron-class. `"gtk": 4` avoids also shipping GTK3 from a +machine that has both; `"icons": false` saves ~15 MB if you rely on host +themes. + +## 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: + +```sh +LD_DEBUG=libs ./dist/MyApp-linux-x64/MyApp 2>&1 | grep 'trying file.*libgtk' +``` + +## Roadmap + +- **Windows**: same 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). +- **Flatpak**: the best Linux end-state — apps on `org.gnome.Platform` share + one GTK runtime and get Flathub distribution; the per-app payload shrinks + to `app/` + the addon. A manifest generator can build on the same app-tree + step this command uses. +- **AppImage**: single-file wrapper around exactly this portable tree. +- **Shared runtime**: install `runtime/` once per machine + (`~/.local/share/node-gtk/runtime/`), apps resolve it + relative-first — the layout already supports this. diff --git a/doc/index.md b/doc/index.md index 235f4900..f6f8e8de 100644 --- a/doc/index.md +++ b/doc/index.md @@ -9,6 +9,7 @@ Adwaita is a set of widgets and theming that is built on top of GTK. 1. [Widgets and styling](#widgets-and-styling) 1. [Devtools](#devtools) 1. [Typescript](#typescript) + 1. [Shipping your app](#shipping-your-app) ## Importing @@ -72,6 +73,11 @@ npx node-gtk generate-types Gtk-4.0 Pango-1.0 [etc] See [typescript.md](./typescript.md) +## Shipping your app + +`node-gtk bundle` packages your app, node, node-gtk and the GTK runtime into a +self-contained directory (and `.tar.gz`) that runs on machines with nothing +installed. See [bundling.md](./bundling.md). ## node-gtk low-level API diff --git a/scripts/bundle-smoke-test.js b/scripts/bundle-smoke-test.js new file mode 100644 index 00000000..0c3bfdbe --- /dev/null +++ b/scripts/bundle-smoke-test.js @@ -0,0 +1,103 @@ +/* + * bundle-smoke-test.js + * + * End-to-end test of `node-gtk bundle`: creates a minimal app that depends on + * this checkout of node-gtk, bundles it, then runs the produced launcher and + * 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. + * + * Usage: node scripts/bundle-smoke-test.js + */ + +const fs = require('fs') +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}` + +if (!fs.existsSync(path.join(repoRoot, 'lib', 'binding', bindingName, 'node_gtk.node'))) { + console.error(`no compiled addon for ${bindingName} — build node-gtk first (pnpm run build:full)`) + process.exit(1) +} + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'node-gtk-bundle-smoke-')) +const appDir = path.join(tmp, 'app') +fs.mkdirSync(appDir, { recursive: true }) + +// --- the app ---------------------------------------------------------------- + +fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ + name: 'bundle-smoke', + version: '1.0.0', + main: 'main.js', + dependencies: { 'node-gtk': '*' }, + bundle: { name: 'BundleSmoke', id: 'org.nodegtk.BundleSmoke' }, +}, null, 2)) + +fs.writeFileSync(path.join(appDir, 'main.js'), ` +const gi = require('node-gtk') +let Gtk, major +try { Gtk = gi.require('Gtk', '4.0'); major = 4 } +catch (e) { Gtk = gi.require('Gtk', '3.0'); major = 3 } +Gtk.init() +const label = new Gtk.Label({ label: 'bundle-smoke' }) +if (label.getLabel() !== 'bundle-smoke') + throw new Error('label mismatch: ' + label.getLabel()) +console.log('BUNDLE_SMOKE_OK gtk' + major + ' node=' + process.version + ' exec=' + process.execPath) +process.exit(0) +`) + +// The app depends on THIS checkout: link it in place of a registry install. +// The bundler dereferences the link and copies real (trimmed) files. +fs.mkdirSync(path.join(appDir, 'node_modules')) +fs.symlinkSync(repoRoot, path.join(appDir, 'node_modules', 'node-gtk'), + process.platform === 'win32' ? 'junction' : 'dir') + +// --- bundle ------------------------------------------------------------------- + +console.log(`smoke: bundling in ${appDir}`) +const outDir = path.join(tmp, 'out') +const bundleResult = child_process.spawnSync( + process.execPath, [path.join(repoRoot, 'bin', 'node-gtk.js'), 'bundle', appDir, '--out', outDir], + { stdio: 'inherit', env: { ...process.env, NODE_GTK_BUNDLE_DEBUG: '1' } }) +if (bundleResult.status !== 0) { + console.error(`smoke: FAIL — bundler exited with ${bundleResult.status} (kept: ${tmp})`) + process.exit(1) +} + +// --- run the launcher, exactly as a user would ---------------------------------- + +const manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'bundle.json'), 'utf8')) +const launcher = path.join(outDir, manifest.launcher) +console.log(`smoke: running ${launcher}`) + +const runResult = process.platform === 'win32' + ? child_process.spawnSync('cmd.exe', ['/c', launcher], { encoding: 'utf8' }) + : child_process.spawnSync(launcher, [], { encoding: 'utf8' }) + +process.stdout.write(runResult.stdout || '') +process.stderr.write(runResult.stderr || '') + +if (runResult.status !== 0 || !(runResult.stdout || '').includes('BUNDLE_SMOKE_OK')) { + console.error(`smoke: FAIL — launcher exited with ${runResult.status} (kept: ${tmp})`) + process.exit(1) +} + +// The bundled node must be the one that ran, not the system node. +if (!runResult.stdout.includes(path.join('runtime', 'node'))) { + console.error(`smoke: FAIL — app ran under the wrong node: ${runResult.stdout.trim()} (kept: ${tmp})`) + process.exit(1) +} + +console.log('smoke: PASS') +fs.rmSync(tmp, { recursive: true, force: true }) diff --git a/tools/bundle.js b/tools/bundle.js new file mode 100644 index 00000000..665bf20f --- /dev/null +++ b/tools/bundle.js @@ -0,0 +1,235 @@ +/* + * bundle.js — create a self-contained, double-clickable bundle of a node-gtk + * application: the app's code, a node runtime, the compiled node-gtk addon, + * and the whole GTK runtime it needs (shared libraries, GI typelibs, + * GSettings schemas, icon themes, gdk-pixbuf loaders). The result runs on + * machines with no node, no GTK and no compiler installed. + * + * Driven by the CLI: `node-gtk bundle [app-directory] [options]`. + * + * Output layout (same shape on every platform; macOS wraps it in an .app): + * + * launcher — wires the runtime environment, execs node + * runtime/ node binary + GTK runtime; identical across apps that + * share a node-gtk/GTK version (shareable, see docs) + * app/ the application: its files + production node_modules + * bundle.json manifest (name, id, versions, launcher path) + * + * Configured from the app's package.json under the "bundle" key; every field + * is optional. See doc/bundling.md for the reference. + */ + +const fs = require('fs') +const path = require('path') + +const { exists, mkdirp, formatSize, dirSize, tryExec } = 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. +const PLATFORMS = { + linux: () => require('./bundle/platform-linux.js'), +} + +const HELP = `Usage: node-gtk bundle [app-directory] [options] + +Creates a self-contained bundle of a node-gtk application for the current +platform: app code + node runtime + node-gtk + the GTK runtime it needs. + +Options: + --out output directory (default: /dist/--) + --name app name (default: "bundle.name" in package.json, or derived) + --entry entry point (default: "bundle.entry", or "main" in package.json) + --archive also produce a compressed archive (.tar.gz / .dmg / .zip) + -h, --help show this help + +Configuration (package.json "bundle" key — all optional): + name, id, entry, include, nodeArgs, libraries, omitPackages, icons, out + +See doc/bundling.md for the full reference and per-platform notes.` + +function run(argv) { + try { + const flags = parseArgs(argv) + if (flags.help) { + console.log(HELP) + return + } + 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}'`) + bundle(flags, platformModule()) + } catch (e) { + console.error(`node-gtk bundle: ${e.message}`) + if (process.env.NODE_GTK_BUNDLE_DEBUG) + console.error(e.stack) + process.exit(1) + } +} + +function bundle(flags, platform) { + const appDir = path.resolve(flags.appDir || '.') + const config = loadConfig(appDir, flags) + const outBase = config.out + const log = message => console.log(message.startsWith(' ') ? message : ` ${message}`) + + console.log(`## Bundling ${config.name} (${config.id}) for ${process.platform}-${process.arch}`) + + prepareOutput(outBase) + + const ctx = { + config, appDir, outBase, log, + bindingName: `node-v${process.versions.modules}-${process.platform}-${process.arch}`, + } + if (process.platform === 'darwin') { + ctx.contentsDir = path.join(outBase, `${config.name}.app`, 'Contents') + ctx.runtimeDir = path.join(ctx.contentsDir, 'Resources', 'runtime') + ctx.appOutDir = path.join(ctx.contentsDir, 'Resources', 'app') + } else { + ctx.runtimeDir = path.join(outBase, 'runtime') + ctx.appOutDir = path.join(outBase, 'app') + } + mkdirp(ctx.runtimeDir) + mkdirp(ctx.appOutDir) + + console.log('## Copying application') + ctx.bindingPath = appTree.copyApp(ctx).bindingPath + + console.log('## Assembling GTK runtime') + platform.assembleRuntime(ctx) + + // The node that runs the bundler is the node that ships (they must agree: + // the addon was built for this ABI). `bundle.node` overrides the source + // binary, for shipping a node built against an older glibc than the build + // machine's — its ABI must still match. + const nodeSrc = config.node !== undefined ? path.resolve(appDir, config.node) : process.execPath + 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)}`) + log(`node: ${process.version} (${formatSize(fs.statSync(nodeDest).size)})`) + + const launcherPath = platform.writeLauncher(ctx) + + fs.writeFileSync(path.join(outBase, 'bundle.json'), JSON.stringify({ + name: config.name, + id: config.id, + version: config.version, + launcher: path.relative(outBase, launcherPath), + platform: process.platform, + arch: process.arch, + node: process.version, + nodeGtk: require('../package.json').version, + created: new Date().toISOString(), + }, null, 2) + '\n') + + reportSizes(ctx) + + if (flags.archive) + platform.archive(ctx) + + console.log(`## Done: ${path.relative(process.cwd(), launcherPath)}`) +} + +// --------------------------------------------------------------------------- +// configuration +// --------------------------------------------------------------------------- + +function loadConfig(appDir, flags) { + const pkgPath = path.join(appDir, 'package.json') + if (!exists(pkgPath)) + throw new Error(`no package.json in ${appDir}`) + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) + const raw = pkg.bundle || {} + + const name = flags.name || raw.name || pascalCase(pkg.name || 'App') + const entry = flags.entry || raw.entry || pkg.main + if (typeof entry !== 'string' || entry === '') + throw new Error(`no entry point — set "main" or "bundle.entry" in package.json`) + if (!exists(path.join(appDir, entry))) + throw new Error(`entry point not found: ${entry}`) + + const config = { + name, + id: raw.id || `com.example.${name}`, + version: pkg.version || '0.0.0', + gtk: raw.gtk, + entry: path.normalize(entry), + include: raw.include || ['**/*'], + nodeArgs: raw.nodeArgs || [], + libraries: raw.libraries || [], + omitPackages: raw.omitPackages || [], + icons: raw.icons !== false, + node: raw.node, + out: path.resolve(appDir, flags.out || raw.out + || path.join('dist', `${name}-${process.platform}-${process.arch}`)), + } + + if (!/^[A-Za-z][A-Za-z0-9._-]*$/.test(config.name)) + throw new Error(`invalid bundle name '${config.name}' (it becomes a file name)`) + return config +} + +function pascalCase(base) { + return base + .replace(/^@.*\//, '') + .split(/[-_.\s]+/) + .filter(Boolean) + .map(w => w[0].toUpperCase() + w.slice(1)) + .join('') || 'App' +} + +// Refuse to delete a directory we did not create: a previous output is +// recognized by its bundle.json manifest (or by being empty). +function prepareOutput(outBase) { + if (exists(outBase)) { + const isBundle = exists(path.join(outBase, 'bundle.json')) + const isEmpty = fs.readdirSync(outBase).length === 0 + if (!isBundle && !isEmpty) + throw new Error(`output directory ${outBase} exists and is not a previous bundle — refusing to overwrite`) + fs.rmSync(outBase, { recursive: true, force: true }) + } + mkdirp(outBase) +} + +function reportSizes(ctx) { + const { runtimeDir, appOutDir } = ctx + console.log('## Size breakdown') + const row = (label, size) => console.log(` ${label.padEnd(26)} ${formatSize(size)}`) + row('runtime: libraries', dirSize(path.join(runtimeDir, 'lib')) - dirSize(path.join(runtimeDir, 'lib', 'girepository-1.0'))) + row('runtime: typelibs', dirSize(path.join(runtimeDir, 'lib', 'girepository-1.0'))) + row('runtime: share (icons, ...)', dirSize(path.join(runtimeDir, 'share'))) + row('runtime: node', dirSize(path.join(runtimeDir, 'node')) + dirSize(path.join(runtimeDir, 'node.exe'))) + row('app', dirSize(appOutDir)) + row('TOTAL', dirSize(ctx.outBase)) +} + +// --------------------------------------------------------------------------- +// argument parsing +// --------------------------------------------------------------------------- + +function parseArgs(argv) { + const flags = {} + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + switch (arg) { + case '-h': case '--help': flags.help = true; break + case '--archive': flags.archive = true; break + case '--out': flags.out = argv[++i]; break + case '--name': flags.name = argv[++i]; break + case '--entry': flags.entry = argv[++i]; break + default: + if (arg.startsWith('-')) + throw new Error(`unknown option '${arg}' — see node-gtk bundle --help`) + if (flags.appDir !== undefined) + throw new Error(`unexpected argument '${arg}'`) + flags.appDir = arg + } + } + return flags +} + +module.exports = { run } diff --git a/tools/bundle/app-tree.js b/tools/bundle/app-tree.js new file mode 100644 index 00000000..f3659a52 --- /dev/null +++ b/tools/bundle/app-tree.js @@ -0,0 +1,161 @@ +/* + * app-tree.js — copy the application into /app/. + * + * Two parts: + * 1. the app's own files, selected by the `include` globs of the config, + * 2. its production node_modules: the dependency closure walked from the + * app's package.json, with symlinks dereferenced (pnpm installs are + * symlink forests; the bundle needs real files). + * + * node-gtk itself is special-cased: only package.json + lib/ ship, with a + * single lib/binding/ directory (the one matching the bundled node), and + * its build-time dependencies (node-pre-gyp, node-gyp, nan) are omitted — + * lib/native.js only requires node-pre-gyp when the direct binding path is + * missing, which the bundler guarantees never happens. + */ + +const fs = require('fs') +const path = require('path') + +const { exists, mkdirp, copyFile, copyTree } = require('./util.js') + +// Packages never copied into a bundle. Config `omitPackages` adds to this. +const DEFAULT_OMIT = ['@mapbox/node-pre-gyp', 'node-gyp', 'nan'] + +// Directory names never copied from the app or from packages. +const ALWAYS_EXCLUDED_DIRS = new Set(['node_modules', '.git']) + +// Top-level app directories excluded from the default '**/*' include: build +// output — including previous bundles — must not be re-bundled. An app that +// keeps runtime assets in one of these lists it in `include` explicitly. +const DEFAULT_EXCLUDED_OUTPUT_DIRS = new Set(['dist', 'out', 'build']) + +function copyApp(ctx) { + const { config, appDir, appOutDir, log } = ctx + + // --- 1. app files ------------------------------------------------------- + const outRoot = path.resolve(ctx.outBase) + const usesDefaultInclude = config.include.length === 1 && config.include[0] === '**/*' + const excludeFn = entry => { + const name = typeof entry === 'string' ? entry : entry.name + const base = path.basename(name) + if (ALWAYS_EXCLUDED_DIRS.has(base)) + return true + const abs = path.resolve(appDir, name) + // never re-bundle bundle output (previous trees or their archives) + if (abs === outRoot || abs.startsWith(outRoot + '.')) + return true + if (usesDefaultInclude && DEFAULT_EXCLUDED_OUTPUT_DIRS.has(name)) + return true + return false + } + + const matches = fs.globSync(config.include, { cwd: appDir, exclude: excludeFn }) + let fileCount = 0 + for (const match of matches) { + const src = path.join(appDir, match) + if (path.resolve(src) === outRoot || !fs.statSync(src).isFile()) + continue + copyFile(src, path.join(appOutDir, match)) + fileCount += 1 + } + + // package.json always ships: node needs it for "type"/"main" resolution. + copyFile(path.join(appDir, 'package.json'), path.join(appOutDir, 'package.json')) + + if (!exists(path.join(appOutDir, config.entry))) + throw new Error(`entry '${config.entry}' was not copied into the bundle — check the 'include' globs`) + + log(`app: ${fileCount} files (include: ${config.include.join(', ')})`) + + // --- 2. production dependencies ----------------------------------------- + const omit = new Set([...DEFAULT_OMIT, ...config.omitPackages]) + const packages = collectPackages(appDir, omit) + + for (const [name, dir] of packages) { + const dest = path.join(appOutDir, 'node_modules', name) + if (name === 'node-gtk') + copyNodeGtk(ctx, dir, dest) + else + copyTree(dir, dest, { filter: src => !ALWAYS_EXCLUDED_DIRS.has(path.basename(src)) }) + } + + log(`app: ${packages.size} packages (${[...packages.keys()].join(', ')})`) + + const bindingPath = findBinding(ctx, appOutDir) + return { bindingPath } +} + +// Walk the production dependency closure. Resolution mimics require(): look +// for node_modules/ in the requiring package's directory, then upward. +// Every hit is realpath'd, which naturally follows pnpm's .pnpm store layout +// (a package's own deps then resolve inside its .pnpm//node_modules). +function collectPackages(appDir, omit) { + const found = new Map() // name -> real package dir + const visit = pkgDir => { + const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')) + const deps = { ...pkg.dependencies, ...pkg.optionalDependencies } + for (const name of Object.keys(deps)) { + if (omit.has(name) || found.has(name)) + continue + const dir = resolvePackageDir(name, pkgDir) + if (dir === undefined) { + if (pkg.optionalDependencies !== undefined && name in pkg.optionalDependencies) + continue + throw new Error(`cannot resolve dependency '${name}' from ${pkgDir} — is the app installed?`) + } + found.set(name, dir) + visit(dir) + } + } + visit(appDir) + return found +} + +function resolvePackageDir(name, fromDir) { + let dir = fromDir + while (true) { + const candidate = path.join(dir, 'node_modules', name) + if (exists(path.join(candidate, 'package.json'))) + return fs.realpathSync(candidate) + const parent = path.dirname(dir) + if (parent === dir) + return undefined + dir = parent + } +} + +// node-gtk trimmed to its runtime files: package.json + lib/, and within +// lib/binding only the ABI directory matching the bundled node binary. +function copyNodeGtk(ctx, srcDir, destDir) { + const bindingName = ctx.bindingName + copyTree(srcDir, destDir, { + filter: src => { + const rel = path.relative(srcDir, src) + if (rel === '') + return true + const parts = rel.split(path.sep) + if (parts[0] === 'package.json') + return true + if (parts[0] !== 'lib') + return false + if (ALWAYS_EXCLUDED_DIRS.has(parts[parts.length - 1])) + return false + if (parts[1] === 'binding' && parts.length >= 3 && parts[2] !== bindingName) + return false + return true + }, + }) +} + +function findBinding(ctx, appOutDir) { + const bindingPath = path.join( + appOutDir, 'node_modules', 'node-gtk', 'lib', 'binding', ctx.bindingName, 'node_gtk.node') + if (!exists(bindingPath)) + throw new Error( + `no compiled node-gtk addon for ${ctx.bindingName} — ` + + `build node-gtk with the node you are bundling (${process.version}) first`) + return bindingPath +} + +module.exports = { copyApp } diff --git a/tools/bundle/platform-linux.js b/tools/bundle/platform-linux.js new file mode 100644 index 00000000..0aea9134 --- /dev/null +++ b/tools/bundle/platform-linux.js @@ -0,0 +1,220 @@ +/* + * platform-linux.js — Linux runtime assembly. + * + * Shared libraries are collected with ldd (which prints the full transitive + * closure) seeded from the compiled addon, the GI namespace libraries + * (seeds.js) and the gdk-pixbuf loaders. Host-provided libraries (glibc, GPU + * drivers, X11/Wayland client stack, font stack — see seeds.js) are excluded, + * following the AppImage community excludelist. + * + * The launcher is a POSIX sh script that wires LD_LIBRARY_PATH, + * GI_TYPELIB_PATH and XDG_DATA_DIRS to the bundled runtime, materializes a + * gdk-pixbuf loader cache with absolute paths (written to the user's cache + * dir — the cache format has no relative-path support), then execs the + * bundled node on the app entry. + * + * Note the binary compatibility baseline: the bundle runs on distributions + * whose glibc is at least as new as the build machine's. Build on the oldest + * distribution you want to support (CI: ubuntu-latest). + */ + +const fs = require('fs') +const path = require('path') + +const { exec, tryExec, exists, mkdirp, copyFile, copyTree, formatSize } = require('./util.js') +const { seedNames, isExcludedLinux } = require('./seeds.js') + +const NODE_BINARY = 'node' + +function assembleRuntime(ctx) { + const { config, runtimeDir, log } = ctx + const libDir = path.join(runtimeDir, 'lib') + mkdirp(libDir) + + // --- typelibs ------------------------------------------------------------ + // Copy the full set: it is small and guarantees every transitive namespace + // dependency (Gdk, Pango, cairo, GdkPixbuf, Graphene, ...) is present. + const typelibSrc = pkgConfigVar('gobject-introspection-1.0', 'typelibdir') + || '/usr/lib/girepository-1.0' + const typelibDst = path.join(libDir, 'girepository-1.0') + copyTypelibs(typelibSrc, typelibDst, log) + + // --- shared-library closure ---------------------------------------------- + const ldcache = ldconfigCache() + const seeds = [] + for (const name of seedNames('linux', config.libraries, config.gtk)) { + const p = ldcache.get(name) + if (p !== undefined) + seeds.push(p) + else + log(` (skip missing seed ${name})`) + } + + const loaders = pixbufLoaders() + const closure = lddClosure([ctx.bindingPath, ...seeds, ...loaders.files]) + + // The seeds themselves are part of the runtime. + for (const p of seeds) + closure.set(path.basename(p), p) + + let copied = 0, excluded = 0 + for (const [name, src] of [...closure].sort()) { + if (isExcludedLinux(name)) { + excluded += 1 + continue + } + copyFile(fs.realpathSync(src), path.join(libDir, name)) + copied += 1 + } + log(`libraries: ${copied} bundled, ${excluded} excluded (host-provided)`) + + // --- 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; templatize + // it so the launcher can point it at the install location. + if (loaders.cache !== undefined) { + const template = fs.readFileSync(loaders.cache, 'utf8') + .replace(/^"[^"]*[\\/]([^"\\/]+\.so)"/gm, '"@LOADERS_DIR@/$1"') + fs.writeFileSync(path.join(loadersDst, '..', 'loaders.cache.in'), template) + } + log(`gdk-pixbuf: ${loaders.files.length} loaders`) + } + + // --- GSettings schemas + icon themes -------------------------------------- + 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() { + const out = tryExec('ldconfig -p') || exec('/sbin/ldconfig -p') + const archTag = { x64: 'x86-64', arm64: 'AArch64' }[process.arch] + const map = new Map() + for (const line of out.split('\n')) { + const m = /^\s*(\S+)\s+\(([^)]*)\)\s+=>\s+(\/\S+)$/.exec(line) + if (m === null) + continue + const [, name, tags, libPath] = m + if (archTag !== undefined && !tags.includes(archTag)) + continue + if (!map.has(name)) + map.set(name, libPath) + } + return map +} + +// Union of the transitive dependency closures of `files`: soname -> path. +// ldd resolves recursively, so one invocation per entry point suffices. +function lddClosure(files) { + const closure = new Map() + for (const file of files) { + const out = tryExec(`ldd ${JSON.stringify(file)}`) + if (out === undefined) + continue + for (const line of out.split('\n')) { + const m = /^\s*(\S+)\s+=>\s+(\/\S+)\s+\(0x/.exec(line) + if (m !== null && !closure.has(m[1])) + closure.set(m[1], m[2]) + } + } + return closure +} + +function pixbufLoaders() { + const moduleDir = pkgConfigVar('gdk-pixbuf-2.0', 'gdk_pixbuf_moduledir') + || '/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders' + if (!exists(moduleDir)) + return { files: [] } + const files = fs.readdirSync(moduleDir) + .filter(f => f.endsWith('.so')) + .map(f => path.join(moduleDir, f)) + const cache = pkgConfigVar('gdk-pixbuf-2.0', 'gdk_pixbuf_cache_file') + || path.join(moduleDir, '..', 'loaders.cache') + 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 nodeArgs = config.nodeArgs.length > 0 ? config.nodeArgs.join(' ') + ' ' : '' + const launcherPath = path.join(outBase, 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 (libraries, typelibs, schemas, icons, +# gdk-pixbuf loaders), then execs the bundled node on the app entry. +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +RT="$HERE/runtime" + +export LD_LIBRARY_PATH="$RT/lib\${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +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}" + +# 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/.cache}/${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 nodeArgs (--import node-gtk/register) and +# the app's own relative paths resolve regardless of the invoking cwd. +cd "$HERE/app" || exit 1 +exec "$RT/node" ${nodeArgs}"./${entry}" "$@" +`) + fs.chmodSync(launcherPath, 0o755) + return launcherPath +} + +function archive(ctx) { + const { outBase, log } = ctx + const archivePath = `${outBase}.tar.gz` + exec(`tar -czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(path.dirname(outBase))} ${JSON.stringify(path.basename(outBase))}`) + log(`archive: ${archivePath} (${formatSize(fs.statSync(archivePath).size)})`) + return archivePath +} + +module.exports = { NODE_BINARY, assembleRuntime, writeLauncher, archive } diff --git a/tools/bundle/seeds.js b/tools/bundle/seeds.js new file mode 100644 index 00000000..7ffad1b2 --- /dev/null +++ b/tools/bundle/seeds.js @@ -0,0 +1,106 @@ +/* + * seeds.js — libraries that seed the shared-library closure walk. + * + * GObject-Introspection loads each namespace's shared library at run time via + * g_module_open() when the app calls gi.require('Gtk', ...). Those libraries + * are NOT linked by node_gtk.node, so scanning the addon's own imports misses + * them: they must be listed explicitly, then expanded into their transitive + * closure with the platform's dependency walker (ldd / otool / ntldd). + * + * Every seed is optional: entries missing on the build machine are skipped + * with a note, so this one list serves GTK3-only and GTK4-only machines + * alike. Apps that use more namespaces (GStreamer, libsoup, WebKit, ...) add + * their libraries through the `libraries` key of the bundle config, using the + * platform file name. + * + * The Windows names mirror windows/runtime-libraries.txt (the seed list of + * the self-contained npm prebuilt, expanded by scripts/windows-bundle-runtime.sh). + */ + +const SEEDS = [ + // --- GObject-Introspection core --- + { name: 'girepository', + linux: 'libgirepository-1.0.so.1', darwin: 'libgirepository-1.0.1.dylib', win32: 'libgirepository-1.0-1.dll' }, + { name: 'gio', + linux: 'libgio-2.0.so.0', darwin: 'libgio-2.0.0.dylib', win32: 'libgio-2.0-0.dll' }, + + // --- GTK (both majors optional; whichever exists gets bundled, unless the + // app pins one with the `gtk` config key) --- + { name: 'gtk4', gtk: 4, + linux: 'libgtk-4.so.1', darwin: 'libgtk-4.1.dylib', win32: 'libgtk-4-1.dll' }, + { name: 'gtk3', gtk: 3, + linux: 'libgtk-3.so.0', darwin: 'libgtk-3.0.dylib', win32: 'libgtk-3-0.dll' }, + { name: 'adwaita', gtk: 4, + linux: 'libadwaita-1.so.0', darwin: 'libadwaita-1.0.dylib', win32: 'libadwaita-1-0.dll' }, + { name: 'gtksourceview', gtk: 4, + linux: 'libgtksourceview-5.so.0', darwin: 'libgtksourceview-5.0.dylib', win32: 'libgtksourceview-5-0.dll' }, + + // --- text / graphics --- + { name: 'pango', + linux: 'libpango-1.0.so.0', darwin: 'libpango-1.0.0.dylib', win32: 'libpango-1.0-0.dll' }, + { name: 'pangocairo', + linux: 'libpangocairo-1.0.so.0', darwin: 'libpangocairo-1.0.0.dylib', win32: 'libpangocairo-1.0-0.dll' }, + { name: 'gdk-pixbuf', + linux: 'libgdk_pixbuf-2.0.so.0', darwin: 'libgdk_pixbuf-2.0.0.dylib', win32: 'libgdk_pixbuf-2.0-0.dll' }, + { name: 'graphene', + linux: 'libgraphene-1.0.so.0', darwin: 'libgraphene-1.0.0.dylib', win32: 'libgraphene-1.0-0.dll' }, + { name: 'cairo-gobject', + linux: 'libcairo-gobject.so.2', darwin: 'libcairo-gobject.2.dylib', win32: 'libcairo-gobject-2.dll' }, + // HarfBuzz GObject bindings — referenced by the HarfBuzz typelib but not + // linked by the GTK stack, so it must be listed explicitly. + { name: 'harfbuzz-gobject', + linux: 'libharfbuzz-gobject.so.0', darwin: 'libharfbuzz-gobject.0.dylib', win32: 'libharfbuzz-gobject-0.dll' }, +] + +// Platform seed file names, plus whatever the app config adds. `gtkMajor` +// (the `gtk` config key) drops the seeds of the other GTK major version. +function seedNames(platform, extraLibraries = [], gtkMajor = undefined) { + return SEEDS + .filter(seed => gtkMajor === undefined || seed.gtk === undefined || seed.gtk === gtkMajor) + .map(seed => seed[platform]) + .filter(Boolean) + .concat(extraLibraries) +} + +/* + * Linux libraries that must NOT be bundled. Bundling these breaks the app on + * hosts whose kernel/driver/font stack differs from the build machine — the + * host's own copies must be used. This follows the AppImage community + * excludelist (pkg2appimage), the reference for what is safe to ship. + * https://github.com/AppImageCommunity/pkg2appimage/blob/master/excludelist + */ +const LINUX_EXCLUDED_EXACT = new Set([ + // glibc — tied to the host kernel and loader + 'libc.so.6', 'libm.so.6', 'libdl.so.2', 'libpthread.so.0', 'librt.so.1', + 'libresolv.so.2', 'libutil.so.1', 'libanl.so.1', 'libmvec.so.1', + 'libthread_db.so.1', 'libBrokenLocale.so.1', 'libcidn.so.1', + // C++ / gcc runtime — must match the host's libgcc/driver stack + 'libstdc++.so.6', 'libgcc_s.so.1', + // font stack — must match the host's font configuration + 'libfontconfig.so.1', 'libfreetype.so.6', 'libharfbuzz.so.0', 'libfribidi.so.0', + // audio — host servers + 'libasound.so.2', 'libjack.so.0', 'libpipewire-0.3.so.0', + // base system + 'libcom_err.so.2', 'libexpat.so.1', 'libgpg-error.so.0', 'libICE.so.6', + 'libSM.so.6', 'libusb-1.0.so.0', 'libuuid.so.1', 'libz.so.1', 'libgmp.so.10', +]) + +const LINUX_EXCLUDED_FAMILIES = [ + /^ld-linux/, // the dynamic loader itself + /^libnss_/, // glibc NSS plugins + /^libGL/, /^libEGL/, /^libGLX/, /^libGLdispatch/, /^libOpenGL/, // OpenGL — host GPU drivers + /^libdrm/, /^libglapi/, /^libgbm/, // mesa/DRM — host GPU drivers + /^libX/, /^libxcb/, // X11 client stack — host display server + /^libwayland-/, // Wayland client stack — host display server +] + +function isExcludedLinux(name) { + return LINUX_EXCLUDED_EXACT.has(name) + || LINUX_EXCLUDED_FAMILIES.some(re => re.test(name)) +} + +module.exports = { + SEEDS, + seedNames, + isExcludedLinux, +} diff --git a/tools/bundle/util.js b/tools/bundle/util.js new file mode 100644 index 00000000..114aed04 --- /dev/null +++ b/tools/bundle/util.js @@ -0,0 +1,78 @@ +/* + * util.js — small helpers shared by the bundle tool. + */ + +const fs = require('fs') +const path = require('path') +const child_process = require('child_process') + +function exec(command, options = {}) { + return child_process.execSync(command, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 64 * 1024 * 1024, + ...options, + }) +} + +// Best-effort exec: undefined on failure instead of throwing. +function tryExec(command, options) { + try { return exec(command, options) } catch (e) { return undefined } +} + +function exists(p) { + try { fs.accessSync(p); return true } catch (e) { return false } +} + +function mkdirp(dir) { + fs.mkdirSync(dir, { recursive: true }) +} + +// Copy a single file, dereferencing symlinks and preserving the mode. +function copyFile(src, dest) { + mkdirp(path.dirname(dest)) + fs.copyFileSync(src, dest) + fs.chmodSync(dest, fs.statSync(src).mode) +} + +// Copy a directory tree, dereferencing symlinks (pnpm installs are symlink +// forests; the bundle must contain real files). +function copyTree(src, dest, options = {}) { + fs.cpSync(src, dest, { recursive: true, dereference: true, force: true, ...options }) +} + +function formatSize(bytes) { + if (bytes >= 1024 * 1024) + return (bytes / (1024 * 1024)).toFixed(1) + ' MB' + if (bytes >= 1024) + return (bytes / 1024).toFixed(1) + ' kB' + return bytes + ' B' +} + +function dirSize(dir) { + if (!exists(dir)) + return 0 + const stat = fs.lstatSync(dir) + if (!stat.isDirectory()) + return stat.size + let total = 0 + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name) + if (entry.isDirectory()) + total += dirSize(p) + else if (entry.isFile()) + total += fs.lstatSync(p).size + } + return total +} + +module.exports = { + exec, + tryExec, + exists, + mkdirp, + copyFile, + copyTree, + formatSize, + dirSize, +} From cafe938cca6f5926185472d981c5cf710725569c Mon Sep 17 00:00:00 2001 From: Rom Grk Date: Sun, 5 Jul 2026 00:33:35 -0400 Subject: [PATCH 2/6] fix: don't poison the module cache on failed gi.require MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit giRequire inserted the (empty) module object into the cache BEFORE the typelib lookup could throw, so probing for a version and falling back — try Gtk 4.0, catch, require Gtk 3.0 — returned the poisoned empty module: 'Gtk.init is not a function'. Surfaced by the bundle smoke test on CI, whose runners only have GTK3. Cache only after Repository_require succeeds. isLoaded() had the same side effect (and its version check read a key that is never written, so it always returned false); route it through g_irepository_is_registered instead, with no cache mutation. Co-Authored-By: Claude Fable 5 --- lib/module.js | 23 ++++++------- tests/require__failed_version_not_cached.js | 37 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 tests/require__failed_version_not_cached.js diff --git a/lib/module.js b/lib/module.js index c16558df..769c6608 100644 --- a/lib/module.js +++ b/lib/module.js @@ -29,12 +29,15 @@ function giRequire(ns, version) { if (moduleCache[ns]) return moduleCache[ns] - const module = moduleCache[ns] = Object.create(null) - const repo = GI.Repository_get_default() GI.Repository_require.call(repo, ns, version || null, 0) version = version || GI.Repository_get_version.call(repo, ns) + // Cache only once the typelib is found: caching before the lookup left a + // poisoned empty module behind on failure, and probing (try Gtk 4.0, fall + // back to Gtk 3.0) then returned that empty module from the cache. + const module = moduleCache[ns] = Object.create(null) + loadDependencies(ns, version) // Top-level infos materialize on first access (see defineLazyInfos in @@ -73,16 +76,12 @@ function loadDependencies(ns, version) { * Check if module version is loaded */ function isLoaded(ns, version) { - const namespace = moduleCache[ns] || (moduleCache[ns] = Object.create(null)); - version = version || null; - - if (namespace[version]) - return true; - - if (version == null && namespace.length > 0) - return true; - - return false; + // Ask the GI repository rather than the module cache: the old cache-backed + // check inserted an empty module as a side effect (poisoning the next + // require of the namespace) and tested a key that was never set, so it + // always returned false. + const repo = GI.Repository_get_default() + return GI.Repository_is_registered.call(repo, ns, version || null) } /** diff --git a/tests/require__failed_version_not_cached.js b/tests/require__failed_version_not_cached.js new file mode 100644 index 00000000..4e1ecd7a --- /dev/null +++ b/tests/require__failed_version_not_cached.js @@ -0,0 +1,37 @@ +/* + * require__failed_version_not_cached.js + * + * A failed gi.require() (typelib not found) must not poison the module + * cache. Apps probe for a version and fall back (try Gtk 4.0, catch, require + * Gtk 3.0 — the bundle smoke app does exactly this); the empty module that + * giRequire used to insert *before* the typelib lookup was returned by the + * fallback require, yielding "Gtk.init is not a function". + * + * gi.isLoaded() had the same poisoning side effect (and always returned + * false); it must reflect the repository without touching the cache. + */ + +const gi = require('../lib/') +const { describe, it, assert, expect, mustThrow } = require('./__common__.js') + +describe('gi.require after a failed require of the same namespace', () => { + + it('isLoaded is false before any require, without side effects', () => { + expect(gi.isLoaded('Gtk'), false) + }) + + it('requiring a missing version throws', mustThrow(/Typelib file .* not found/, () => { + gi.require('Gtk', '99.0') + })) + + it('the fallback require returns a working module', () => { + const Gtk = gi.require('Gtk') // whichever version is installed + assert(typeof Gtk.init === 'function', + `Gtk.init should be a function, got ${typeof Gtk.init} (poisoned cache?)`) + }) + + it('isLoaded reflects the loaded namespace', () => { + expect(gi.isLoaded('Gtk'), true) + expect(gi.isLoaded('Gtk', '99.0'), false) + }) +}) From 1fac06dfda7e797aa9a06da10e38d00575992c26 Mon Sep 17 00:00:00 2001 From: Rom Grk Date: Sun, 5 Jul 2026 01:27:37 -0400 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20`node-gtk=20flatpak`=20=E2=80=94=20?= =?UTF-8?q?package=20apps=20as=20installable=20Flatpaks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The format real users install: one-click via GNOME Software, Flathub- ready, sandboxed, with the GTK runtime shared across apps (the smoke app's deliverable is an 18MB .flatpak vs its 100MB portable tar.gz). GTK comes from org.gnome.Platform — nothing GTK-related is bundled. flatpak-builder builds offline; instead of the usual lockfile→sources generators, the same app-tree step `node-gtk bundle` uses stages the app + production node_modules as a plain dir source (new rebuildAddon mode: node-gtk ships its compile inputs — src/, binding.gyp, nan — and no host binding). The addon is compiled in-sandbox against the runtime's GTK with npm's internal node-gyp: --nodedir points at the node SDK extension's bundled headers (offline), and module_name/ module_path are passed explicitly since binding.gyp is node-pre-gyp style. The node binary is copied from the SDK extension into /app/bin. Generates manifest + launcher + .desktop + AppStream metainfo + icon install from the shared "bundle" config (new: summary, icon, license, categories, flatpak.{runtimeVersion,node,finishArgs}), builds with flatpak-builder or org.flatpak.Builder (granting the output dir to its sandbox — it cannot see /tmp), and emits a single-file .flatpak via build-bundle --runtime-repo so end-user installs fetch the Platform from Flathub automatically. Verified end-to-end locally: built, installed and ran the smoke app under GNOME Platform 49 / node26 extension. CI checks generation only (the sandbox build needs the ~1GB GNOME SDK). Co-Authored-By: Claude Fable 5 --- .github/workflows/main.yaml | 7 + README.md | 7 +- bin/node-gtk.js | 5 + doc/bundling.md | 119 ++++++++++-- doc/index.md | 7 +- scripts/flatpak-smoke-test.js | 84 ++++++++ tools/bundle.js | 19 +- tools/bundle/app-tree.js | 30 ++- tools/flatpak.js | 350 ++++++++++++++++++++++++++++++++++ 9 files changed, 597 insertions(+), 31 deletions(-) create mode 100644 scripts/flatpak-smoke-test.js create mode 100644 tools/flatpak.js diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index ea3215f6..481224a4 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -153,3 +153,10 @@ jobs: - name: Bundle smoke test if: matrix.os == 'ubuntu-latest' && matrix.node == 24 run: xvfb-run -a 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. + - name: Flatpak generation smoke test + if: matrix.os == 'ubuntu-latest' && matrix.node == 24 + run: node scripts/flatpak-smoke-test.js diff --git a/README.md b/README.md index 011ba38e..8974aedc 100644 --- a/README.md +++ b/README.md @@ -44,12 +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, the **bundle** tool packages your app, node, node-gtk and -the whole GTK runtime into a self-contained directory that runs on machines with -nothing installed (Linux for now — see [doc/bundling.md](./doc/bundling.md)): +When it's time to ship (Linux for now — see [doc/bundling.md](./doc/bundling.md)): ```sh -npx node-gtk bundle --archive +npx node-gtk flatpak # a Flatpak users install with one click (Flathub-ready) +npx node-gtk bundle # a self-contained portable directory / tar.gz ``` ## Installing diff --git a/bin/node-gtk.js b/bin/node-gtk.js index 0d88aaa6..2b673684 100755 --- a/bin/node-gtk.js +++ b/bin/node-gtk.js @@ -7,6 +7,7 @@ * create Create a new GTK/Adwaita application. * list List the GObject-Introspection libraries available locally. * bundle Create a self-contained bundle of a node-gtk application. + * flatpak Package a node-gtk application as a Flatpak. */ const cmd = process.argv[2] @@ -25,6 +26,9 @@ switch (cmd) { case 'bundle': require('../tools/bundle.js').run(process.argv.slice(3)) break + case 'flatpak': + require('../tools/flatpak.js').run(process.argv.slice(3)) + break case undefined: case '-h': case '--help': @@ -37,6 +41,7 @@ Commands: generate-types [...] Generate TypeScript types (.d.ts) list [filter] List available libraries & versions bundle [directory] Create a self-contained app bundle + flatpak [directory] Package the app as a Flatpak Run \`node-gtk --help\` for details.`) process.exit(cmd ? 0 : 1) diff --git a/doc/bundling.md b/doc/bundling.md index fbcc0e0f..24905cf3 100644 --- a/doc/bundling.md +++ b/doc/bundling.md @@ -1,17 +1,25 @@ # Bundling — ship your app to users -`node-gtk bundle` creates a **self-contained, double-clickable bundle** of a -node-gtk application: your code, a node runtime, the compiled node-gtk addon, -and the entire GTK runtime it needs. The result runs on machines with no -node, no GTK, and no compiler installed. +Two commands cover shipping, for different audiences: + +- **[`node-gtk flatpak`](#flatpak)** — the way **real users install apps**: + a Flatpak, one-click installable via GNOME Software, distributable on + Flathub (updates, sandboxing, shared GTK runtime). Start here for desktop + distribution. +- **[`node-gtk bundle`](#portable-bundles)** — a **self-contained portable + directory** (plus `.tar.gz`): your code, a node runtime, the compiled + addon, and the entire GTK runtime. Runs on machines with nothing + installed; right for GitHub release artifacts, kiosks, fleets, and + "try my app" links. + +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`). -> For distribution-grade Linux packaging (Flatpak, AppImage), see -> [Roadmap](#roadmap) — the portable tree this command produces is exactly what -> those formats wrap. + +# Portable bundles ## Usage @@ -125,20 +133,97 @@ libraries resolve from on your machine: LD_DEBUG=libs ./dist/MyApp-linux-x64/MyApp 2>&1 | grep 'trying file.*libgtk' ``` -## Roadmap +# Flatpak + +```sh +cd my-app +npx node-gtk flatpak # generate + build + MyApp.flatpak +npx node-gtk flatpak --install # …and install it (user), then: +flatpak run com.example.MyApp +``` + +The output `dist/flatpak/MyApp.flatpak` is a **single file users double-click +to install** through GNOME Software on any distro — the file embeds the +Flathub repo reference, so the GNOME runtime is fetched automatically. For +real distribution, [submit the generated manifest to Flathub](#shipping-on-flathub). + +Requirements: `flatpak` plus a builder (`flatpak install flathub +org.flatpak.Builder`, or your distro's `flatpak-builder` package). The first +build downloads the GNOME SDK (~1 GB, cached). `--no-build` generates +everything without building. + +## How it works + +Unlike `node-gtk bundle`, **nothing GTK-related is bundled**: the app runs on +`org.gnome.Platform`, shared across all Flatpak apps and updated +independently. What's inside `/app` is only your code, node (from the +`org.freedesktop.Sdk.Extension.node` SDK extension) and the node-gtk +addon. + +`flatpak-builder` builds offline, which is normally painful for Node apps +(lockfile→sources generators). We sidestep it: the same app-tree step +`node-gtk bundle` uses stages your files + production node_modules +(pnpm-safe, symlinks dereferenced) as a plain `dir` source. The only thing +compiled in the sandbox is the node-gtk addon, against the runtime's GTK, +using the SDK extension's bundled node headers (`--nodedir`) — still no +network. + +## Flatpak configuration + +The shared `"bundle"` key, plus a `"flatpak"` sub-key: + +```jsonc +"bundle": { + "name": "MyApp", + "id": "com.example.MyApp", // MUST be your GApplication application-id + "summary": "Does the thing", // .desktop comment + AppStream summary + "icon": "assets/icon.svg", // svg or png; required by Flathub + "license": "MIT", // SPDX, defaults to package.json "license" + "categories": ["GTK", "Utility"], + "flatpak": { + "runtimeVersion": "49", // org.gnome.Platform version + "node": 26, // SDK extension major (20/22/24/26) + "finishArgs": [ // sandbox permissions beyond the GUI defaults + "--share=network", + "--filesystem=home" + ] + } +} +``` + +Defaults grant only GUI access (`wayland`, `fallback-x11`, `ipc`, `dri`) — +network and filesystem are deliberately opt-in; request the minimum, Flathub +reviews it. + +Two things that bite: + +- **The flatpak id must equal your `Gtk.Application` `applicationId`** — + otherwise GNOME Shell can't associate windows with the app (generic icon, + wrong dock entry). +- The sandbox has no host filesystem by default: `fs` reads outside the + sandbox need `--filesystem=` permissions, or better, the XDG portals. + +## Shipping on Flathub + +Flathub is a manifest repository: you submit the generated +`.yml` (plus your app source — for a node app, typically a release +tarball of the staged `app/` tree) via PR to +[flathub/flathub](https://github.com/flathub/flathub). Before submitting, +fill in the metainfo TODOs (`description`, screenshots, releases, +`content_rating`) and provide a real icon; `flatpak run org.flatpak.Builder +--command=flatpak-builder-lint` checks compliance. After acceptance, users +find the app in GNOME Software and updates ship automatically. + +# Roadmap -- **Windows**: same architecture; DLL closure via `ntldd` (already CI-proven - for the npm prebuilt), `.cmd` launcher, `.zip` archive. Needs an MSYS2 - MINGW64 environment at bundle time. +- **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). -- **Flatpak**: the best Linux end-state — apps on `org.gnome.Platform` share - one GTK runtime and get Flathub distribution; the per-app payload shrinks - to `app/` + the addon. A manifest generator can build on the same app-tree - step this command uses. -- **AppImage**: single-file wrapper around exactly this portable tree. -- **Shared runtime**: install `runtime/` once per machine +- **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. diff --git a/doc/index.md b/doc/index.md index f6f8e8de..15eb9b40 100644 --- a/doc/index.md +++ b/doc/index.md @@ -75,9 +75,10 @@ See [typescript.md](./typescript.md) ## Shipping your app -`node-gtk bundle` packages your app, node, node-gtk and the GTK runtime into a -self-contained directory (and `.tar.gz`) that runs on machines with nothing -installed. See [bundling.md](./bundling.md). +`node-gtk flatpak` packages your app as a Flatpak — the format real users +install through GNOME Software, Flathub-ready. `node-gtk bundle` produces a +self-contained portable directory (and `.tar.gz`) that runs on machines with +nothing installed. See [bundling.md](./bundling.md). ## node-gtk low-level API diff --git a/scripts/flatpak-smoke-test.js b/scripts/flatpak-smoke-test.js new file mode 100644 index 00000000..dcaea993 --- /dev/null +++ b/scripts/flatpak-smoke-test.js @@ -0,0 +1,84 @@ +/* + * flatpak-smoke-test.js + * + * Checks `node-gtk flatpak --no-build`: stages a minimal app and asserts the + * generated flatpak sources are complete and buildable — manifest, launcher, + * desktop file, metainfo, and an app tree carrying the node-gtk COMPILE + * inputs (src/ + binding.gyp + nan) instead of a host-compiled binding. + * + * The actual sandbox build is exercised locally / at release time, not on + * every CI run: it downloads the ~1 GB GNOME SDK. This test needs no GTK, no + * display and no flatpak. + * + * Usage: node scripts/flatpak-smoke-test.js + */ + +const fs = require('fs') +const os = require('os') +const path = require('path') +const child_process = require('child_process') + +if (process.platform !== 'linux') { + console.log(`flatpak-smoke-test: flatpaks are Linux-only, skipping on ${process.platform}`) + process.exit(0) +} + +const repoRoot = path.resolve(__dirname, '..') +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'node-gtk-flatpak-smoke-')) +const appDir = path.join(tmp, 'app') +const id = 'org.nodegtk.FlatpakSmoke' +fs.mkdirSync(appDir, { recursive: true }) + +fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ + name: 'flatpak-smoke', + version: '1.0.0', + main: 'main.js', + license: 'MIT', + dependencies: { 'node-gtk': '*' }, + bundle: { name: 'FlatpakSmoke', id, summary: 'Flatpak generation smoke test' }, +}, null, 2)) +fs.writeFileSync(path.join(appDir, 'main.js'), `require('node-gtk')\n`) +fs.mkdirSync(path.join(appDir, 'node_modules')) +fs.symlinkSync(repoRoot, path.join(appDir, 'node_modules', 'node-gtk'), 'dir') + +const outDir = path.join(tmp, 'out') +const result = child_process.spawnSync( + process.execPath, + [path.join(repoRoot, 'bin', 'node-gtk.js'), 'flatpak', appDir, '--out', outDir, '--no-build'], + { stdio: 'inherit', env: { ...process.env, NODE_GTK_BUNDLE_DEBUG: '1' } }) +if (result.status !== 0) { + console.error(`smoke: FAIL — generation exited with ${result.status} (kept: ${tmp})`) + process.exit(1) +} + +const checks = [ + // generated integration files + [`${id}.yml`, content => content.includes(`app-id: ${id}`) && content.includes('org.freedesktop.Sdk.Extension.node')], + ['launcher.sh', content => content.includes('exec /app/bin/node')], + [`${id}.desktop`, content => content.includes('Exec=' + id)], + [`${id}.metainfo.xml`, content => content.includes(`${id}`)], + // the staged tree must be COMPILABLE in the sandbox + ['app/node_modules/node-gtk/binding.gyp', () => true], + ['app/node_modules/node-gtk/src', () => true], + ['app/node_modules/nan', () => true], +] +for (const [rel, check] of checks) { + const p = path.join(outDir, rel) + if (!fs.existsSync(p)) { + console.error(`smoke: FAIL — missing ${rel} (kept: ${tmp})`) + process.exit(1) + } + if (fs.statSync(p).isFile() && !check(fs.readFileSync(p, 'utf8'))) { + console.error(`smoke: FAIL — unexpected content in ${rel} (kept: ${tmp})`) + process.exit(1) + } +} + +// No host-compiled binding may leak into the offline sources. +if (fs.existsSync(path.join(outDir, 'app/node_modules/node-gtk/lib/binding'))) { + console.error(`smoke: FAIL — host lib/binding leaked into flatpak sources (kept: ${tmp})`) + process.exit(1) +} + +console.log('smoke: PASS') +fs.rmSync(tmp, { recursive: true, force: true }) diff --git a/tools/bundle.js b/tools/bundle.js index 665bf20f..a45b644f 100644 --- a/tools/bundle.js +++ b/tools/bundle.js @@ -152,10 +152,15 @@ function loadConfig(appDir, flags) { if (!exists(path.join(appDir, entry))) throw new Error(`entry point not found: ${entry}`) + const flatpakRaw = raw.flatpak || {} const config = { name, id: raw.id || `com.example.${name}`, version: pkg.version || '0.0.0', + summary: raw.summary || `The ${name} application`, + license: raw.license || pkg.license, + icon: raw.icon, + categories: raw.categories || ['GTK'], gtk: raw.gtk, entry: path.normalize(entry), include: raw.include || ['**/*'], @@ -166,6 +171,11 @@ function loadConfig(appDir, flags) { node: raw.node, out: path.resolve(appDir, flags.out || raw.out || path.join('dist', `${name}-${process.platform}-${process.arch}`)), + flatpak: { + runtimeVersion: String(flatpakRaw.runtimeVersion || '49'), + node: Number(flatpakRaw.node || defaultFlatpakNode()), + finishArgs: flatpakRaw.finishArgs || [], + }, } if (!/^[A-Za-z][A-Za-z0-9._-]*$/.test(config.name)) @@ -173,6 +183,13 @@ function loadConfig(appDir, flags) { return config } +// The org.freedesktop.Sdk.Extension.node major to build/run with: the +// running node's major when an extension exists for it. +function defaultFlatpakNode() { + const major = Number(process.versions.node.split('.')[0]) + return [20, 22, 24, 26].includes(major) ? major : 24 +} + function pascalCase(base) { return base .replace(/^@.*\//, '') @@ -232,4 +249,4 @@ function parseArgs(argv) { return flags } -module.exports = { run } +module.exports = { run, loadConfig, prepareOutput } diff --git a/tools/bundle/app-tree.js b/tools/bundle/app-tree.js index f3659a52..d3e790a1 100644 --- a/tools/bundle/app-tree.js +++ b/tools/bundle/app-tree.js @@ -12,6 +12,11 @@ * its build-time dependencies (node-pre-gyp, node-gyp, nan) are omitted — * lib/native.js only requires node-pre-gyp when the direct binding path is * missing, which the bundler guarantees never happens. + * + * Flatpak mode (ctx.rebuildAddon) inverts the node-gtk special case: the + * addon must be COMPILED inside the flatpak sandbox against the GNOME + * runtime's GTK, so src/ + binding.gyp + the nan headers ship instead of the + * host's compiled lib/binding. */ const fs = require('fs') @@ -20,7 +25,9 @@ const path = require('path') const { exists, mkdirp, copyFile, copyTree } = require('./util.js') // Packages never copied into a bundle. Config `omitPackages` adds to this. -const DEFAULT_OMIT = ['@mapbox/node-pre-gyp', 'node-gyp', 'nan'] +// In rebuild mode, nan (a header-only compile dependency of node-gtk) ships. +const DEFAULT_OMIT = ['@mapbox/node-pre-gyp', 'node-gyp'] +const COMPILED_OMIT = ['nan'] // Directory names never copied from the app or from packages. const ALWAYS_EXCLUDED_DIRS = new Set(['node_modules', '.git']) @@ -69,7 +76,11 @@ function copyApp(ctx) { log(`app: ${fileCount} files (include: ${config.include.join(', ')})`) // --- 2. production dependencies ----------------------------------------- - const omit = new Set([...DEFAULT_OMIT, ...config.omitPackages]) + const omit = new Set([ + ...DEFAULT_OMIT, + ...(ctx.rebuildAddon ? [] : COMPILED_OMIT), + ...config.omitPackages, + ]) const packages = collectPackages(appDir, omit) for (const [name, dir] of packages) { @@ -82,6 +93,9 @@ function copyApp(ctx) { log(`app: ${packages.size} packages (${[...packages.keys()].join(', ')})`) + if (ctx.rebuildAddon) + return { bindingPath: undefined } + const bindingPath = findBinding(ctx, appOutDir) return { bindingPath } } @@ -127,21 +141,25 @@ function resolvePackageDir(name, fromDir) { // node-gtk trimmed to its runtime files: package.json + lib/, and within // lib/binding only the ABI directory matching the bundled node binary. +// In rebuild mode the compile inputs (src/, binding.gyp) ship INSTEAD of any +// host-compiled lib/binding. function copyNodeGtk(ctx, srcDir, destDir) { const bindingName = ctx.bindingName + const topLevel = ctx.rebuildAddon + ? new Set(['package.json', 'binding.gyp', 'lib', 'src']) + : new Set(['package.json', 'lib']) copyTree(srcDir, destDir, { filter: src => { const rel = path.relative(srcDir, src) if (rel === '') return true const parts = rel.split(path.sep) - if (parts[0] === 'package.json') - return true - if (parts[0] !== 'lib') + if (!topLevel.has(parts[0])) return false if (ALWAYS_EXCLUDED_DIRS.has(parts[parts.length - 1])) return false - if (parts[1] === 'binding' && parts.length >= 3 && parts[2] !== bindingName) + if (parts[0] === 'lib' && parts[1] === 'binding' + && (ctx.rebuildAddon || (parts.length >= 3 && parts[2] !== bindingName))) return false return true }, diff --git a/tools/flatpak.js b/tools/flatpak.js new file mode 100644 index 00000000..e91ad1b1 --- /dev/null +++ b/tools/flatpak.js @@ -0,0 +1,350 @@ +/* + * flatpak.js — package a node-gtk application as a Flatpak: the format real + * users install (one-click via GNOME Software / Flathub), with shared GTK + * runtimes, updates and sandboxing. + * + * Driven by the CLI: `node-gtk flatpak [app-directory] [options]`. + * + * How it fits together: + * - The GTK runtime comes from org.gnome.Platform — nothing GTK-related is + * bundled (unlike `node-gtk bundle`). Apps share one platform copy. + * - flatpak-builder builds OFFLINE. The usual answer for node apps is + * lockfile→sources generators; we sidestep that entirely by staging the + * app tree (files + production node_modules, symlinks dereferenced) with + * the same app-tree step `node-gtk bundle` uses, and feeding it in as a + * plain `dir` source. + * - The only thing compiled in the sandbox is the node-gtk addon, against + * the runtime's GTK: node + npm's internal node-gyp come from the + * org.freedesktop.Sdk.Extension.node SDK extension, and + * `--nodedir=/usr/lib/sdk/node` points node-gyp at the extension's + * bundled headers so nothing is downloaded. + * - The node binary is copied out of the SDK extension into /app/bin (the + * Platform does not ship node at runtime). + * + * Output: /dist/flatpak/ with the manifest + desktop/metainfo/launcher + * files and the staged app tree; if a builder is available it also produces + * a local build and a single-file .flatpak bundle that users can + * double-click to install — GNOME Software fetches the Platform from Flathub + * automatically (--runtime-repo). + */ + +const fs = require('fs') +const path = require('path') + +const { exists, mkdirp, copyFile, formatSize, dirSize, exec, tryExec } = require('./bundle/util.js') +const { loadConfig, prepareOutput } = require('./bundle.js') +const appTree = require('./bundle/app-tree.js') + +const FLATHUB_REPO = 'https://dl.flathub.org/repo/flathub.flatpakrepo' + +const HELP = `Usage: node-gtk flatpak [app-directory] [options] + +Packages a node-gtk application as a Flatpak. Generates the manifest and +desktop files, stages the app for an offline flatpak-builder build, then (if +flatpak-builder or org.flatpak.Builder is available) builds it and produces a +single-file .flatpak bundle users can double-click to install. + +Options: + --out output directory (default: /dist/flatpak) + --no-build only generate the manifest + staged sources + --install install the result into the user's flatpak installation + -h, --help show this help + +Configuration (package.json "bundle" key): everything \`node-gtk bundle\` uses, +plus: summary, icon, license, categories, and + "flatpak": { "runtimeVersion": "49", "node": 26, "finishArgs": [...] } + +See doc/bundling.md for the full reference, sandbox permissions, and the +Flathub submission path.` + +function run(argv) { + try { + const flags = parseArgs(argv) + if (flags.help) { + console.log(HELP) + return + } + if (process.platform !== 'linux') + throw new Error('flatpaks are built on Linux') + flatpak(flags) + } catch (e) { + console.error(`node-gtk flatpak: ${e.message}`) + if (process.env.NODE_GTK_BUNDLE_DEBUG) + console.error(e.stack) + process.exit(1) + } +} + +function flatpak(flags) { + const appDir = path.resolve(flags.appDir || '.') + const config = loadConfig(appDir, {}) + const outBase = path.resolve(appDir, flags.out || path.join('dist', 'flatpak')) + const log = message => console.log(` ${message}`) + + console.log(`## Packaging ${config.name} (${config.id}) as a Flatpak`) + console.log(` runtime org.gnome.Platform//${config.flatpak.runtimeVersion}, node ${config.flatpak.node}`) + + prepareOutput(outBase) + + const ctx = { + config, appDir, outBase, log, + appOutDir: path.join(outBase, 'app'), + rebuildAddon: true, + } + mkdirp(ctx.appOutDir) + + console.log('## Staging application (offline sources for flatpak-builder)') + appTree.copyApp(ctx) + + console.log('## Generating manifest') + stageIcon(ctx) // before the manifest: it references the staged icon file + const manifestPath = writeManifest(ctx) + writeLauncher(ctx) + writeDesktopFile(ctx) + writeMetainfo(ctx) + + // Marker for prepareOutput, and a record of what produced this. + fs.writeFileSync(path.join(outBase, 'bundle.json'), JSON.stringify({ + name: config.name, + id: config.id, + version: config.version, + format: 'flatpak', + runtime: `org.gnome.Platform//${config.flatpak.runtimeVersion}`, + node: config.flatpak.node, + nodeGtk: require('../package.json').version, + created: new Date().toISOString(), + }, null, 2) + '\n') + + log(`manifest: ${path.relative(process.cwd(), manifestPath)}`) + + if (flags.noBuild) { + console.log(`## Done (generation only). Build with:`) + console.log(` flatpak-builder --user --install-deps-from=flathub --force-clean ${path.join(outBase, 'build')} ${manifestPath}`) + return + } + + const builder = findBuilder() + if (builder === undefined) { + console.log('## flatpak-builder not found — skipping build. Install it with:') + console.log(' flatpak install flathub org.flatpak.Builder') + console.log(` then: ${HELP.split('\n')[0]}`) + return + } + + console.log(`## Building (${builder.label}) — first build downloads the GNOME SDK`) + const repoDir = path.join(outBase, 'repo') + const buildDir = path.join(outBase, 'build') + exec(`${builder.command(outBase)} --user --force-clean --install-deps-from=flathub ` + + `--state-dir=${JSON.stringify(path.join(outBase, '.flatpak-builder'))} ` + + `--repo=${JSON.stringify(repoDir)} ${JSON.stringify(buildDir)} ${JSON.stringify(manifestPath)}`, + { stdio: ['ignore', 'inherit', 'inherit'] }) + + const bundleFile = path.join(outBase, `${config.name}.flatpak`) + console.log('## Creating single-file bundle') + exec(`flatpak build-bundle --runtime-repo=${FLATHUB_REPO} ` + + `${JSON.stringify(repoDir)} ${JSON.stringify(bundleFile)} ${config.id}`) + log(`${path.relative(process.cwd(), bundleFile)} (${formatSize(fs.statSync(bundleFile).size)})`) + + if (flags.install) { + console.log('## Installing (user)') + exec(`flatpak install --user -y --reinstall ${JSON.stringify(bundleFile)}`, + { stdio: ['ignore', 'inherit', 'inherit'] }) + log(`installed — run with: flatpak run ${config.id}`) + } + + console.log(`## Done: ${path.relative(process.cwd(), bundleFile)}`) +} + +// Native flatpak-builder if present, else the org.flatpak.Builder flatpak +// (the Flathub-recommended way to get the builder). The flatpak-run variant +// gets explicit access to the output directory: its sandbox sees the host +// filesystem EXCEPT /tmp and other special paths, so builds outside $HOME +// fail without it. +function findBuilder() { + if (tryExec('flatpak-builder --version') !== undefined) + return { label: 'flatpak-builder', command: () => 'flatpak-builder' } + if (tryExec('flatpak info org.flatpak.Builder') !== undefined) + return { + label: 'org.flatpak.Builder', + command: outBase => `flatpak run --filesystem=${JSON.stringify(outBase)} org.flatpak.Builder`, + } + return undefined +} + +// --------------------------------------------------------------------------- +// generated files +// --------------------------------------------------------------------------- + +// YAML single-quoted scalar. +function yq(value) { + return `'${String(value).replace(/'/g, "''")}'` +} + +function writeManifest(ctx) { + const { config, outBase } = ctx + const { runtimeVersion, node } = config.flatpak + const sdk = `/usr/lib/sdk/node${node}` + + // The addon build, run inside the sandbox: compile with npm's internal + // node-gyp against the SDK extension's node headers (offline). The + // module_name/module_path gyp variables are normally node-pre-gyp's job; + // with them set, binding.gyp's action_after_build installs the addon where + // lib/native.js finds it. Compile inputs are dropped afterwards. + const buildAddon = [ + `cd /app/main/node_modules/node-gtk`, + `B=$PWD/lib/binding/node-v$(node -p process.versions.modules)-linux-$(node -p process.arch)`, + `node ${sdk}/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild ` + + `--nodedir=${sdk} -- -Dmodule_name=node_gtk -Dmodule_path=$B`, + `rm -rf build src binding.gyp ../nan`, + ].join(' && ') + + const finishArgs = [ + '--socket=wayland', + '--socket=fallback-x11', + '--share=ipc', + '--device=dri', + ...config.flatpak.finishArgs, + ] + + const icon = iconInstallCommand(ctx) + + const manifest = `# Flatpak manifest for ${config.name} — generated by \`node-gtk flatpak\`. +# Reference: doc/bundling.md. Regenerate rather than editing where possible; +# persistent settings (finish-args etc.) belong in package.json "bundle". +app-id: ${config.id} +runtime: org.gnome.Platform +runtime-version: ${yq(runtimeVersion)} +sdk: org.gnome.Sdk +sdk-extensions: + - org.freedesktop.Sdk.Extension.node${node} +command: ${config.id} +finish-args: +${finishArgs.map(a => ` - ${yq(a)}`).join('\n')} +modules: + - name: ${config.id.split('.').pop().toLowerCase()} + buildsystem: simple + build-options: + append-path: ${sdk}/bin + build-commands: + # the app: staged offline by \`node-gtk flatpak\` (files + production + # node_modules); nothing is fetched from npm here + - mkdir -p /app/main && cp -a app/. /app/main/ + # the node-gtk addon, compiled against the runtime's GTK + - ${yq(buildAddon)} + # the node runtime (the Platform does not ship one) + - install -Dm755 ${sdk}/bin/node /app/bin/node + # launcher + desktop integration + - install -Dm755 launcher.sh /app/bin/${config.id} + - install -Dm644 ${config.id}.desktop /app/share/applications/${config.id}.desktop + - install -Dm644 ${config.id}.metainfo.xml /app/share/metainfo/${config.id}.metainfo.xml +${icon !== undefined ? ` - ${icon}\n` : ''} sources: + - type: dir + path: app + dest: app + - type: file + path: launcher.sh + - type: file + path: ${config.id}.desktop + - type: file + path: ${config.id}.metainfo.xml +${ctx.iconFile !== undefined ? ` - type: file\n path: ${ctx.iconFile}\n` : ''}` + + const manifestPath = path.join(outBase, `${config.id}.yml`) + fs.writeFileSync(manifestPath, manifest) + return manifestPath +} + +function writeLauncher(ctx) { + const { config, outBase } = ctx + const nodeArgs = config.nodeArgs.length > 0 ? config.nodeArgs.join(' ') + ' ' : '' + const entry = config.entry.split(path.sep).join('/') + fs.writeFileSync(path.join(outBase, 'launcher.sh'), `#!/bin/sh +# ${config.name} — generated by \`node-gtk flatpak\`. +cd /app/main +exec /app/bin/node ${nodeArgs}./${entry} "$@" +`) +} + +function writeDesktopFile(ctx) { + const { config, outBase } = ctx + fs.writeFileSync(path.join(outBase, `${config.id}.desktop`), `[Desktop Entry] +Type=Application +Name=${config.name} +Comment=${config.summary} +Exec=${config.id} %U +Icon=${config.id} +Terminal=false +Categories=${[...config.categories, ''].join(';')} +`) +} + +function writeMetainfo(ctx) { + const { config, outBase } = ctx + const esc = s => String(s).replace(/&/g, '&').replace(//g, '>') + // Enough for local installs; Flathub review needs real content — the + // generated TODOs mark what to fill in. + fs.writeFileSync(path.join(outBase, `${config.id}.metainfo.xml`), ` + + ${esc(config.id)} + CC0-1.0 + ${esc(config.license || 'LicenseRef-proprietary')} + ${esc(config.name)} + ${esc(config.summary)} + +

${esc(config.summary)}

+ +
+ ${esc(config.id)}.desktop +
+`) +} + +// Copy the configured icon next to the manifest and return the in-sandbox +// install command for it. SVG installs as scalable; PNG as 256x256. +function stageIcon(ctx) { + const { config, appDir, outBase, log } = ctx + if (config.icon === undefined) { + log('no "bundle.icon" configured — the app will show a generic icon (Flathub requires one)') + return + } + const src = path.resolve(appDir, config.icon) + if (!exists(src)) + throw new Error(`icon not found: ${config.icon}`) + const ext = path.extname(src).toLowerCase() + if (ext !== '.svg' && ext !== '.png') + throw new Error(`icon must be .svg or .png, got: ${config.icon}`) + ctx.iconFile = `icon${ext}` + copyFile(src, path.join(outBase, ctx.iconFile)) +} + +function iconInstallCommand(ctx) { + if (ctx.iconFile === undefined) + return undefined + const { config } = ctx + const dir = ctx.iconFile.endsWith('.svg') ? 'scalable' : '256x256' + return `install -Dm644 ${ctx.iconFile} /app/share/icons/hicolor/${dir}/apps/${config.id}${path.extname(ctx.iconFile)}` +} + +// --------------------------------------------------------------------------- + +function parseArgs(argv) { + const flags = {} + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + switch (arg) { + case '-h': case '--help': flags.help = true; break + case '--no-build': flags.noBuild = true; break + case '--install': flags.install = true; break + case '--out': flags.out = argv[++i]; break + default: + if (arg.startsWith('-')) + throw new Error(`unknown option '${arg}' — see node-gtk flatpak --help`) + if (flags.appDir !== undefined) + throw new Error(`unexpected argument '${arg}'`) + flags.appDir = arg + } + } + return flags +} + +module.exports = { run } From 7212b35fc34238a128d86ace1a0dc5e34807ee71 Mon Sep 17 00:00:00 2001 From: Rom Grk Date: Sun, 5 Jul 2026 02:17:24 -0400 Subject: [PATCH 4/6] fix: register the gi: import hooks in generated launchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apps written with the `gi:` import scheme (the `node-gtk create` default) crashed at startup in bundles and flatpaks with ERR_UNSUPPORTED_ESM_URL_SCHEME: the loader hooks are installed by `node --import node-gtk/register`, which the generated launchers only passed when the app configured nodeArgs by hand. Launchers now pass --import node-gtk/register by default; the new `register: false` config key opts out. Harmless for CJS apps — register.mjs only installs module hooks. Verified: an ESM `import Gtk from 'gi:Gtk-4.0'` app built, installed and ran as a flatpak; the CJS bundle smoke test still passes with the flag present. Co-Authored-By: Claude Fable 5 --- doc/bundling.md | 4 +++- scripts/flatpak-smoke-test.js | 3 ++- tools/bundle.js | 5 +++++ tools/bundle/platform-linux.js | 3 ++- tools/flatpak.js | 3 ++- 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/doc/bundling.md b/doc/bundling.md index 24905cf3..ffdcfb04 100644 --- a/doc/bundling.md +++ b/doc/bundling.md @@ -80,7 +80,9 @@ is optional: "gtk": 4, // bundle only this GTK major (default: all installed) "include": ["src/**", "assets/**"], // app files; default: "**/*" minus // node_modules/.git/dist/out/build - "nodeArgs": ["--import", "node-gtk/register"], // e.g. for TS entries + "nodeArgs": ["--max-old-space-size=512"], // extra node flags, if any + "register": true, // default: launchers pass --import node-gtk/register + // so `gi:` imports work; set false to opt out "libraries": ["libgstreamer-1.0.so.0"], // extra closure seeds (GStreamer, libsoup, …) "omitPackages": ["some-dev-helper"], // npm packages to leave out "icons": false, // skip Adwaita/hicolor themes (default: true) diff --git a/scripts/flatpak-smoke-test.js b/scripts/flatpak-smoke-test.js index dcaea993..0702d371 100644 --- a/scripts/flatpak-smoke-test.js +++ b/scripts/flatpak-smoke-test.js @@ -54,7 +54,8 @@ if (result.status !== 0) { const checks = [ // generated integration files [`${id}.yml`, content => content.includes(`app-id: ${id}`) && content.includes('org.freedesktop.Sdk.Extension.node')], - ['launcher.sh', content => content.includes('exec /app/bin/node')], + // the gi: import scheme must work out of the box → loader registered + ['launcher.sh', content => content.includes('exec /app/bin/node --import node-gtk/register')], [`${id}.desktop`, content => content.includes('Exec=' + id)], [`${id}.metainfo.xml`, content => content.includes(`${id}`)], // the staged tree must be COMPILABLE in the sandbox diff --git a/tools/bundle.js b/tools/bundle.js index a45b644f..054e3435 100644 --- a/tools/bundle.js +++ b/tools/bundle.js @@ -164,6 +164,11 @@ function loadConfig(appDir, flags) { gtk: raw.gtk, entry: path.normalize(entry), include: raw.include || ['**/*'], + // The `gi:` import scheme (the default in `node-gtk create` apps) only + // works with the loader hooks registered, so launchers pass + // `--import node-gtk/register` unless explicitly disabled. Harmless for + // CJS apps: register.mjs only installs hooks. + register: raw.register !== false, nodeArgs: raw.nodeArgs || [], libraries: raw.libraries || [], omitPackages: raw.omitPackages || [], diff --git a/tools/bundle/platform-linux.js b/tools/bundle/platform-linux.js index 0aea9134..271a9147 100644 --- a/tools/bundle/platform-linux.js +++ b/tools/bundle/platform-linux.js @@ -175,7 +175,8 @@ function pkgConfigVar(pkg, variable) { function writeLauncher(ctx) { const { config, outBase } = ctx - const nodeArgs = config.nodeArgs.length > 0 ? config.nodeArgs.join(' ') + ' ' : '' + const args = [...(config.register ? ['--import', 'node-gtk/register'] : []), ...config.nodeArgs] + const nodeArgs = args.length > 0 ? args.join(' ') + ' ' : '' const launcherPath = path.join(outBase, config.name) const entry = config.entry.split(path.sep).join('/') diff --git a/tools/flatpak.js b/tools/flatpak.js index e91ad1b1..f29166cc 100644 --- a/tools/flatpak.js +++ b/tools/flatpak.js @@ -256,7 +256,8 @@ ${ctx.iconFile !== undefined ? ` - type: file\n path: ${ctx.iconFile function writeLauncher(ctx) { const { config, outBase } = ctx - const nodeArgs = config.nodeArgs.length > 0 ? config.nodeArgs.join(' ') + ' ' : '' + const args = [...(config.register ? ['--import', 'node-gtk/register'] : []), ...config.nodeArgs] + const nodeArgs = args.length > 0 ? args.join(' ') + ' ' : '' const entry = config.entry.split(path.sep).join('/') fs.writeFileSync(path.join(outBase, 'launcher.sh'), `#!/bin/sh # ${config.name} — generated by \`node-gtk flatpak\`. From fef8c4372b2703e2be5d3134e994faa7ed4b97ca Mon Sep 17 00:00:00 2001 From: Rom Grk Date: Sun, 5 Jul 2026 02:29:35 -0400 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20flatpak=20gotcha=20=E2=80=94=20runt?= =?UTF-8?q?ime=20API=20surface=20vs=20dev=20machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rolling-release host can expose introspected names an older GNOME runtime doesn't (glib 2.88 introspects g_unix_signal_add_full as GLibUnix.signalAdd; the GNOME 49 runtime's glib 2.86 exposes signalAddFull). Document the version-tolerant lookup pattern and the one-liner to get a node REPL inside the sandbox. Co-Authored-By: Claude Fable 5 --- doc/bundling.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/doc/bundling.md b/doc/bundling.md index ffdcfb04..8ca543bc 100644 --- a/doc/bundling.md +++ b/doc/bundling.md @@ -197,13 +197,21 @@ Defaults grant only GUI access (`wayland`, `fallback-x11`, `ipc`, `dri`) — network and filesystem are deliberately opt-in; request the minimum, Flathub reviews it. -Two things that bite: +Three things that bite: - **The flatpak id must equal your `Gtk.Application` `applicationId`** — otherwise GNOME Shell can't associate windows with the app (generic icon, wrong dock entry). - The sandbox has no host filesystem by default: `fs` reads outside the sandbox need `--filesystem=` permissions, or better, the XDG portals. +- **The API surface follows the runtime's libraries, not your dev + machine's.** A rolling-release host can expose introspected names a + slightly older GNOME runtime doesn't (e.g. glib ≥2.88 introspects + `g_unix_signal_add_full` as `GLibUnix.signalAdd`; the GNOME 49 runtime's + glib 2.86 calls it `signalAddFull`). Write version-tolerant lookups + (`GLibUnix.signalAdd ?? GLibUnix.signalAddFull`) and test inside the + sandbox — a node REPL against the runtime is one command: + `flatpak run --command=/app/bin/node `. ## Shipping on Flathub From 9276146d2439598292ebfbeb783f7c833cbf2c0c Mon Sep 17 00:00:00 2001 From: Rom Grk Date: Sun, 5 Jul 2026 13:21:02 -0400 Subject: [PATCH 6/6] feat: flatpak --release/--lint/--run, Flathub-ready scaffold, full-build CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything between 'builds locally' and 'on Flathub': - --release: Flathub builds from fetchable sources, so tarball the staged tree (top-level dir matching archive strip-components) and emit .flathub.yml referencing it by url+sha256; the URL derives from package.json "repository" (--release-url overrides). Prints the upload-and-submit steps. - --lint: flatpak-builder-lint (native or via org.flatpak.Builder with output-dir filesystem access) on the manifest + metainfo — surfaces the exact Flathub-review checklist. Metainfo now auto-fills the homepage from "repository". - --run: install + run in one step. - Warn when staged sources set a GApplication applicationId different from the flatpak id (D-Bus registration fails inside the sandbox — hit this for real with the first app). - bundle.json marker written FIRST so a failed run leaves a directory the next run may overwrite. - flatpak-build.yaml (workflow_dispatch): full sandbox build + install + headless run (dbus-run-session + xvfb) with the GNOME SDK cached; main.yaml keeps the cheap generation-only check. Verified locally: the exact --full path passes end-to-end. - `node-gtk create` scaffolds Flathub-ready: bundle block whose id matches the app's APP_ID by construction, placeholder data/icon.svg, flatpak/bundle npm scripts, README shipping section. Co-Authored-By: Claude Fable 5 --- .github/workflows/flatpak-build.yaml | 42 +++++ doc/bundling.md | 43 ++++- scripts/flatpak-smoke-test.js | 77 ++++++-- tools/bundle.js | 14 +- tools/create-app.js | 1 + tools/flatpak.js | 239 +++++++++++++++++++++---- tools/templates/app/README.md.tmpl | 14 ++ tools/templates/app/data/icon.svg.tmpl | 13 ++ tools/templates/app/package.json.tmpl | 9 + 9 files changed, 389 insertions(+), 63 deletions(-) create mode 100644 .github/workflows/flatpak-build.yaml create mode 100644 tools/templates/app/data/icon.svg.tmpl diff --git a/.github/workflows/flatpak-build.yaml b/.github/workflows/flatpak-build.yaml new file mode 100644 index 00000000..5e8b5a3e --- /dev/null +++ b/.github/workflows/flatpak-build.yaml @@ -0,0 +1,42 @@ +name: flatpak-build +# +# Full `node-gtk flatpak` verification: build a smoke app's flatpak for real +# (addon compiled in the sandbox against org.gnome.Platform), install it, and +# run it headless. This downloads the ~1GB GNOME SDK (cached between runs), +# so it is not part of the per-push matrix — main.yaml runs the cheap +# generation-only smoke test instead. +# +on: + workflow_dispatch: + +jobs: + full-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install flatpak tooling + run: | + sudo apt-get update + sudo apt-get install -y flatpak flatpak-builder dbus xvfb + flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + + # The GNOME SDK + node extension the build pulls via + # --install-deps-from=flathub. Keyed on the runtime/extension pair the + # smoke app resolves to (node 24 -> node24 extension). + - uses: actions/cache@v4 + with: + path: ~/.local/share/flatpak + key: flatpak-org.gnome.Sdk-49-node24-v1 + + # Staging only needs the JS dependency tree (node-gtk ships its compile + # inputs; the addon builds inside the flatpak sandbox), so no host GTK + # and no host addon build. + - run: npm install -g pnpm@11 + - run: pnpm install --ignore-scripts + + - name: Full flatpak smoke test (build + install + run) + run: node scripts/flatpak-smoke-test.js --full diff --git a/doc/bundling.md b/doc/bundling.md index 8ca543bc..d10ce429 100644 --- a/doc/bundling.md +++ b/doc/bundling.md @@ -183,7 +183,8 @@ The shared `"bundle"` key, plus a `"flatpak"` sub-key: "license": "MIT", // SPDX, defaults to package.json "license" "categories": ["GTK", "Utility"], "flatpak": { - "runtimeVersion": "49", // org.gnome.Platform version + "runtimeVersion": "49", // org.gnome.Platform version (50 is also current; + // pick one and test against it) "node": 26, // SDK extension major (20/22/24/26) "finishArgs": [ // sandbox permissions beyond the GUI defaults "--share=network", @@ -193,6 +194,11 @@ The shared `"bundle"` key, plus a `"flatpak"` sub-key: } ``` +Flags: `--install` (install user-level), `--run` (install + run), `--no-build` +(generate only), `--release` / `--release-url` (Flathub submission sources, +below), `--lint` (run `flatpak-builder-lint` on the manifest + metainfo — do +this before submitting anywhere). + Defaults grant only GUI access (`wayland`, `fallback-x11`, `ipc`, `dri`) — network and filesystem are deliberately opt-in; request the minimum, Flathub reviews it. @@ -215,14 +221,33 @@ Three things that bite: ## Shipping on Flathub -Flathub is a manifest repository: you submit the generated -`.yml` (plus your app source — for a node app, typically a release -tarball of the staged `app/` tree) via PR to -[flathub/flathub](https://github.com/flathub/flathub). Before submitting, -fill in the metainfo TODOs (`description`, screenshots, releases, -`content_rating`) and provide a real icon; `flatpak run org.flatpak.Builder ---command=flatpak-builder-lint` checks compliance. After acceptance, users -find the app in GNOME Software and updates ship automatically. +Flathub builds on its own infrastructure from *fetchable* sources — the +locally staged tree can't be submitted directly. `--release` produces exactly +what a submission needs: + +```sh +npx node-gtk flatpak --release --lint +``` + +- `--flatpak-src.tar.gz` — the staged sources (app + + production node_modules + desktop files) as one tarball +- `.flathub.yml` — the manifest referencing that tarball by URL + sha256 + (URL derived from package.json `"repository"`: + `https://github.com///releases/download/v/`; + override with `--release-url`) + +The flow: **1.** fix everything `--lint` reports (the metainfo TODOs — +description, screenshots, releases, content rating — and a real icon); +**2.** create the GitHub release `v` and upload the tarball; +**3.** submit `.flathub.yml` via PR to +[flathub/flathub](https://github.com/flathub/flathub). After acceptance, +users find the app in GNOME Software and every update ships automatically — +new releases are a version bump + new tarball + manifest update in your +Flathub repo. + +Alternative without Flathub: host your own flatpak repository (an ostree +repo is static files — GitHub Pages works) and point users at a `.flatpakref`; +you keep update delivery, minus the store discoverability. # Roadmap diff --git a/scripts/flatpak-smoke-test.js b/scripts/flatpak-smoke-test.js index 0702d371..0494fdd5 100644 --- a/scripts/flatpak-smoke-test.js +++ b/scripts/flatpak-smoke-test.js @@ -1,16 +1,22 @@ /* * flatpak-smoke-test.js * - * Checks `node-gtk flatpak --no-build`: stages a minimal app and asserts the - * generated flatpak sources are complete and buildable — manifest, launcher, - * desktop file, metainfo, and an app tree carrying the node-gtk COMPILE - * inputs (src/ + binding.gyp + nan) instead of a host-compiled binding. + * Checks `node-gtk flatpak`. Two modes: * - * The actual sandbox build is exercised locally / at release time, not on - * every CI run: it downloads the ~1 GB GNOME SDK. This test needs no GTK, no + * Default (generation only — what main.yaml runs on every CI push): stages a + * minimal app and asserts the generated flatpak sources are complete and + * buildable — manifest, launcher, desktop file, metainfo, release artifacts + * (--release), and an app tree carrying the node-gtk COMPILE inputs (src/ + + * binding.gyp + nan) instead of a host-compiled binding. Needs no GTK, no * display and no flatpak. * - * Usage: node scripts/flatpak-smoke-test.js + * --full (the flatpak-build.yaml workflow_dispatch job, and local release + * checks): additionally builds the flatpak for real (downloads the GNOME SDK + * on first run), installs it user-level, and runs the installed app under + * a private D-Bus session + Xvfb, asserting the GTK code executed inside the + * sandbox. + * + * Usage: node scripts/flatpak-smoke-test.js [--full] */ const fs = require('fs') @@ -23,6 +29,7 @@ if (process.platform !== 'linux') { process.exit(0) } +const full = process.argv.includes('--full') const repoRoot = path.resolve(__dirname, '..') const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'node-gtk-flatpak-smoke-')) const appDir = path.join(tmp, 'app') @@ -34,23 +41,39 @@ fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ version: '1.0.0', main: 'main.js', license: 'MIT', + repository: 'https://github.com/romgrk/node-gtk.git', dependencies: { 'node-gtk': '*' }, bundle: { name: 'FlatpakSmoke', id, summary: 'Flatpak generation smoke test' }, }, null, 2)) -fs.writeFileSync(path.join(appDir, 'main.js'), `require('node-gtk')\n`) +// A real GTK4 app: org.gnome.Platform guarantees Gtk 4.0 in --full mode; the +// generation-only mode never executes it. +fs.writeFileSync(path.join(appDir, 'main.js'), ` +const gi = require('node-gtk') +const Gtk = gi.require('Gtk', '4.0') +Gtk.init() +const label = new Gtk.Label({ label: 'flatpak-smoke' }) +console.log('FLATPAK_SMOKE_OK', label.getLabel(), 'exec=' + process.execPath) +process.exit(0) +`) fs.mkdirSync(path.join(appDir, 'node_modules')) fs.symlinkSync(repoRoot, path.join(appDir, 'node_modules', 'node-gtk'), 'dir') const outDir = path.join(tmp, 'out') -const result = child_process.spawnSync( - process.execPath, - [path.join(repoRoot, 'bin', 'node-gtk.js'), 'flatpak', appDir, '--out', outDir, '--no-build'], +const args = [path.join(repoRoot, 'bin', 'node-gtk.js'), 'flatpak', appDir, '--out', outDir, '--release'] +if (!full) + args.push('--no-build') +else + args.push('--install') + +const result = child_process.spawnSync(process.execPath, args, { stdio: 'inherit', env: { ...process.env, NODE_GTK_BUNDLE_DEBUG: '1' } }) if (result.status !== 0) { - console.error(`smoke: FAIL — generation exited with ${result.status} (kept: ${tmp})`) + console.error(`smoke: FAIL — node-gtk flatpak exited with ${result.status} (kept: ${tmp})`) process.exit(1) } +// --- generated sources ------------------------------------------------------ + const checks = [ // generated integration files [`${id}.yml`, content => content.includes(`app-id: ${id}`) && content.includes('org.freedesktop.Sdk.Extension.node')], @@ -58,10 +81,13 @@ const checks = [ ['launcher.sh', content => content.includes('exec /app/bin/node --import node-gtk/register')], [`${id}.desktop`, content => content.includes('Exec=' + id)], [`${id}.metainfo.xml`, content => content.includes(`${id}`)], + // --release: Flathub manifest referencing the tarball by url + sha256 + [`${id}.flathub.yml`, content => /type: archive/.test(content) && /sha256: [0-9a-f]{64}/.test(content)], + ['FlatpakSmoke-1.0.0-flatpak-src.tar.gz', undefined], // the staged tree must be COMPILABLE in the sandbox - ['app/node_modules/node-gtk/binding.gyp', () => true], - ['app/node_modules/node-gtk/src', () => true], - ['app/node_modules/nan', () => true], + ['app/node_modules/node-gtk/binding.gyp', undefined], + ['app/node_modules/node-gtk/src', undefined], + ['app/node_modules/nan', undefined], ] for (const [rel, check] of checks) { const p = path.join(outDir, rel) @@ -69,7 +95,7 @@ for (const [rel, check] of checks) { console.error(`smoke: FAIL — missing ${rel} (kept: ${tmp})`) process.exit(1) } - if (fs.statSync(p).isFile() && !check(fs.readFileSync(p, 'utf8'))) { + if (check !== undefined && !check(fs.readFileSync(p, 'utf8'))) { console.error(`smoke: FAIL — unexpected content in ${rel} (kept: ${tmp})`) process.exit(1) } @@ -81,5 +107,24 @@ if (fs.existsSync(path.join(outDir, 'app/node_modules/node-gtk/lib/binding'))) { process.exit(1) } +// --- full mode: run the installed flatpak ------------------------------------ + +if (full) { + console.log(`smoke: running installed ${id} under dbus-run-session + xvfb`) + const runResult = child_process.spawnSync( + 'dbus-run-session', ['--', 'xvfb-run', '-a', 'flatpak', 'run', id], { encoding: 'utf8' }) + process.stdout.write(runResult.stdout || '') + process.stderr.write(runResult.stderr || '') + if (runResult.status !== 0 || !(runResult.stdout || '').includes('FLATPAK_SMOKE_OK')) { + console.error(`smoke: FAIL — flatpak run exited with ${runResult.status} (kept: ${tmp})`) + process.exit(1) + } + if (!runResult.stdout.includes('exec=/app/bin/node')) { + console.error(`smoke: FAIL — app ran under the wrong node (kept: ${tmp})`) + process.exit(1) + } + child_process.spawnSync('flatpak', ['uninstall', '--user', '-y', id]) +} + console.log('smoke: PASS') fs.rmSync(tmp, { recursive: true, force: true }) diff --git a/tools/bundle.js b/tools/bundle.js index 054e3435..74578fd9 100644 --- a/tools/bundle.js +++ b/tools/bundle.js @@ -78,6 +78,11 @@ function bundle(flags, platform) { prepareOutput(outBase) + // Early marker so a FAILED run leaves a directory prepareOutput still + // recognizes as ours (overwriteable on retry); rewritten with the full + // record once the bundle exists. + writeMarker(outBase, { name: config.name, id: config.id }) + const ctx = { config, appDir, outBase, log, bindingName: `node-v${process.versions.modules}-${process.platform}-${process.arch}`, @@ -114,7 +119,7 @@ function bundle(flags, platform) { const launcherPath = platform.writeLauncher(ctx) - fs.writeFileSync(path.join(outBase, 'bundle.json'), JSON.stringify({ + writeMarker(outBase, { name: config.name, id: config.id, version: config.version, @@ -124,7 +129,7 @@ function bundle(flags, platform) { node: process.version, nodeGtk: require('../package.json').version, created: new Date().toISOString(), - }, null, 2) + '\n') + }) reportSizes(ctx) @@ -157,6 +162,7 @@ function loadConfig(appDir, flags) { name, id: raw.id || `com.example.${name}`, version: pkg.version || '0.0.0', + repository: typeof pkg.repository === 'string' ? pkg.repository : (pkg.repository || {}).url, summary: raw.summary || `The ${name} application`, license: raw.license || pkg.license, icon: raw.icon, @@ -204,6 +210,10 @@ function pascalCase(base) { .join('') || 'App' } +function writeMarker(outBase, record) { + fs.writeFileSync(path.join(outBase, 'bundle.json'), JSON.stringify(record, null, 2) + '\n') +} + // Refuse to delete a directory we did not create: a previous output is // recognized by its bundle.json manifest (or by being empty). function prepareOutput(outBase) { diff --git a/tools/create-app.js b/tools/create-app.js index f590d2e1..ff651037 100644 --- a/tools/create-app.js +++ b/tools/create-app.js @@ -25,6 +25,7 @@ const FILES = [ ['style.css.tmpl', 'style.css'], ['src/main.ts.tmpl', path.join('src', 'main.ts')], ['src/welcome.ts.tmpl', path.join('src', 'welcome.ts')], + ['data/icon.svg.tmpl', path.join('data', 'icon.svg')], ] // --------------------------------------------------------------------------- diff --git a/tools/flatpak.js b/tools/flatpak.js index f29166cc..41dea261 100644 --- a/tools/flatpak.js +++ b/tools/flatpak.js @@ -26,12 +26,20 @@ * a local build and a single-file .flatpak bundle that users can * double-click to install — GNOME Software fetches the Platform from Flathub * automatically (--runtime-repo). + * + * --release additionally produces what a Flathub submission needs: Flathub + * builds on its own infrastructure from FETCHABLE sources, so the staged + * tree is tarballed and a second manifest (.flathub.yml) references that + * tarball by url + sha256. Upload the tarball to a release, submit the + * manifest. */ const fs = require('fs') const path = require('path') +const crypto = require('crypto') +const child_process = require('child_process') -const { exists, mkdirp, copyFile, formatSize, dirSize, exec, tryExec } = require('./bundle/util.js') +const { exists, mkdirp, copyFile, formatSize, exec, tryExec } = require('./bundle/util.js') const { loadConfig, prepareOutput } = require('./bundle.js') const appTree = require('./bundle/app-tree.js') @@ -45,10 +53,16 @@ flatpak-builder or org.flatpak.Builder is available) builds it and produces a single-file .flatpak bundle users can double-click to install. Options: - --out output directory (default: /dist/flatpak) - --no-build only generate the manifest + staged sources - --install install the result into the user's flatpak installation - -h, --help show this help + --out output directory (default: /dist/flatpak) + --no-build only generate the manifest + staged sources + --install install the result into the user's flatpak installation + --run install, then run the app (implies --install) + --release also produce Flathub submission sources: a tarball of + the staged app + .flathub.yml referencing it + --release-url download URL the release tarball will live at + (default: derived from package.json "repository") + --lint run flatpak-builder-lint on the manifest + metainfo + -h, --help show this help Configuration (package.json "bundle" key): everything \`node-gtk bundle\` uses, plus: summary, icon, license, categories, and @@ -86,6 +100,19 @@ function flatpak(flags) { prepareOutput(outBase) + // Marker for prepareOutput (so a failed run stays overwriteable), and a + // record of what produced this. Written first on purpose. + fs.writeFileSync(path.join(outBase, 'bundle.json'), JSON.stringify({ + name: config.name, + id: config.id, + version: config.version, + format: 'flatpak', + runtime: `org.gnome.Platform//${config.flatpak.runtimeVersion}`, + node: config.flatpak.node, + nodeGtk: require('../package.json').version, + created: new Date().toISOString(), + }, null, 2) + '\n') + const ctx = { config, appDir, outBase, log, appOutDir: path.join(outBase, 'app'), @@ -95,6 +122,7 @@ function flatpak(flags) { console.log('## Staging application (offline sources for flatpak-builder)') appTree.copyApp(ctx) + checkApplicationId(ctx) console.log('## Generating manifest') stageIcon(ctx) // before the manifest: it references the staged icon file @@ -103,20 +131,22 @@ function flatpak(flags) { writeDesktopFile(ctx) writeMetainfo(ctx) - // Marker for prepareOutput, and a record of what produced this. - fs.writeFileSync(path.join(outBase, 'bundle.json'), JSON.stringify({ - name: config.name, - id: config.id, - version: config.version, - format: 'flatpak', - runtime: `org.gnome.Platform//${config.flatpak.runtimeVersion}`, - node: config.flatpak.node, - nodeGtk: require('../package.json').version, - created: new Date().toISOString(), - }, null, 2) + '\n') - log(`manifest: ${path.relative(process.cwd(), manifestPath)}`) + const lintTargets = [['manifest', manifestPath], ['appstream', path.join(outBase, `${config.id}.metainfo.xml`)]] + + if (flags.release) { + console.log('## Producing Flathub submission sources (--release)') + const flathubManifest = writeReleaseArtifacts(ctx, flags) + lintTargets[0] = ['manifest', flathubManifest] + } + + if (flags.lint) { + console.log('## Linting (flatpak-builder-lint)') + if (!runLint(ctx, lintTargets)) + process.exitCode = 1 + } + if (flags.noBuild) { console.log(`## Done (generation only). Build with:`) console.log(` flatpak-builder --user --install-deps-from=flathub --force-clean ${path.join(outBase, 'build')} ${manifestPath}`) @@ -127,7 +157,6 @@ function flatpak(flags) { if (builder === undefined) { console.log('## flatpak-builder not found — skipping build. Install it with:') console.log(' flatpak install flathub org.flatpak.Builder') - console.log(` then: ${HELP.split('\n')[0]}`) return } @@ -145,7 +174,7 @@ function flatpak(flags) { `${JSON.stringify(repoDir)} ${JSON.stringify(bundleFile)} ${config.id}`) log(`${path.relative(process.cwd(), bundleFile)} (${formatSize(fs.statSync(bundleFile).size)})`) - if (flags.install) { + if (flags.install || flags.run) { console.log('## Installing (user)') exec(`flatpak install --user -y --reinstall ${JSON.stringify(bundleFile)}`, { stdio: ['ignore', 'inherit', 'inherit'] }) @@ -153,6 +182,12 @@ function flatpak(flags) { } console.log(`## Done: ${path.relative(process.cwd(), bundleFile)}`) + + if (flags.run) { + console.log(`## Running ${config.id}`) + const result = child_process.spawnSync('flatpak', ['run', config.id], { stdio: 'inherit' }) + process.exitCode = result.status === null ? 1 : result.status + } } // Native flatpak-builder if present, else the org.flatpak.Builder flatpak @@ -171,6 +206,123 @@ function findBuilder() { return undefined } +// --------------------------------------------------------------------------- +// application-id check +// --------------------------------------------------------------------------- + +// The sandbox only lets the app own the D-Bus name matching its flatpak id: +// a GApplication with a different applicationId fails to register +// (GDBus.Error ServiceUnknown) and typically exits without a window. Scan the +// staged sources for applicationId literals and warn on a mismatch. +function checkApplicationId(ctx) { + const { config, appOutDir, log } = ctx + const found = new Set() + const re = /application_?[iI]d\s*[:=]\s*['"]([A-Za-z0-9._-]+)['"]/g + + const visit = dir => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name.startsWith('.')) + continue + const p = path.join(dir, entry.name) + if (entry.isDirectory()) { + visit(p) + } else if (/\.(js|mjs|cjs|ts|mts|jsx|tsx)$/.test(entry.name) && fs.statSync(p).size < 512 * 1024) { + for (const m of fs.readFileSync(p, 'utf8').matchAll(re)) + found.add(m[1]) + } + } + } + visit(appOutDir) + + if (found.size > 0 && !found.has(config.id)) { + log(`WARNING: the app sets applicationId ${[...found].join(', ')} but the flatpak id is ${config.id}.`) + log(` The sandbox only allows owning the flatpak id on D-Bus, so GApplication`) + log(` registration WILL fail. Make bundle.id and applicationId identical.`) + } +} + +// --------------------------------------------------------------------------- +// Flathub release sources +// --------------------------------------------------------------------------- + +// Flathub builds from fetchable sources on its own infrastructure. Tarball +// the staged sources (with a top-level directory, matching the default +// strip-components of archive sources) and emit a manifest referencing it. +function writeReleaseArtifacts(ctx, flags) { + const { config, outBase, log } = ctx + const top = `${config.name}-${config.version}` + const tarName = `${top}-flatpak-src.tar.gz` + const tarPath = path.join(outBase, tarName) + + const members = ['app', 'launcher.sh', `${config.id}.desktop`, `${config.id}.metainfo.xml`] + if (ctx.iconFile !== undefined) + members.push(ctx.iconFile) + exec(`tar -czf ${JSON.stringify(tarPath)} --transform ${JSON.stringify(`s,^,${top}/,`)} ` + + `-C ${JSON.stringify(outBase)} ${members.map(m => JSON.stringify(m)).join(' ')}`) + + const sha256 = crypto.createHash('sha256').update(fs.readFileSync(tarPath)).digest('hex') + const url = releaseUrl(ctx, tarName, flags) + + const sources = ` - type: archive + url: ${url} + sha256: ${sha256}` + const flathubManifestPath = path.join(outBase, `${config.id}.flathub.yml`) + fs.writeFileSync(flathubManifestPath, manifestContent(ctx, sources, + `# Flathub submission manifest: sources are fetched from the release tarball.`)) + + log(`${tarName} (${formatSize(fs.statSync(tarPath).size)}) sha256=${sha256.slice(0, 12)}…`) + log(`${config.id}.flathub.yml (sources: ${url})`) + console.log(` Next: upload ${tarName} to a release at that URL, fill the metainfo`) + console.log(` TODOs, then submit the .flathub.yml manifest to github.com/flathub/flathub`) + return flathubManifestPath +} + +function releaseUrl(ctx, tarName, flags) { + if (flags.releaseUrl !== undefined) + return flags.releaseUrl.endsWith('.tar.gz') ? flags.releaseUrl + : `${flags.releaseUrl.replace(/\/$/, '')}/${tarName}` + const { config } = ctx + const m = /github\.com[:/]([^/]+)\/([^/.]+)/.exec(config.repository || '') + if (m !== null) + return `https://github.com/${m[1]}/${m[2]}/releases/download/v${config.version}/${tarName}` + ctx.log(`(no --release-url and no github "repository" in package.json — using a placeholder URL)`) + return `https://example.com/TODO/${tarName}` +} + +// --------------------------------------------------------------------------- +// lint +// --------------------------------------------------------------------------- + +function runLint(ctx, targets) { + const { outBase, log } = ctx + let command, args + if (tryExec('flatpak-builder-lint --version') !== undefined) { + command = 'flatpak-builder-lint' + args = [] + } else if (tryExec('flatpak info org.flatpak.Builder') !== undefined) { + command = 'flatpak' + args = ['run', `--filesystem=${outBase}`, '--command=flatpak-builder-lint', 'org.flatpak.Builder'] + } else { + log('flatpak-builder-lint not available — install with: flatpak install flathub org.flatpak.Builder') + return true + } + + let ok = true + for (const [kind, file] of targets) { + const result = child_process.spawnSync(command, [...args, kind, file], { encoding: 'utf8' }) + const output = ((result.stdout || '') + (result.stderr || '')).trim() + if (result.status === 0) { + log(`lint ${kind}: OK`) + } else { + ok = false + log(`lint ${kind}: FAILED`) + if (output !== '') + console.log(output.split('\n').map(l => ` ${l}`).join('\n')) + } + } + return ok +} + // --------------------------------------------------------------------------- // generated files // --------------------------------------------------------------------------- @@ -180,8 +332,12 @@ function yq(value) { return `'${String(value).replace(/'/g, "''")}'` } -function writeManifest(ctx) { - const { config, outBase } = ctx +// The manifest body, parameterized over the sources block: local builds use +// dir+file sources pointing at the generated tree; the Flathub variant uses +// a single release-tarball archive source (extracted with the default +// strip-components=1, hence the tarball's top-level directory). +function manifestContent(ctx, sourcesYaml, note) { + const { config } = ctx const { runtimeVersion, node } = config.flatpak const sdk = `/usr/lib/sdk/node${node}` @@ -208,7 +364,8 @@ function writeManifest(ctx) { const icon = iconInstallCommand(ctx) - const manifest = `# Flatpak manifest for ${config.name} — generated by \`node-gtk flatpak\`. + return `# Flatpak manifest for ${config.name} — generated by \`node-gtk flatpak\`. +${note} # Reference: doc/bundling.md. Regenerate rather than editing where possible; # persistent settings (finish-args etc.) belong in package.json "bundle". app-id: ${config.id} @@ -238,19 +395,23 @@ modules: - install -Dm644 ${config.id}.desktop /app/share/applications/${config.id}.desktop - install -Dm644 ${config.id}.metainfo.xml /app/share/metainfo/${config.id}.metainfo.xml ${icon !== undefined ? ` - ${icon}\n` : ''} sources: - - type: dir - path: app - dest: app - - type: file - path: launcher.sh - - type: file - path: ${config.id}.desktop - - type: file - path: ${config.id}.metainfo.xml -${ctx.iconFile !== undefined ? ` - type: file\n path: ${ctx.iconFile}\n` : ''}` +${sourcesYaml} +` +} + +function writeManifest(ctx) { + const { config, outBase } = ctx + const sources = [ + ' - type: dir\n path: app\n dest: app', + ' - type: file\n path: launcher.sh', + ` - type: file\n path: ${config.id}.desktop`, + ` - type: file\n path: ${config.id}.metainfo.xml`, + ...(ctx.iconFile !== undefined ? [` - type: file\n path: ${ctx.iconFile}`] : []), + ].join('\n') const manifestPath = path.join(outBase, `${config.id}.yml`) - fs.writeFileSync(manifestPath, manifest) + fs.writeFileSync(manifestPath, manifestContent(ctx, sources, + `# Local-build manifest: sources point at the generated tree next to it.`)) return manifestPath } @@ -282,8 +443,10 @@ Categories=${[...config.categories, ''].join(';')} function writeMetainfo(ctx) { const { config, outBase } = ctx const esc = s => String(s).replace(/&/g, '&').replace(//g, '>') - // Enough for local installs; Flathub review needs real content — the - // generated TODOs mark what to fill in. + const gh = /github\.com[:/]([^/]+)\/([^/.]+)/.exec(config.repository || '') + const homepage = gh !== null ? `https://github.com/${gh[1]}/${gh[2]}` : undefined + // Enough for local installs; Flathub review needs real content — run with + // --lint for the full checklist, the TODOs mark what to fill in. fs.writeFileSync(path.join(outBase, `${config.id}.metainfo.xml`), ` ${esc(config.id)} @@ -296,7 +459,7 @@ function writeMetainfo(ctx) { ${esc(config.id)}.desktop - +${homepage !== undefined ? ` ${esc(homepage)}\n` : ''} `) } @@ -336,6 +499,10 @@ function parseArgs(argv) { case '-h': case '--help': flags.help = true; break case '--no-build': flags.noBuild = true; break case '--install': flags.install = true; break + case '--run': flags.run = true; break + case '--release': flags.release = true; break + case '--release-url': flags.release = true; flags.releaseUrl = argv[++i]; break + case '--lint': flags.lint = true; break case '--out': flags.out = argv[++i]; break default: if (arg.startsWith('-')) diff --git a/tools/templates/app/README.md.tmpl b/tools/templates/app/README.md.tmpl index d07c4297..34bc9a8a 100644 --- a/tools/templates/app/README.md.tmpl +++ b/tools/templates/app/README.md.tmpl @@ -76,6 +76,19 @@ re-run it: npm run generate-types ``` +## Shipping + +The project is born Flathub-ready — the `bundle` block in package.json matches +the app id in `src/main.ts`, and `data/icon.svg` is a placeholder to replace: + +```sh +npm run flatpak # build + install a Flatpak (the format users install) +npm run bundle # a self-contained portable directory + tar.gz +``` + +See [bundling.md](https://github.com/romgrk/node-gtk/blob/master/doc/bundling.md) +for the Flathub submission path (`node-gtk flatpak --release --lint`). + ## Project structure ``` @@ -83,6 +96,7 @@ npm run generate-types ├── src/ │ ├── main.ts # the application entry point │ └── welcome.ts # a component with its own inline, hot-reloadable styles +├── data/icon.svg # placeholder app icon (used by flatpak/bundle) ├── style.css # custom CSS (hot-reloads live under `npm run dev`) ├── tsconfig.json └── package.json diff --git a/tools/templates/app/data/icon.svg.tmpl b/tools/templates/app/data/icon.svg.tmpl new file mode 100644 index 00000000..157c7de6 --- /dev/null +++ b/tools/templates/app/data/icon.svg.tmpl @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/tools/templates/app/package.json.tmpl b/tools/templates/app/package.json.tmpl index 94332a39..e8b24ccb 100644 --- a/tools/templates/app/package.json.tmpl +++ b/tools/templates/app/package.json.tmpl @@ -4,6 +4,13 @@ "description": "An Adwaita application built with node-gtk.", "type": "module", "private": true, + "main": "src/main.ts", + "bundle": { + "id": "__APP_ID__", + "summary": "An Adwaita application built with node-gtk", + "icon": "data/icon.svg", + "gtk": 4 + }, "scripts": { "dev": "npm run dev:css-reload", "dev:css-reload": "cross-env NODE_ENV=development node --import tsx --import node-gtk/register src/main.ts", @@ -12,6 +19,8 @@ "build": "tsc", "typecheck": "tsc --noEmit", "generate-types": "node-gtk generate-types Gtk-4.0 Adw-1", + "flatpak": "node-gtk flatpak --install", + "bundle": "node-gtk bundle --archive", "postinstall": "npm run generate-types" }, "dependencies": {