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..8ca543bc 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 @@ -72,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) @@ -125,20 +135,105 @@ 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" + ] + } +} +``` -- **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. +Defaults grant only GUI access (`wayland`, `fallback-x11`, `ipc`, `dri`) — +network and filesystem are deliberately opt-in; request the minimum, Flathub +reviews it. + +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 + +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 `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..0702d371 --- /dev/null +++ b/scripts/flatpak-smoke-test.js @@ -0,0 +1,85 @@ +/* + * 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')], + // 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 + ['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..054e3435 100644 --- a/tools/bundle.js +++ b/tools/bundle.js @@ -152,13 +152,23 @@ 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 || ['**/*'], + // 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 || [], @@ -166,6 +176,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 +188,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 +254,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/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 new file mode 100644 index 00000000..f29166cc --- /dev/null +++ b/tools/flatpak.js @@ -0,0 +1,351 @@ +/* + * 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 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\`. +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 }