// godot-keet Launcher
//
// Starts keet-daemon in-process, launches Godot game binary.
// pgh provides P2P OTA updates (content-addressed) when installed via pgh.
//
// Usage: bare index.js [--debug] [--no-updates] [--storage <path>]
import subprocess from 'bare-subprocess'
import fs from 'fs'
import path from 'path'
import os from 'os'
import process from 'process'
import Pipe from 'bare-pipe'
// keet-daemon is loaded LAZILY (see loadKeetDaemon) — never as a top-level
// import. A static `import './daemon/daemon.js'` is evaluated before main()
// runs flattenIfNeeded(), so on a fresh pgh install (content-addressed
// symlinked node_modules) keet-core's Bundlebee resolver can't find its
// modules — it expects a flat npm layout, and Bare caches by realpath. The
// daemon must be imported only AFTER node_modules is flattened.
let startDaemon = null
let _keetDaemonLoaded = false
async function loadKeetDaemon () {
if (_keetDaemonLoaded) return startDaemon
_keetDaemonLoaded = true
try {
const mod = await import('./daemon/daemon.js')
startDaemon = mod.startDaemon
} catch (err) {
console.error('[launcher] keet-daemon module not loadable:', err.message)
if (err.code) console.error('[launcher] code:', err.code)
}
return startDaemon
}
const isPear = typeof Pear !== 'undefined'
const DAEMON_SOCKET = '/tmp/keet-daemon.sock'
const rawArgs = isPear ? (Pear.config.args || []) : Bare.argv.slice(2)
const debugMode = rawArgs.includes('--debug')
const noUpdates = rawArgs.includes('--no-updates')
// --storage <path> overrides the app storage dir (game/identity state)
const storageIdx = rawArgs.indexOf('--storage')
const customStorage = storageIdx !== -1 ? rawArgs[storageIdx + 1] : null
function getAppDir () {
return path.dirname(new URL(import.meta.url).pathname)
}
function getStorageDir () {
if (customStorage) return customStorage
const p = os.platform()
const name = 'godot-keet'
if (p === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', name)
if (p === 'win32') return path.join(os.homedir(), 'AppData', 'Roaming', name)
return path.join(os.homedir(), '.config', name)
}
function getPlatformDir () {
const p = os.platform()
if (p === 'linux') return 'linux'
if (p === 'darwin') return 'macos'
if (p === 'win32') return 'windows'
return p
}
// Match pgh's detectHost convention (lib/commands/app.js#detectHost).
// Used to find arch-specific binaries laid out by ci.yaml under
// by-arch/<host>/ for multi-arch publishes. Falls back to flat layout
// when by-arch/<host>/ is absent so single-arch installs still work.
function getHost () {
let host = os.platform() + '-' + os.arch()
try {
const osr = fs.readFileSync('/etc/os-release', 'utf8')
if (/^ID=alpine/m.test(osr)) host += '-musl'
} catch {}
return host
}
function resolveBuildDir (appDir, platformDir) {
const arched = path.join(appDir, 'by-arch', getHost(), 'build', platformDir)
if (fs.existsSync(arched)) return arched
return path.join(appDir, 'build', platformDir)
}
function exit (code) {
if (isPear) Pear.exit(code)
else Bare.exit(code)
}
// Probe the socket to check if a daemon is actually listening
function probeSocket (socketPath) {
return new Promise((resolve) => {
try {
fs.statSync(socketPath)
} catch {
resolve(false) // Socket file doesn't exist
return
}
// File exists — try connecting to verify daemon is alive
const pipe = new Pipe(socketPath)
const timer = setTimeout(() => {
pipe.destroy()
resolve(false)
}, 2000)
pipe.on('connect', () => {
clearTimeout(timer)
pipe.destroy()
resolve(true)
})
pipe.on('error', () => {
clearTimeout(timer)
resolve(false)
})
})
}
// Start keet-daemon in-process if not already running
async function ensureDaemon () {
// Check if an external daemon is actually alive (not just a stale socket)
const alive = await probeSocket(DAEMON_SOCKET)
if (alive) {
console.log('[launcher] keet-daemon already running (external)')
return null
}
// Remove stale socket if it exists
try { fs.unlinkSync(DAEMON_SOCKET) } catch {}
// Daemon module is loaded lazily after the flatten; if it failed to load
// there's nothing to start.
if (!startDaemon) {
console.error('[launcher] keet-daemon module not loaded — starting without daemon')
return null
}
// Start daemon in-process
console.log('[launcher] Starting keet-daemon (in-process)...')
try {
const daemon = await startDaemon({
socketPath: DAEMON_SOCKET,
debug: debugMode
})
console.log('[launcher] keet-daemon ready')
return daemon
} catch (err) {
console.error('[launcher] keet-daemon failed to start:', err.message)
console.error('[launcher] Game will start without daemon')
return null
}
}
// Background OTA via pgh — content-addressed store + hyper-overlay, NO
// Hyperdrive/blobs. `pgh app update <name>` resolves the latest release from
// package.json's pgh.upgrade, fetches any new tree closure into the content
// store, and ATOMICALLY materializes it into the install dir (scratch build +
// swap). The swap preserves the running process's inodes, so it is safe to run
// while the game is live; the next launch picks up the new tree.
//
// This replaces the former pear-runtime Hyperdrive updater, which kept an
// unbounded append-only blob corestore under ~/.config/godot-keet/pear-runtime
// (observed at ~2.8 GB on a long-lived install — every release's blocks
// accumulated and were never reclaimed).
function resolvePghBin () {
// Prefer the user launcher (~/.local/bin/pgh). Fall back to the
// pgh-installed bare runtime + entry inside the content store. Returns an
// argv prefix array, or null if pgh is not present on this machine.
const launcher = path.join(os.homedir(), '.local', 'bin', 'pgh')
if (fs.existsSync(launcher)) return [launcher]
const storeRoot = process.env.PGH_STORE_ROOT || path.join(os.homedir(), '.local', 'share', 'store')
const bare = path.join(storeRoot, 'apps', 'pgh', 'runtime', 'bare')
const entry = path.join(storeRoot, 'apps', 'pgh', 'bin', 'pgh.bare.js')
if (fs.existsSync(bare) && fs.existsSync(entry)) return [bare, entry]
return null
}
function startBackgroundUpdate () {
if (noUpdates) {
console.log('[launcher] OTA updates disabled (--no-updates)')
return
}
try {
const pkg = JSON.parse(fs.readFileSync(path.join(getAppDir(), 'package.json'), 'utf-8'))
const upgrade = pkg.pgh && pkg.pgh.upgrade
if (!upgrade) {
console.log('[launcher] No pgh.upgrade link in package.json, skipping OTA')
return
}
const appName = (pkg.pgh && pkg.pgh.bin) || pkg.name || 'godot-keet'
const argv = resolvePghBin()
if (!argv) {
console.log('[launcher] pgh CLI not found, skipping OTA')
return
}
console.log(`[launcher] OTA: checking for updates via ${upgrade}`)
// Detached + best-effort: never blocks or delays the game. pgh's atomic
// materialize leaves the live install intact if the process is interrupted.
const proc = subprocess.spawn(argv[0], [...argv.slice(1), 'app', 'update', appName], {
stdio: 'ignore',
detached: true
})
proc.on('error', (err) => {
console.error('[launcher] OTA check failed to spawn:', err.message)
})
if (proc.unref) proc.unref()
} catch (err) {
console.error('[launcher] OTA init failed:', err.message)
console.error('[launcher] Continuing without OTA updates')
}
}
// Replace top-level symlinks in node_modules with real copies. Idempotent —
// no-op if already flat. Required because keet-core 4.x's Bundlebee resolver
// expects a flat npm layout, while pgh's content-addressed materializer
// installs node_modules/<pkg> as symlinks into node_modules/.pgh/<hash>/.
// Without this, keet-daemon throws "failed to find module:
// /node_modules/<pkg>/index.js" on first bundle load (room invite / chat send).
// Mirrors the proven hyper-tps workaround.
async function flattenIfNeeded () {
const nodeModulesDir = path.join(getAppDir(), 'node_modules')
if (!fs.existsSync(nodeModulesDir)) return
// Quick check: any top-level (or scoped) symlink left?
let needsFlatten = false
for (const name of fs.readdirSync(nodeModulesDir)) {
if (name === '.pgh') continue
const p = path.join(nodeModulesDir, name)
try {
const st = fs.lstatSync(p)
if (st.isSymbolicLink()) { needsFlatten = true; break }
if (name.startsWith('@') && st.isDirectory()) {
for (const sub of fs.readdirSync(p)) {
if (fs.lstatSync(path.join(p, sub)).isSymbolicLink()) { needsFlatten = true; break }
}
}
} catch {}
if (needsFlatten) break
}
if (!needsFlatten) {
console.log('[launcher] node_modules already flat — skipping flatten')
return
}
console.log('[launcher] Flattening node_modules (pgh symlinks → real copies)…')
const scriptPath = path.join(getAppDir(), 'flatten-node-modules.cjs')
if (!fs.existsSync(scriptPath)) {
console.error('[launcher] flatten script missing at', scriptPath)
return
}
await new Promise((resolve, reject) => {
const child = subprocess.spawn('node', [scriptPath, nodeModulesDir], { stdio: 'inherit' })
child.on('exit', code => code === 0 ? resolve() : reject(new Error('flatten exit ' + code)))
child.on('error', reject)
}).catch(err => {
console.error('[launcher] flatten failed (continuing anyway):', err.message)
})
}
let daemonRef = null
async function main () {
const platformDir = getPlatformDir()
// Read version from version.txt (written by CI)
let appVersion = 'dev'
try {
appVersion = fs.readFileSync(path.join(getAppDir(), 'version.txt'), 'utf-8').trim()
} catch {}
console.log(`[launcher] godot-keet ${appVersion}`)
console.log(`[launcher] Platform: ${platformDir}`)
// keet-core's Bundlebee needs a flat node_modules; pgh installs it as
// content-addressed symlinks. Flatten first, THEN load the daemon module
// (its keet-core import resolves only against the flat layout).
await flattenIfNeeded()
await loadKeetDaemon()
// Start daemon in-process (or detect existing external daemon)
daemonRef = await ensureDaemon()
// Kick off background P2P OTA via pgh (content-addressed, no blobs).
// Bare mode only; non-blocking.
if (!isPear) {
startBackgroundUpdate()
}
if (isPear) {
// The legacy Pear-bundle path (pear-appdrive) has been removed. The app
// now runs under bare and self-updates via pgh — launch via `bare index.js`.
console.error('[launcher] Pear runtime mode is no longer supported — run via bare')
exit(1)
return
}
// Prefer the multi-arch layout (by-arch/<host>/build/<platform>/) when
// present, falling back to a flat single-arch build/. The platform build
// dir contains a godot-keet/ subdir holding the binary + .pck.
const buildDir = resolveBuildDir(getAppDir(), platformDir)
const gamePath = path.join(buildDir, 'godot-keet', 'godot-keet')
if (!fs.existsSync(gamePath)) {
console.error(`[launcher] Game binary not found: ${gamePath}`)
exit(1)
return
}
console.log(`[launcher] Launching game: ${gamePath}`)
// Ensure DISPLAY is set on Linux
const bareEnv = (await import('bare-env')).default
if (!bareEnv.DISPLAY && os.platform() === 'linux') bareEnv.DISPLAY = ':0'
const gameArgs = ['--verbose', '--', `--version=${appVersion}`]
if (debugMode) gameArgs.push('--debug-collisions')
console.log(`[launcher] Args: ${gameArgs.join(' ')}`)
const game = subprocess.spawn(gamePath, gameArgs, {
stdio: 'inherit'
})
game.on('exit', (code) => {
console.log(`[launcher] Game exited with code ${code}`)
// Clean up socket if we started the daemon
if (daemonRef) {
try { fs.unlinkSync(DAEMON_SOCKET) } catch {}
}
// OTA is applied atomically by the background `pgh app update` (it swaps
// the install tree in place), so the next launch automatically uses the
// new version — nothing to apply here.
exit(code || 0)
})
const cleanup = () => {
try { game.kill('SIGTERM') } catch {}
if (daemonRef) {
try { fs.unlinkSync(DAEMON_SOCKET) } catch {}
}
}
Bare.on('exit', cleanup)
}
main().catch((err) => {
console.error('[launcher] Fatal:', err)
if (daemonRef) {
try { fs.unlinkSync(DAEMON_SOCKET) } catch {}
}
exit(1)
})