'use strict' const path = require('path') const FramedStream = require('framed-stream') const PearRuntime = require('pear-runtime') const ReadyResource = require('ready-resource') const { SquidManager } = require('./lib/squid-manager') const { createWorld, listWorlds, resolveWorldPaths } = require('./lib/worlds') const { worldsRoot } = require('./lib/paths') const { ensureDir } = require('./lib/worlds') const { WorldTunnelHost, WorldTunnelClient } = require('./lib/world-tunnel') const { loadOrCreateTunnelKeys } = require('./lib/tunnel-keys') const { encodeInvite, decodeInvite, decodeKey } = require('./lib/invite') /** * Flying Jib Bare application shell. * - Optional pear-runtime worker for OTA (hello-pear-bare pattern) * - Squid + HyperDHT tunnels run in this process under Bare (ADR-0013) */ module.exports = class App extends ReadyResource { constructor({ dir, appPath, updates, version, upgrade, name }) { super() this.dir = dir this.appPath = appPath this.updates = updates this.version = version this.upgrade = upgrade this.name = name this.IPC = null this.pipe = null this.squid = null this.tunnelHost = null this.tunnelClient = null this.activeWorld = null this._shuttingDown = false } _open() { ensureDir(worldsRoot(this.dir)) ensureDir(path.join(this.dir, 'corestore')) const enableWorker = this.updates !== false && this.upgrade && !String(this.upgrade).includes(' this._onWorkerMessage(data)) this.pipe.on('error', (err) => this.emit('error', err)) this.IPC.on('error', (err) => this.emit('error', err)) } catch (err) { this.emit('message', `[worker] not started: ${err.message}`) } } } async _close() { if (this.tunnelClient) { await this.tunnelClient.close().catch(() => {}) this.tunnelClient = null } if (this.tunnelHost) { await this.tunnelHost.close().catch(() => {}) this.tunnelHost = null } if (this.squid) { await this.squid.close().catch(() => {}) this.squid = null } this.activeWorld = null const pipe = this.pipe const IPC = this.IPC this.pipe = null this.IPC = null pipe?.destroy() IPC?.destroy() } _onWorkerMessage(data) { const message = data.toString() if (message === 'updating') { this.emit('updating') return } if (message === 'updated') { this.emit('updated') this._sendWorker('pear:applyUpdate') return } if (message === 'pear:updateApplied') { this.emit('update-applied') return } this.emit('message', message) } _sendWorker(message) { if (this.pipe) this.pipe.write(message) } listWorlds() { return listWorlds(this.dir) } createWorld(opts) { return createWorld(this.dir, opts) } /** * Start Squid for a world (loopback only). * @param {{ world: string, port?: number, version?: string }} opts */ async startWorld(opts) { if (this.squid) { throw new Error('A world is already running; stop it first') } const { meta, anvil } = resolveWorldPaths(this.dir, opts.world) const port = opts.port || 25565 const version = opts.version || meta.version this.squid = new SquidManager({ worldFolder: anvil, port, version, motd: meta.motd || `Flying Jib — ${meta.name}`, logging: false }) this.squid.on('error', (err) => this.emit('error', err)) this.squid.on('listening', (p) => { this.emit('message', `Squid listening on 127.0.0.1:${p}`) }) await this.squid.ready() this.activeWorld = meta return this.squid.status } /** * Start Squid + HyperDHT host tunnel; return status + invite string. * @param {{ world: string, port?: number, version?: string }} opts */ async hostWorld(opts) { const squidStatus = await this.startWorld(opts) const keys = loadOrCreateTunnelKeys(this.dir, this.activeWorld.id) this.tunnelHost = new WorldTunnelHost({ localPort: squidStatus.port, seed: keys.seed, cap: keys.cap }) this.tunnelHost.on('connection', () => { this.emit('message', 'Remote peer connected via tunnel') }) this.tunnelHost.on('error', (err) => this.emit('error', err)) await this.tunnelHost.ready() const invite = encodeInvite({ type: 'private-world', worldKey: keys.publicKeyZ32, cap: keys.capZ32, name: this.activeWorld.name, mcVersion: this.activeWorld.version, portHint: squidStatus.port }) return { squid: squidStatus, tunnel: this.tunnelHost.status, invite } } /** * Join a private world via fj1. invite (client tunnel only; no local Squid). * @param {{ invite: string, localPort?: number }} opts */ async joinWorld(opts) { if (this.tunnelClient) { throw new Error('Already joined a remote world; stop first') } const inv = decodeInvite(opts.invite) const publicKey = decodeKey(inv.worldKey) const cap = decodeKey(inv.cap) this.tunnelClient = new WorldTunnelClient({ publicKey, cap, localPort: opts.localPort != null ? Number(opts.localPort) : 0 }) this.tunnelClient.on('error', (err) => this.emit('error', err)) await this.tunnelClient.ready() return { invite: inv, tunnel: this.tunnelClient.status } } async stopWorld() { let stopped = false if (this.tunnelClient) { await this.tunnelClient.close().catch(() => {}) this.tunnelClient = null stopped = true } if (this.tunnelHost) { await this.tunnelHost.close().catch(() => {}) this.tunnelHost = null stopped = true } if (this.squid) { await this.squid.close().catch(() => {}) this.squid = null stopped = true } this.activeWorld = null return stopped } getStatus() { return { storage: this.dir, world: this.activeWorld, squid: this.squid ? this.squid.status : { running: false }, tunnelHost: this.tunnelHost ? this.tunnelHost.status : null, tunnelClient: this.tunnelClient ? this.tunnelClient.status : null } } async exit(code = 0) { if (this._shuttingDown) return this._shuttingDown = true if (typeof Bare !== 'undefined') Bare.exitCode = code try { await this.stopWorld() } catch { // ignore } await this.close() } }