OverviewFilesCommitsIssuesDocsReleasesPeers

godot-keet / index.js

357 lines · javascript ref:
// 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)
})