#!/usr/bin/env node
import { chmodSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { basename, join, resolve } from 'node:path'
import { tmpdir } from 'node:os'
const EXPECTED = {
name: 'PearBrowser',
binary: 'pearbrowser',
iconName: 'icon',
category: 'Network',
desktopId: 'PearBrowser.desktop',
appstreamId: 'io.github.bigdestiny2.pearbrowser',
metainfoName: 'io.github.bigdestiny2.pearbrowser.metainfo.xml',
projectLicense: 'Apache-2.0 AND MIT'
}
const ROOT = new URL('..', import.meta.url)
const args = parseArgs(process.argv.slice(2))
const errors = []
const warnings = []
const inspections = []
validateSourceMetadata()
if (args.appdir) {
validateAppDir(resolve(args.appdir), 'appdir')
}
if (args.buildDir) {
validateBuildDir(resolve(args.buildDir))
}
if (args.appimage) {
validateAppImage(resolve(args.appimage))
}
const report = {
ok: errors.length === 0,
source: {
icon: 'appling/assets/linux/icon.png',
metainfo: `appling/assets/linux/${EXPECTED.metainfoName}`
},
inspections,
warnings,
errors
}
if (args.json) printJson(report)
else printHuman(report)
process.exit(report.ok ? 0 : 1)
function validateSourceMetadata () {
const iconPath = pathFromRoot('appling/assets/linux/icon.png')
const metainfoPath = pathFromRoot(`appling/assets/linux/${EXPECTED.metainfoName}`)
validateFile(iconPath, 'Linux source icon')
if (validateFile(metainfoPath, 'Linux AppStream metainfo')) {
validateMetainfo(readFileSync(metainfoPath, 'utf8'), `appling/assets/linux/${EXPECTED.metainfoName}`)
}
inspections.push({
kind: 'source',
icon: relativePath(iconPath),
metainfo: relativePath(metainfoPath)
})
}
function validateBuildDir (buildDir) {
if (!existsSync(buildDir)) {
errors.push(`Linux build directory is missing: ${buildDir}`)
return
}
const appDirs = findPaths(buildDir, (path) => path.endsWith('.AppDir') && isDirectory(path))
for (const appDir of appDirs) validateAppDir(appDir, 'build-dir')
const appImages = findPaths(buildDir, (path) => isProductAppImage(path) && isFile(path))
if (appDirs.length === 0 && appImages.length === 0) {
errors.push(`Linux build directory has no .AppDir or .AppImage to inspect: ${buildDir}`)
return
}
if (appImages.length > 1) {
errors.push(`Linux build directory must contain exactly one PearBrowser AppImage, found ${appImages.length}: ${appImages.join(', ')}`)
}
for (const appImage of appImages) validateAppImage(appImage)
}
function isProductAppImage (path) {
return /^PearBrowser(?:[-_.].*)?\.AppImage$/i.test(basename(path))
}
function validateAppImage (appImage) {
if (!validateFile(appImage, 'Linux AppImage')) return
if (process.platform !== 'linux') {
errors.push(`AppImage extraction requires Linux; inspect an AppDir instead on ${process.platform}: ${appImage}`)
return
}
const workDir = mkdtempSync(join(tmpdir(), 'pearbrowser-appimage-'))
try {
chmodSync(appImage, 0o755)
const result = spawnSync(appImage, ['--appimage-extract'], {
cwd: workDir,
encoding: 'utf8',
env: {
...process.env,
APPIMAGE_EXTRACT_AND_RUN: '1'
}
})
if (result.status !== 0) {
const detail = String(result.stderr || result.stdout || '').trim()
errors.push(`failed to extract AppImage ${appImage}${detail ? `: ${detail}` : ''}`)
return
}
validateAppDir(join(workDir, 'squashfs-root'), 'appimage')
} finally {
rmSync(workDir, { recursive: true, force: true })
}
}
function validateAppDir (appDir, kind) {
if (!existsSync(appDir) || !isDirectory(appDir)) {
errors.push(`AppDir is missing or not a directory: ${appDir}`)
return
}
const desktopPath = join(appDir, EXPECTED.desktopId)
const iconPath = join(appDir, `${EXPECTED.iconName}.png`)
const appRunPath = join(appDir, 'AppRun')
const metainfoPath = join(appDir, 'usr', 'share', 'metainfo', EXPECTED.metainfoName)
validateFile(appRunPath, `AppDir AppRun (${appDir})`)
if (validateFile(desktopPath, `AppDir desktop entry (${appDir})`)) {
validateDesktopEntry(readFileSync(desktopPath, 'utf8'), desktopPath)
}
validateFile(iconPath, `AppDir icon (${appDir})`)
if (validateFile(metainfoPath, `AppDir AppStream metainfo (${appDir})`)) {
validateMetainfo(readFileSync(metainfoPath, 'utf8'), metainfoPath)
}
inspections.push({
kind,
appDir,
desktop: desktopPath,
icon: iconPath,
metainfo: metainfoPath
})
}
function validateDesktopEntry (text, label) {
const entry = parseDesktopEntry(text)
if (!entry.hasDesktopEntry) errors.push(`${label} is missing [Desktop Entry]`)
requireDesktopValue(entry.values, 'Type', 'Application', label)
requireDesktopValue(entry.values, 'Name', EXPECTED.name, label)
requireDesktopValue(entry.values, 'Exec', EXPECTED.binary, label)
requireDesktopValue(entry.values, 'Icon', EXPECTED.iconName, label)
const categories = splitCategories(entry.values.Categories)
if (!categories.includes(EXPECTED.category)) {
errors.push(`${label} Categories must include ${EXPECTED.category}`)
}
if (!entry.values.Comment) errors.push(`${label} must include Comment`)
}
function parseDesktopEntry (text) {
const values = {}
let inDesktopEntry = false
let hasDesktopEntry = false
for (const rawLine of String(text || '').split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const section = line.match(/^\[(.+)]$/)
if (section) {
inDesktopEntry = section[1] === 'Desktop Entry'
if (inDesktopEntry) hasDesktopEntry = true
continue
}
if (!inDesktopEntry) continue
const match = line.match(/^([A-Za-z0-9-]+)=(.*)$/)
if (match) values[match[1]] = match[2].trim()
}
return { hasDesktopEntry, values }
}
function requireDesktopValue (values, key, expected, label) {
if (!values[key]) errors.push(`${label} is missing ${key}`)
else if (values[key] !== expected) errors.push(`${label} ${key} must be ${expected}, got ${values[key]}`)
}
function validateMetainfo (xml, label) {
const text = String(xml || '')
if (!/<component\b[^>]*type="desktop-application"/.test(text)) {
errors.push(`${label} component must be type="desktop-application"`)
}
requireXmlText(text, 'id', EXPECTED.appstreamId, label)
requireXmlText(text, 'metadata_license', 'CC0-1.0', label)
requireXmlText(text, 'project_license', EXPECTED.projectLicense, label)
requireXmlText(text, 'name', EXPECTED.name, label)
requireXmlText(text, 'summary', 'Peer-to-peer browser and app store', label)
requireXmlText(text, 'binary', EXPECTED.binary, label)
const launchable = text.match(/<launchable\b[^>]*type="desktop-id"[^>]*>([^<]+)<\/launchable>/)
if (!launchable) errors.push(`${label} must include launchable type="desktop-id"`)
else if (launchable[1].trim() !== EXPECTED.desktopId) {
errors.push(`${label} launchable desktop-id must be ${EXPECTED.desktopId}, got ${launchable[1].trim()}`)
}
if (!new RegExp(`<category>\\s*${escapeRegex(EXPECTED.category)}\\s*<\\/category>`).test(text)) {
errors.push(`${label} categories must include ${EXPECTED.category}`)
}
if (!/<url\b[^>]*type="homepage"[^>]*>https:\/\/github\.com\/bigdestiny2\/pearbrowser-desktop<\/url>/.test(text)) {
errors.push(`${label} must include the PearBrowser GitHub homepage URL`)
}
}
function requireXmlText (text, tag, expected, label) {
const match = text.match(new RegExp(`<${tag}>\\s*([^<]+?)\\s*<\\/${tag}>`))
if (!match) errors.push(`${label} is missing <${tag}>`)
else if (match[1].trim() !== expected) errors.push(`${label} <${tag}> must be ${expected}, got ${match[1].trim()}`)
}
function validateFile (path, label) {
if (!existsSync(path)) {
errors.push(`${label} is missing: ${path}`)
return false
}
if (!isFile(path)) {
errors.push(`${label} is not a file: ${path}`)
return false
}
if (statSync(path).size <= 0) {
errors.push(`${label} is empty: ${path}`)
return false
}
return true
}
function findPaths (root, predicate) {
const matches = []
const stack = [root]
while (stack.length) {
const current = stack.pop()
let entries = []
try {
entries = readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const path = join(current, entry.name)
if (entry.isDirectory()) {
if (shouldSkipSearchDir(entry.name)) continue
if (predicate(path)) matches.push(path)
stack.push(path)
} else if (predicate(path)) {
matches.push(path)
}
}
}
return matches.sort((a, b) => a.length - b.length || basename(a).localeCompare(basename(b)))
}
function shouldSkipSearchDir (name) {
return name === '_deps' || name === 'node_modules' || name === '.git'
}
function splitCategories (value) {
return String(value || '').split(';').map((part) => part.trim()).filter(Boolean)
}
function isFile (path) {
try {
return statSync(path).isFile()
} catch {
return false
}
}
function isDirectory (path) {
try {
return statSync(path).isDirectory()
} catch {
return false
}
}
function pathFromRoot (path) {
return new URL(`../${path}`, import.meta.url)
}
function relativePath (urlOrPath) {
const path = String(urlOrPath?.pathname || urlOrPath)
const root = String(ROOT.pathname)
return path.startsWith(root) ? path.slice(root.length + (root.endsWith('/') ? 0 : 1)) : path
}
function parseArgs (argv) {
const parsed = {
appdir: '',
appimage: '',
buildDir: '',
json: false
}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === '--appdir') parsed.appdir = requireValue(argv, ++i, arg)
else if (arg === '--appimage') parsed.appimage = requireValue(argv, ++i, arg)
else if (arg === '--build-dir') parsed.buildDir = requireValue(argv, ++i, arg)
else if (arg === '--json') parsed.json = true
else if (arg === '-h' || arg === '--help') usage(0)
else usage(2, `unknown argument: ${arg}`)
}
return parsed
}
function requireValue (argv, index, flag) {
const value = argv[index] || ''
if (!value || value.startsWith('--')) usage(2, `${flag} requires a value`)
return value
}
function usage (code, message = '') {
if (message) console.error(`error: ${message}`)
console.error('usage: node scripts/check-linux-appimage-metadata.mjs [--build-dir appling/build] [--appdir path] [--appimage path] [--json]')
process.exit(code)
}
function escapeRegex (value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function printJson (report) {
console.log(JSON.stringify(report, null, 2))
}
function printHuman (report) {
console.log(`PearBrowser Linux AppImage metadata (${report.ok ? 'PASS' : 'BLOCKED'})`)
for (const inspection of report.inspections) {
if (inspection.kind === 'source') {
console.log(`- source: ${inspection.icon}, ${inspection.metainfo}`)
} else {
console.log(`- ${inspection.kind}: ${inspection.appDir}`)
}
}
for (const warning of report.warnings) console.warn(`warning: ${warning}`)
for (const error of report.errors) console.error(`error: ${error}`)
}