From 38d2dffd6be537f7b9e5b08c9b0b4dc8ccc9f4c9 Mon Sep 17 00:00:00 2001 From: Rom Grk Date: Sun, 5 Jul 2026 14:21:42 -0400 Subject: [PATCH 1/2] feat: flatpak desktop-integration discovery + Flathub-clean release layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driven by the first real Flathub submission (mariner). Everything a submission tripped over is now automated or surfaced early: - App-provided desktop-integration files are discovered and used instead of generated stubs: data/.desktop (Exec rewritten for the sandbox), data/.metainfo.xml (verbatim, id checked), data/icons/hicolor/ (whole theme tree, sized+scalable+symbolic). Overridable via bundle.desktopFile/metainfo/iconsDir; stubs remain the fallback. - Generated metainfo stub enriched (content_rating, releases, developer from package.json author) so even stub-only apps pass appstreamcli. - --release writes flathub/.yml — the linter requires the manifest be named exactly .yml, and the flathub/ dir is what the submission PR contains. Tarball members now include the icon theme. - bundle.flatpak.lintExceptions ({check: justification}): passed to flatpak-builder-lint as --exceptions --user-exceptions, so permissions an app stands by (a file manager's --filesystem=host) lint green locally while documenting exactly what the submission PR must request. - Early warnings for Flathub conventions: com.github.*/com.gitlab.* ids (must be io.github.*/io.gitlab.*), placeholder com.example ids on --release, broad filesystem access without a declared exception. - create-app scaffolds the icon at data/icons/hicolor/scalable/apps/ .svg (the discovered convention) instead of bundle.icon. - Default runtime 49 -> 50 (build-validated with the node26 extension); CI cache key follows. Validated end-to-end on mariner: build on org.gnome.Platform//50, install, headless run (window up, clean SIGTERM), manifest+appstream lint green, repo lint green modulo the two screenshot-mirroring errors expected outside Flathub's pipeline (documented). Co-Authored-By: Claude Fable 5 --- .github/workflows/flatpak-build.yaml | 2 +- doc/bundling.md | 74 +++++++-- scripts/flatpak-smoke-test.js | 27 +++- tools/bundle.js | 13 +- tools/create-app.js | 9 +- tools/flatpak.js | 207 ++++++++++++++++++++++---- tools/templates/app/README.md.tmpl | 5 +- tools/templates/app/package.json.tmpl | 1 - 8 files changed, 284 insertions(+), 54 deletions(-) diff --git a/.github/workflows/flatpak-build.yaml b/.github/workflows/flatpak-build.yaml index 5e8b5a3e..7009cb3a 100644 --- a/.github/workflows/flatpak-build.yaml +++ b/.github/workflows/flatpak-build.yaml @@ -30,7 +30,7 @@ jobs: - uses: actions/cache@v4 with: path: ~/.local/share/flatpak - key: flatpak-org.gnome.Sdk-49-node24-v1 + key: flatpak-org.gnome.Sdk-50-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 diff --git a/doc/bundling.md b/doc/bundling.md index d10ce429..3ba339f7 100644 --- a/doc/bundling.md +++ b/doc/bundling.md @@ -177,19 +177,25 @@ The shared `"bundle"` key, plus a `"flatpak"` sub-key: ```jsonc "bundle": { "name": "MyApp", - "id": "com.example.MyApp", // MUST be your GApplication application-id + "id": "io.github.you.myapp", // MUST be your GApplication application-id; + // Flathub requires io.github.*/io.gitlab.* + // for code-hosting-based ids "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 (50 is also current; - // pick one and test against it) + "runtimeVersion": "50", // org.gnome.Platform version — pin the one + // you tested against "node": 26, // SDK extension major (20/22/24/26) "finishArgs": [ // sandbox permissions beyond the GUI defaults "--share=network", "--filesystem=home" - ] + ], + "lintExceptions": { // linter errors your app stands by; each one + // must be requested (with this justification) + // in the Flathub submission PR + "finish-args-host-filesystem-access": "MyApp is a file manager; …" + } } } ``` @@ -203,6 +209,24 @@ Defaults grant only GUI access (`wayland`, `fallback-x11`, `ipc`, `dri`) — network and filesystem are deliberately opt-in; request the minimum, Flathub reviews it. +## Desktop integration files + +Ship your own files at the conventional locations and they are used as-is; +anything missing gets a minimal generated stub: + +- `data/.desktop` — copied with only `Exec=` rewritten to the sandbox + command, so one file serves host installs and the flatpak. Keep + `Icon=`: flatpak only exports icons named after the app id. +- `data/.metainfo.xml` — copied verbatim. The generated stub passes + validation, but Flathub review wants a real description, screenshots and + release notes only you can write — start from the stub in `dist/flatpak/`. +- `data/icons/hicolor//apps/.(svg|png)` — the whole theme tree + ships (sized, scalable and symbolic variants). Single-file alternative: + `"bundle": { "icon": "assets/icon.svg" }`. + +Override the discovery with `"bundle": { "desktopFile": …, "metainfo": …, +"iconsDir": … }` when your layout differs. + Three things that bite: - **The flatpak id must equal your `Gtk.Application` `applicationId`** — @@ -231,19 +255,37 @@ 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 +- `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. + override with `--release-url`). The `flathub/` directory is exactly what + the submission PR contains, and the manifest is named `.yml` because + the linter requires it. + +The flow: + +1. Fix everything `--lint` reports. Common gates: the app id — Flathub + rejects `com.github.*`; a GitHub-hosted app must use `io.github..` + (and `applicationId` must match) — and broad permissions like + `--filesystem=host`, which are declared in `"lintExceptions"` with a + justification and granted per-app during review. +2. Create the GitHub release `v` and upload the tarball: + `gh release create v dist/flatpak/`. +3. Fork [flathub/flathub](https://github.com/flathub/flathub), branch off + `new-pr` (not master), add the contents of `dist/flatpak/flathub/`, and + open a PR against `new-pr`. Request any `lintExceptions` in the PR + description with their justifications. + +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. + +One caveat if you run the deepest check locally +(`flatpak-builder-lint repo dist/flatpak/repo`): it reports +`appstream-screenshots-not-mirrored-in-ostree` / +`appstream-external-screenshot-url`. Both are expected outside Flathub's +infrastructure — their pipeline mirrors screenshots into +`dl.flathub.org/media` automatically. Alternative without Flathub: host your own flatpak repository (an ostree repo is static files — GitHub Pages works) and point users at a `.flatpakref`; diff --git a/scripts/flatpak-smoke-test.js b/scripts/flatpak-smoke-test.js index 0494fdd5..8e3bd80b 100644 --- a/scripts/flatpak-smoke-test.js +++ b/scripts/flatpak-smoke-test.js @@ -58,6 +58,24 @@ process.exit(0) fs.mkdirSync(path.join(appDir, 'node_modules')) fs.symlinkSync(repoRoot, path.join(appDir, 'node_modules', 'node-gtk'), 'dir') +// App-provided desktop-integration files at the conventional data/ locations: +// the metainfo and icon theme must be discovered and used instead of stubs +// (the missing .desktop still exercises the generated-stub path). +fs.mkdirSync(path.join(appDir, 'data', 'icons', 'hicolor', 'scalable', 'apps'), { recursive: true }) +fs.writeFileSync(path.join(appDir, 'data', `${id}.metainfo.xml`), ` + + ${id} + CC0-1.0 + MIT + FlatpakSmoke + Flatpak generation smoke test +

SMOKE_MARKER_APP_PROVIDED_METAINFO

+ ${id}.desktop +
+`) +fs.writeFileSync(path.join(appDir, 'data', 'icons', 'hicolor', 'scalable', 'apps', `${id}.svg`), + '\n') + const outDir = path.join(tmp, 'out') const args = [path.join(repoRoot, 'bin', 'node-gtk.js'), 'flatpak', appDir, '--out', outDir, '--release'] if (!full) @@ -80,9 +98,12 @@ const checks = [ // 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}`)], - // --release: Flathub manifest referencing the tarball by url + sha256 - [`${id}.flathub.yml`, content => /type: archive/.test(content) && /sha256: [0-9a-f]{64}/.test(content)], + // app-provided data/ files must be used instead of generated stubs + [`${id}.metainfo.xml`, content => content.includes('SMOKE_MARKER_APP_PROVIDED_METAINFO')], + [`icons/hicolor/scalable/apps/${id}.svg`, undefined], + // --release: Flathub manifest (named .yml for the linter) referencing + // the tarball by url + sha256 + [`flathub/${id}.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', undefined], diff --git a/tools/bundle.js b/tools/bundle.js index 74578fd9..16290c4f 100644 --- a/tools/bundle.js +++ b/tools/bundle.js @@ -165,7 +165,13 @@ function loadConfig(appDir, flags) { repository: typeof pkg.repository === 'string' ? pkg.repository : (pkg.repository || {}).url, summary: raw.summary || `The ${name} application`, license: raw.license || pkg.license, + author: pkg.author, icon: raw.icon, + // App-provided desktop-integration files (flatpak): explicit paths here, + // else discovered at the conventional data/ locations. + desktopFile: raw.desktopFile, + metainfo: raw.metainfo, + iconsDir: raw.iconsDir, categories: raw.categories || ['GTK'], gtk: raw.gtk, entry: path.normalize(entry), @@ -183,9 +189,14 @@ function loadConfig(appDir, flags) { out: path.resolve(appDir, flags.out || raw.out || path.join('dist', `${name}-${process.platform}-${process.arch}`)), flatpak: { - runtimeVersion: String(flatpakRaw.runtimeVersion || '49'), + runtimeVersion: String(flatpakRaw.runtimeVersion || '50'), node: Number(flatpakRaw.node || defaultFlatpakNode()), finishArgs: flatpakRaw.finishArgs || [], + // { "": "" } — permissions the app stands + // by (a file manager needs --filesystem=host). Tolerated by --lint via + // --user-exceptions; each needs the justification restated in the + // Flathub submission PR. + lintExceptions: flatpakRaw.lintExceptions || {}, }, } diff --git a/tools/create-app.js b/tools/create-app.js index ff651037..963c099d 100644 --- a/tools/create-app.js +++ b/tools/create-app.js @@ -17,6 +17,9 @@ const TEMPLATE_DIR = path.join(__dirname, 'templates', 'app') // template file -> destination path (relative to the new project root). // Templates carry a `.tmpl` suffix so npm never rewrites `.gitignore` to // `.npmignore` and never treats a nested `package.json` as a real manifest. +// Destinations get token substitution too: the icon lands at the hicolor +// location `node-gtk flatpak` discovers (and freedesktop tooling expects), +// named after the app id. const FILES = [ ['package.json.tmpl', 'package.json'], ['tsconfig.json.tmpl', 'tsconfig.json'], @@ -25,7 +28,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')], + ['data/icon.svg.tmpl', path.join('data', 'icons', 'hicolor', 'scalable', 'apps', '__APP_ID__.svg')], ] // --------------------------------------------------------------------------- @@ -105,10 +108,10 @@ function createProject(opts) { const written = [] for (const [src, dest] of FILES) { const content = substitute(fs.readFileSync(path.join(TEMPLATE_DIR, src), 'utf8')) - const destPath = path.join(dir, dest) + const destPath = path.join(dir, substitute(dest)) fs.mkdirSync(path.dirname(destPath), { recursive: true }) fs.writeFileSync(destPath, content) - written.push(dest) + written.push(path.relative(dir, destPath)) } return written } diff --git a/tools/flatpak.js b/tools/flatpak.js index 41dea261..5ac6a3a6 100644 --- a/tools/flatpak.js +++ b/tools/flatpak.js @@ -39,7 +39,7 @@ const path = require('path') const crypto = require('crypto') const child_process = require('child_process') -const { exists, mkdirp, copyFile, formatSize, exec, tryExec } = require('./bundle/util.js') +const { exists, mkdirp, copyFile, copyTree, formatSize, exec, tryExec } = require('./bundle/util.js') const { loadConfig, prepareOutput } = require('./bundle.js') const appTree = require('./bundle/app-tree.js') @@ -65,8 +65,14 @@ Options: -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": [...] } +plus: summary, icon, license, categories, desktopFile, metainfo, iconsDir, and + "flatpak": { "runtimeVersion": "50", "node": 26, "finishArgs": [...], + "lintExceptions": { "": "" } } + +Desktop integration: an app that ships its own files at the conventional +locations — data/.desktop, data/.metainfo.xml, data/icons/hicolor/ — +gets them used as-is (Exec rewritten for the sandbox); otherwise minimal +stubs are generated. Flathub review needs the real thing. See doc/bundling.md for the full reference, sandbox permissions, and the Flathub submission path.` @@ -120,16 +126,18 @@ function flatpak(flags) { } mkdirp(ctx.appOutDir) + checkFlathubConventions(ctx, 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 + stageIcons(ctx) // before the manifest: it references the staged files const manifestPath = writeManifest(ctx) writeLauncher(ctx) - writeDesktopFile(ctx) - writeMetainfo(ctx) + stageDesktopFile(ctx) + stageMetainfo(ctx) log(`manifest: ${path.relative(process.cwd(), manifestPath)}`) @@ -206,6 +214,26 @@ function findBuilder() { return undefined } +// --------------------------------------------------------------------------- +// Flathub conventions check +// --------------------------------------------------------------------------- + +// Flathub gates submissions on conventions a local build never trips over; +// surface them up front — they are expensive to discover at review time. +function checkFlathubConventions(ctx, flags) { + const { config, log } = ctx + if (/^(com|org)\.(github|gitlab)\./.test(config.id)) + log(`WARNING: Flathub rejects '${config.id}' — ids based on code hosting must use ` + + `io.github.* / io.gitlab.* (and the GApplication applicationId must follow)`) + if (flags.release && /^com\.example\./.test(config.id)) + log(`WARNING: '${config.id}' is a placeholder id — Flathub requires an id under a domain you control`) + const broad = config.flatpak.finishArgs.filter(a => /^--filesystem=(host|home)\b/.test(a)) + const acknowledged = Object.keys(config.flatpak.lintExceptions).some(k => k.includes('filesystem')) + if (broad.length > 0 && !acknowledged) + log(`note: ${broad.join(', ')} is a Flathub linter error — prefer portals, or declare the ` + + `justification in bundle.flatpak.lintExceptions and request an exception in the submission PR`) +} + // --------------------------------------------------------------------------- // application-id check // --------------------------------------------------------------------------- @@ -248,6 +276,9 @@ function checkApplicationId(ctx) { // 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. +// The manifest lands in flathub/.yml: the linter requires the file to be +// named exactly after the app id, and the flathub/ directory is what the +// submission PR (and later the app's Flathub repo) contains. function writeReleaseArtifacts(ctx, flags) { const { config, outBase, log } = ctx const top = `${config.name}-${config.version}` @@ -255,6 +286,8 @@ function writeReleaseArtifacts(ctx, flags) { const tarPath = path.join(outBase, tarName) const members = ['app', 'launcher.sh', `${config.id}.desktop`, `${config.id}.metainfo.xml`] + if (ctx.iconsStaged) + members.push('icons') if (ctx.iconFile !== undefined) members.push(ctx.iconFile) exec(`tar -czf ${JSON.stringify(tarPath)} --transform ${JSON.stringify(`s,^,${top}/,`)} ` + @@ -266,14 +299,16 @@ function writeReleaseArtifacts(ctx, flags) { const sources = ` - type: archive url: ${url} sha256: ${sha256}` - const flathubManifestPath = path.join(outBase, `${config.id}.flathub.yml`) + const flathubDir = path.join(outBase, 'flathub') + mkdirp(flathubDir) + const flathubManifestPath = path.join(flathubDir, `${config.id}.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`) + log(`flathub/${config.id}.yml (sources: ${url})`) + console.log(` Next: upload ${tarName} to a release at that URL, then submit the`) + console.log(` flathub/ manifest via PR to github.com/flathub/flathub (see doc/bundling.md)`) return flathubManifestPath } @@ -294,7 +329,7 @@ function releaseUrl(ctx, tarName, flags) { // --------------------------------------------------------------------------- function runLint(ctx, targets) { - const { outBase, log } = ctx + const { config, outBase, log } = ctx let command, args if (tryExec('flatpak-builder-lint --version') !== undefined) { command = 'flatpak-builder-lint' @@ -307,6 +342,18 @@ function runLint(ctx, targets) { return true } + // Permissions the app stands by, declared with their justification in + // bundle.flatpak.lintExceptions — same JSON shape as Flathub's exceptions + // store, so the file documents exactly what the submission PR must request. + const exceptions = config.flatpak.lintExceptions + if (Object.keys(exceptions).length > 0) { + const exceptionsFile = path.join(outBase, 'lint-exceptions.json') + fs.writeFileSync(exceptionsFile, JSON.stringify({ [config.id]: exceptions }, null, 2) + '\n') + // --user-exceptions only supplies the store; --exceptions enables it. + args.push('--exceptions', '--user-exceptions', exceptionsFile) + log(`lint: tolerating ${Object.keys(exceptions).join(', ')} — request these as exceptions in the Flathub submission PR`) + } + let ok = true for (const [kind, file] of targets) { const result = child_process.spawnSync(command, [...args, kind, file], { encoding: 'utf8' }) @@ -394,7 +441,7 @@ modules: - 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: +${icon !== undefined ? ` - ${yq(icon)}\n` : ''} sources: ${sourcesYaml} ` } @@ -406,6 +453,7 @@ function writeManifest(ctx) { ' - type: file\n path: launcher.sh', ` - type: file\n path: ${config.id}.desktop`, ` - type: file\n path: ${config.id}.metainfo.xml`, + ...(ctx.iconsStaged ? [' - type: dir\n path: icons\n dest: icons'] : []), ...(ctx.iconFile !== undefined ? [` - type: file\n path: ${ctx.iconFile}`] : []), ].join('\n') @@ -427,9 +475,38 @@ exec /app/bin/node ${nodeArgs}./${entry} "$@" `) } -function writeDesktopFile(ctx) { - const { config, outBase } = ctx - fs.writeFileSync(path.join(outBase, `${config.id}.desktop`), `[Desktop Entry] +// An app-provided desktop-integration file: the explicit config path (an +// error when missing — it was asked for), else the first conventional +// location that exists, else undefined (a stub is generated). +function findAppFile(ctx, configured, candidates) { + if (configured !== undefined) { + const p = path.resolve(ctx.appDir, configured) + if (!exists(p)) + throw new Error(`configured file not found: ${configured}`) + return p + } + for (const candidate of candidates) { + const p = path.join(ctx.appDir, candidate) + if (exists(p)) + return p + } + return undefined +} + +// The .desktop file: the app's own ("bundle.desktopFile", or the +// conventional data/.desktop) ships nearly verbatim — only Exec is +// rewritten, because inside the sandbox the launch command is the flatpak id +// installed at /app/bin/, whatever the file says for host installs. +function stageDesktopFile(ctx) { + const { config, appDir, outBase, log } = ctx + const dest = path.join(outBase, `${config.id}.desktop`) + const src = findAppFile(ctx, config.desktopFile, [ + path.join('data', `${config.id}.desktop`), + `${config.id}.desktop`, + ]) + + if (src === undefined) { + fs.writeFileSync(dest, `[Desktop Entry] Type=Application Name=${config.name} Comment=${config.summary} @@ -438,16 +515,56 @@ Icon=${config.id} Terminal=false Categories=${[...config.categories, ''].join(';')} `) + return + } + + let content = fs.readFileSync(src, 'utf8') + content = content.replace(/^Exec=(\S+)( .*)?$/m, (_, __, rest) => `Exec=${config.id}${rest || ''}`) + const icon = /^Icon=(.*)$/m.exec(content) + if (icon !== null && icon[1].trim() !== config.id) + log(`WARNING: ${path.relative(appDir, src)} sets Icon=${icon[1].trim()} — flatpak only exports icons named after the app id (${config.id})`) + if (/^DBusActivatable=true/m.test(content)) + log(`WARNING: ${path.relative(appDir, src)} sets DBusActivatable=true but no D-Bus service file is installed — launches from GNOME Shell will fail`) + fs.writeFileSync(dest, content) + log(`desktop: ${path.relative(appDir, src)} (Exec rewritten for the sandbox)`) } -function writeMetainfo(ctx) { - const { config, outBase } = ctx +// The AppStream metainfo: the app's own ("bundle.metainfo", or the +// conventional data/.metainfo.xml) ships verbatim; the generated stub is +// enough for appstreamcli but Flathub review wants real content — +// description, screenshots — which only the app author can write. +function stageMetainfo(ctx) { + const { config, appDir, outBase, log } = ctx + const dest = path.join(outBase, `${config.id}.metainfo.xml`) + const src = findAppFile(ctx, config.metainfo, [ + path.join('data', `${config.id}.metainfo.xml`), + path.join('data', `${config.id}.appdata.xml`), + `${config.id}.metainfo.xml`, + ]) + + if (src === undefined) { + writeGeneratedMetainfo(ctx, dest) + return + } + + const content = fs.readFileSync(src, 'utf8') + const id = /([^<]+)<\/id>/.exec(content) + if (id === null || id[1] !== config.id) + log(`WARNING: ${path.relative(appDir, src)} declares ${id !== null ? id[1] : '(none)'} but the flatpak id is ${config.id} — AppStream requires them to match`) + fs.writeFileSync(dest, content) + log(`metainfo: ${path.relative(appDir, src)}`) +} + +function writeGeneratedMetainfo(ctx, dest) { + const { config } = ctx const esc = s => String(s).replace(/&/g, '&').replace(//g, '>') 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`), ` + const developer = authorName(config.author) + // The generated stub carries everything derivable (it passes appstreamcli + // validate); description and screenshots are the author's — Flathub review + // rejects placeholders, run with --lint for the full checklist. + fs.writeFileSync(dest, ` ${esc(config.id)} CC0-1.0 @@ -456,19 +573,50 @@ function writeMetainfo(ctx) { ${esc(config.summary)}

${esc(config.summary)}

- +
${esc(config.id)}.desktop -${homepage !== undefined ? ` ${esc(homepage)}\n` : ''}
+ +${developer !== undefined ? ` + ${esc(developer)} + +` : ''}${homepage !== undefined ? ` ${esc(homepage)}\n` : ''} + + + `) } -// 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) { +// package.json "author": "Name (url)" or { "name": ... }. +function authorName(author) { + const raw = typeof author === 'object' && author !== null ? author.name : author + if (typeof raw !== 'string') + return undefined + const name = raw.replace(/<[^>]*>|\([^)]*\)/g, '').trim() + return name === '' ? undefined : name +} + +// Icons: an app-provided theme tree ("bundle.iconsDir", or the conventional +// data/icons/hicolor/) ships whole — sized, scalable and symbolic variants; +// else the single "bundle.icon" file; else a warning, Flathub requires one. +function stageIcons(ctx) { const { config, appDir, outBase, log } = ctx + + const themeDir = findAppFile(ctx, config.iconsDir, [path.join('data', 'icons')]) + if (themeDir !== undefined && exists(path.join(themeDir, 'hicolor'))) { + copyTree(themeDir, path.join(outBase, 'icons')) + ctx.iconsStaged = true + const named = fs.globSync( + [`hicolor/*/apps/${config.id}.svg`, `hicolor/*/apps/${config.id}.png`], { cwd: themeDir }) + if (named.length === 0) + log(`WARNING: no ${config.id}.svg/.png under ${path.relative(appDir, themeDir)}/hicolor/*/apps/ — the app will show a generic icon`) + log(`icons: ${path.relative(appDir, themeDir)} (${named.length > 0 ? named.join(', ') : 'theme tree'})`) + return + } + if (config.icon === undefined) { - log('no "bundle.icon" configured — the app will show a generic icon (Flathub requires one)') + log('no icon found (data/icons/hicolor/ or "bundle.icon") — the app will show a generic icon (Flathub requires one)') return } const src = path.resolve(appDir, config.icon) @@ -481,10 +629,15 @@ function stageIcon(ctx) { copyFile(src, path.join(outBase, ctx.iconFile)) } +// The in-sandbox install command for whatever stageIcons staged. A theme +// tree merges into /app/share/icons; a single SVG installs as scalable, a +// PNG as 256x256. function iconInstallCommand(ctx) { + const { config } = ctx + if (ctx.iconsStaged) + return `mkdir -p /app/share/icons && cp -a icons/. /app/share/icons/` 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)}` } diff --git a/tools/templates/app/README.md.tmpl b/tools/templates/app/README.md.tmpl index 34bc9a8a..b1ef984b 100644 --- a/tools/templates/app/README.md.tmpl +++ b/tools/templates/app/README.md.tmpl @@ -79,7 +79,8 @@ 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: +the app id in `src/main.ts`, and the icon under `data/icons/hicolor/` is a +placeholder to replace (anything you add there — sizes, symbolic — ships too): ```sh npm run flatpak # build + install a Flatpak (the format users install) @@ -96,7 +97,7 @@ for the Flathub submission path (`node-gtk flatpak --release --lint`). ├── 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) +├── data/icons/ # app icon(s), named after the app id (used by flatpak) ├── style.css # custom CSS (hot-reloads live under `npm run dev`) ├── tsconfig.json └── package.json diff --git a/tools/templates/app/package.json.tmpl b/tools/templates/app/package.json.tmpl index e8b24ccb..0a1b3624 100644 --- a/tools/templates/app/package.json.tmpl +++ b/tools/templates/app/package.json.tmpl @@ -8,7 +8,6 @@ "bundle": { "id": "__APP_ID__", "summary": "An Adwaita application built with node-gtk", - "icon": "data/icon.svg", "gtk": 4 }, "scripts": { From 3162eca3a3c589b6d6deedd3223174f8aed1780d Mon Sep 17 00:00:00 2001 From: Rom Grk Date: Sun, 5 Jul 2026 16:34:49 -0400 Subject: [PATCH 2/2] fix: default bundle categories need a valid main category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appstreamcli compose (run by flatpak-builder at finish) rejects a desktop file whose Categories contain no main category — GTK alone is additional-only, so the first real flatpak-build.yaml run failed with no-valid-category. Default to Utility;GTK. Co-Authored-By: Claude Fable 5 --- tools/bundle.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/bundle.js b/tools/bundle.js index 16290c4f..67a9ffa0 100644 --- a/tools/bundle.js +++ b/tools/bundle.js @@ -172,7 +172,10 @@ function loadConfig(appDir, flags) { desktopFile: raw.desktopFile, metainfo: raw.metainfo, iconsDir: raw.iconsDir, - categories: raw.categories || ['GTK'], + // The default needs a valid MAIN category (Utility): appstreamcli + // compose, which flatpak-builder runs, rejects the app with + // no-valid-category otherwise — GTK alone is additional-only. + categories: raw.categories || ['Utility', 'GTK'], gtk: raw.gtk, entry: path.normalize(entry), include: raw.include || ['**/*'],