UNPKG

xcraft-core-host

Version:

Multiple engines support for xcraft-server

971 lines (846 loc) 26.7 kB
'use strict'; if (process.platform === 'win32') { /* HACK: workaround when require.resolve is used on modules * that are created as junctions on Windows. When a junction * is resolved to the real directory, the drive letter doesn't * use the same case. It's a major problem when a module is * required from module in node_modules/ and other modules * in lib/ (referenced in node_modules/ with a junction). */ const Module = require('module'); const origResolveFilename = Module._resolveFilename; Module._resolveFilename = function () { const result = origResolveFilename.apply(this, arguments); return result.replace(/^([a-z]):/, (c) => c.toUpperCase()); }; } const moduleName = 'xcraft-core-host'; const xLog = require('xcraft-core-log')(moduleName); const fse = require('fs-extra'); const path = require('node:path'); const initialConfigLoader = require('./initialConfigLoader.js'); const configBuilder = require('./configBuilder.js'); let electron; try { electron = require('electron'); } catch (ex) { if (ex.code !== 'MODULE_NOT_FOUND') { throw ex; } } const isElectron = !!process.versions.electron && electron && typeof electron !== 'string'; const watt = require('gigawatts'); const EventEmitter = require('events'); class Host extends EventEmitter { #sweeped = false; #idleStateInterval; #secondInstance = []; #filePath = []; #openUrl = []; #config = null; _app = null; constructor() { super(); if (isElectron) { const {app} = require('electron'); this._app = app; } watt.wrapAll(this); } getRealmKeysPath(server) { const keyPath = path.join( this.#config.realmsStorePath, `${server}@${this.#config.variantId}-key.pem` ); const certPath = path.join( this.#config.realmsStorePath, `${server}@${this.#config.variantId}-cert.pem` ); return {keyPath, certPath}; } async saveRealmKeys(server, certPem, privateKeyPem) { const {keyPath, certPath} = this.getRealmKeysPath(server); await fse.writeFile(certPath, certPem); await fse.writeFile(keyPath, privateKeyPem); } async importKeyAndCertFiles(server, keyFiles) { const {certPath, keyPath} = this.getRealmKeysPath(server); if (keyFiles.length !== 2) { return false; } const list = []; for (const keyFile of keyFiles) { const basename = path.basename(keyFile); if (basename === path.basename(certPath)) { list.push({src: keyFile, dst: certPath}); } else if (basename === path.basename(keyPath)) { list.push({src: keyFile, dst: keyPath}); } } if (list.length !== 2) { return false; } for (const {src, dst} of list) { await fse.copy(src, dst); } return true; } async getRealmClientCertificateSubject(server) { const pki = require('node-forge').pki; const {certPath} = this.getRealmKeysPath(server); const certPem = await fse.readFile(certPath); const cert = pki.certificateFromPem(certPem); return Object.fromEntries( cert.subject.attributes.map((attr) => [attr.shortName, attr.value]) ); } async checkRealmKeys(server) { const {keyPath, certPath} = this.getRealmKeysPath(server); const hasKey = await fse.pathExists(keyPath); const hasCert = await fse.pathExists(certPath); //TODO: check expire date ? return hasKey && hasCert; } async tryToImportKeys(server) { xLog.err('Gatekeeper process failed, ask for keys...'); const {app, dialog} = require('electron'); await app.whenReady(); const result = dialog.showMessageBoxSync({ title: `Connection to the ${server} realm`, type: 'info', message: 'The connection via the gatekeeper has failed.\nYou must provide the keys or quit and contact an administrator.', buttons: ['Continue', 'Quit'], }); if (result === 1) { xLog.dbg(`user canceled import`); app.exit(0); return; } const filePaths = dialog.showOpenDialogSync({ title: 'Select a key and a cert files to import (2 files)', defaultPath: app.getPath('home'), buttonLabel: 'import', filters: [ { name: 'PEM key and certificate file (.pem)', extensions: ['pem'], }, ], properties: ['openFile', 'multiSelections', 'dontAddToRecent'], }); if (!filePaths) { xLog.dbg(`user canceled import`); app.exit(0); return; } const imported = await this.importKeyAndCertFiles(server, filePaths); if (!imported) { await dialog.showErrorBox( 'Error with key and certificate', 'The provided files are not supported, exit...' ); app.exit(0); return; } } async gatekeeper() { const {topology} = require('xcraft-core-etc')().load('xcraft-core-horde'); let disposers = []; let realmsUserInfos = null; for (const [server, {gatekeeper}] of Object.entries(topology)) { if (!gatekeeper) { continue; } if (!realmsUserInfos) { realmsUserInfos = {}; } const hasKeys = await this.checkRealmKeys(server); if (hasKeys) { const {CN, OU, E} = await this.getRealmClientCertificateSubject(server); realmsUserInfos[server] = {login: E, rank: OU, id: CN}; continue; } let failed; try { xLog.dbg('Registering with gatekeeper...'); const registerResp = await fetch(`${gatekeeper}/register`); const {providerUrl, waitingUrl} = await registerResp.json(); xLog.dbg('Login with auth provider...'); if (!this.WM) { this.WM = require('./wm.js'); } const disposer = this.WM.instance.displayAuth( providerUrl, true, null, true, server, disposers[disposers.length - 1] ); disposers.push(disposer); const loginResp = await fetch(waitingUrl); const results = await loginResp.json(); const {login, accepted} = results; if (!accepted) { xLog.dbg('Registration failed for login ', login, ' (not accepted)'); failed = true; } else { xLog.dbg('Registration accepted for login ', login); const { keys: {certPem, privateKeyPem}, } = results; await this.saveRealmKeys(server, certPem, privateKeyPem); const {CN, OU, E} = await this.getRealmClientCertificateSubject( server ); realmsUserInfos[server] = {login: E, rank: OU, id: CN}; xLog.dbg('Realm keys saved'); } } catch (err) { failed = true; xLog.err('Cannot reach the gatekeeper:', err); } finally { if (failed) { await this.tryToImportKeys(server); } } } this.WM.instance.displaySplash(disposers[disposers.length - 1]); return realmsUserInfos; } async load(initialConfig) { initialConfig = configBuilder(initialConfig); const {config, skipEnv} = await this.selectRealm(initialConfig); this.#config = config; const xHost = require('xcraft-core-host'); Object.assign(xHost, this.#config); if (!process.env.GOBLINS_APP) { process.env.GOBLINS_APP = config.variantId ? `${config.appId}@${config.variantId}` : config.appId; } if (!process.env.GOBLINS_APP_MASTER) { process.env.GOBLINS_APP_MASTER = config.appMasterId; } if (config.appEnv === 'release' && !process.env.NODE_ENV) { process.env.NODE_ENV = 'production'; } if (!process.env.XCRAFT_LOG && process.env.NODE_ENV !== 'development') { process.env.XCRAFT_LOG = '2'; } this._appConfigPath = config.appConfigPath; this._ignoreCloseRequests = false; this._powerSaveBlockerIds = []; this._xServer = require('xcraft-server')( config.appConfigPath, config.projectPath, () => require('./args-parsing.js')(false, process.argv, true), skipEnv ); if (isElectron && this.#config.useRealms) { this.#config.realmsUserInfos = await this.gatekeeper(); Object.assign(xHost, this.#config); } if (isElectron) { if (!config.useRealms) { const {app} = require('electron'); await app.whenReady(); } if (!this.WM) { this.WM = require('./wm.js'); } this.WM.instance.loadConfig(); if (!config.useRealms) { this.WM.instance.loadSplash(); } } const xEtc = require('xcraft-core-etc')(); const appArgs = require('./args-parsing.js')(); if (appArgs.nabu) { /* because nabu is working with an external nabu-thrall server, AXON is mandatory */ const transportConfig = xEtc.load('xcraft-core-transport'); if (transportConfig.backends.indexOf('axon') === -1) { transportConfig.backends.push('axon'); } xEtc.saveRun('xcraft-core-transport', transportConfig); } this._xConfig = xEtc.load(moduleName); xLog.dbg(`process arguments: ${process.argv.join(' ')}`); process.on('uncaughtException', (ex) => { xLog.err( `Please, ensure to yield properly all async calls: ${ ex.stack || ex.message || ex }` ); }); if (isElectron) { const {powerSaveBlocker, powerMonitor} = electron; if (this._xConfig.singleInstance) { const gotTheLock = this._app.requestSingleInstanceLock(); if (!gotTheLock) { this._app.quit(); } else { this._app.on('second-instance', (event, rawArgs, workingDir) => { try { const WM = require('./wm.js'); WM.instance.focus(); const args = require('./args-parsing.js')(true, rawArgs); if (this._busClient && this._busClient.isConnected()) { this._notifyNewInstance(args, workingDir, rawArgs); } else { this.#secondInstance.push({args, workingDir, rawArgs}); } } catch (err) { xLog.err(err.stack || err.message || err); } }); } } this._app.on('window-all-closed', () => { if (this._ignoreCloseRequests) { const MsgBox = require('./msgbox.js'); const alert = new MsgBox(); alert.open(); alert.emit('Waiting for application restart...'); return; } if (this._busClient && this._busClient.isConnected()) { this._notifyAppClosed(); } else { this._app.quit(); } }); // Specific for macOS this._app.on('open-file', (ev, filePath) => { if (this._busClient && this._busClient.isConnected()) { this._notifyOpenFile(filePath); } else { this.#filePath.push(filePath); } ev.preventDefault(); }); this._app.on('open-url', (ev, url) => { if (this._busClient && this._busClient.isConnected()) { this._notifyOpenUrl(url); } else { this.#openUrl.push(url); } ev.preventDefault(); }); for (const blockerType of this._xConfig.powerSaveBlockers) { this._powerSaveBlockerIds.push(powerSaveBlocker.start(blockerType)); } if (this._xConfig.powerMonitorSweeper) { this.#idleStateInterval = setInterval(() => { const idleState = powerMonitor.getSystemIdleState(30); switch (idleState) { case 'locked': this._notifyPowerMonitorLock('locked'); break; case 'active': case 'unknown': this._notifyPowerMonitorLock('unlocked'); break; } }, 1000); } } else { xLog.info(`node runtime detected`); } xLog.info(`config; ${JSON.stringify(config, null, 2)}`); } async selectRealm(config) { let selectedRealm = null; let skipEnv = false; if (isElectron && config.useRealms) { await this._app.whenReady(); this.WM = require('./wm.js'); if (config.realmFiles.length > 1) { selectedRealm = await this.WM.instance.prompt({ values: config.realmFiles, }); } else { selectedRealm = config.realmFiles[0]; this.WM.instance.displaySplash(); } } if (selectedRealm) { xLog.dbg(`selected realm: ${selectedRealm}`); config.variantId = selectedRealm.replace(/\.ork$/, ''); config.appConfigPath = path.join( config.appData, config.appCompany, config.variantId ? `${config.appId}-${config.variantId}` : config.appId ); const realmPath = path.join(config.realmsStorePath, selectedRealm); const xcraftRoot = path.join( config.appData, config.appCompany, `${config.appId}-${config.variantId}` ); const overrider = await fse.readJSON(realmPath); require('xcraft-server/lib/init-env.js')( xcraftRoot, config.projectPath, null, overrider ); skipEnv = true; delete process.env.GOBLINS_APP; delete process.env.GOBLINS_APP_MASTER; } config._isMinimalConfig = false; return {config, skipEnv}; } get config() { return this.#config; } get filePath() { return this.#filePath; } *_waitForSync(next) { let alert; let databases; const emit = () => { if (!alert) { return; } if (databases?.length) { alert.emit(`Synchronizing ${databases.join(', ')}<br/>Please wait…`); } else { alert.emit(`Synchronizing<br/>Please wait…`); } }; setTimeout(() => { const MsgBox = require('./msgbox.js'); alert = new MsgBox(); alert.open(); emit(); }, 500); for (let wait = false; ; wait = true) { const res = yield this._busClient.command.send( 'goblin.tryShutdown', {wait}, null, next ); if (!res.data) { break; } ({databases} = res.data); if (!databases.length) { break; } emit(); } if (alert) { alert.close(); } } // FIXME: not 100% accurate.. *_terminate(next) { if (this.#idleStateInterval) { clearInterval(this.#idleStateInterval); this.#idleStateInterval = null; } const goblinConfig = require('xcraft-core-etc')().load( 'xcraft-core-goblin' ); if (goblinConfig?.actionsSync?.enable) { yield this._waitForSync(); } if (this._unsubLineUpdated) { this._unsubLineUpdated(); } if (this._busClient) { this._busClient.command.send('shutdown'); } if (isElectron) { const {powerSaveBlocker} = require('electron'); for (const powerSaveId of this._powerSaveBlockerIds) { powerSaveBlocker.stop(powerSaveId); } } } _notifyOpenFile(filePath) { if (!this._xConfig.openFileQuest) { return; } this._busClient.command.send( this._xConfig.openFileQuest, {filePaths: [filePath]}, null, (err) => { if (err) { xLog.err(err.stack || err.message || err); } } ); } _notifyOpenUrl(url) { if (!this._xConfig.openUrlQuest) { return; } this._busClient.command.send( this._xConfig.openUrlQuest, {url}, null, (err) => { if (err) { xLog.err(err.stack || err.message || err); } } ); } *_notifyProtocol(req, next) { let {protocol, host, pathname, href, searchParams} = new URL(req.url); protocol = protocol.split(':', 1)[0]; if (!this._xConfig.protocols?.[protocol]) { return; } if (!this._busClient || !this._busClient.isConnected()) { return; } const params = Array.from(searchParams.entries()); try { const result = yield this._busClient.command.send( this._xConfig.protocols[protocol], {protocol, host, pathname, href, params}, null, next ); if (result?.data) { const stream = fse.createReadStream(result.data); return new Response(stream, { headers: { type: 'application/octet-stream', }, }); } } catch (ex) { xLog.err(ex.stack || ex.message || ex); } } _notifyPowerMonitorLock(status) { if (!this._xConfig.powerMonitorSweeper) { return; } if (!this._busClient || !this._busClient.isConnected()) { return; } const hasSweeper = this._busClient.getCommandsNames()['cryo.sweep']; if (!hasSweeper) { return; } if (status === 'unlocked') { this.#sweeped = false; return; } if (status === 'locked' && !this.#sweeped) { this._busClient.command.send('cryo.sweep', null, null, (err) => { this.#sweeped = true; if (err) { xLog.err(err.stack || err.message || err); } }); } } _notifyAppClosed() { this._terminate(); } _notifyNewInstance(args, workingDir, rawArgs) { if (!this._xConfig.newInstanceQuest) { return; } this._busClient.command.send( this._xConfig.newInstanceQuest, {commandLine: args, workingDirectory: workingDir, rawArgs}, null, (err) => { if (err) { xLog.err(err.stack || err.message || err); } } ); } _getGoblinUser() { const busConfig = require('xcraft-core-etc')().load('xcraft-core-bus'); const {resourcesPath} = require('xcraft-core-host'); const policiesJSONFile = path.join(resourcesPath, busConfig.policiesPath); const {readJSONSync} = require('fs-extra'); const policies = readJSONSync(policiesJSONFile, {throws: false}); let goblinUser; if (policies && policies.defaultSystemUserId) { goblinUser = `${policies.defaultSystemUserId}@system`; } else { goblinUser = 'defaultSystemUser@system'; } if ( this.#config.useRealms && this.#config.realmsUserInfos && Object.keys(this.#config.realmsUserInfos).length > 0 ) { const Goblin = require('xcraft-core-goblin'); //get the first entry, in multi-realms can be prolematic const userInfos = Object.values(this.#config.realmsUserInfos)[0]; Goblin.registerUser(userInfos); goblinUser = userInfos.id; } return goblinUser; } *_init(xBus, next) { /* HACK: force and unusual orc name. * The problem is that the greathall::* topic is already registered, then * when this BusClient is used to send commands, an other orc name must * be used in order to handle properly all subscribes of events. */ let connected = false; const httpProxy = require('./proxy.js'); yield httpProxy.initNetworkStack(); const _next = next.parallel(); const unsub = this._busClient.events.subscribe(`greathall::loaded`, () => { unsub(); _next(); }); this._ignoreCloseRequests = false; const onReconnect = (callback) => { this._busClient .on('reconnect', () => callback('done')) .on('reconnect attempt', () => callback('attempt')); return () => { this._busClient.removeListener('reconnect', callback); this._busClient.removeListener('reconnect attempt', callback); }; }; onReconnect((status) => { const goblinConfig = require('xcraft-core-etc')().load( 'xcraft-core-goblin' ); const syncClientEnabled = goblinConfig.actionsSync?.enable; switch (status) { case 'attempt': this._ignoreCloseRequests = syncClientEnabled ? false : true; xLog.warn('Connection lost with the server, attempt a reconnection'); break; case 'done': this._ignoreCloseRequests = false; xLog.dbg('New connection done'); break; } }); const onCommandsRegistry = () => { if (!this._busClient.isConnected() || !connected) { return; } this._busClient.removeListener('commands.registry', onCommandsRegistry); this._busClient.command.send( 'goblin._init', null, null, (err) => this.emit('goblin.initialized', err), {forceNested: true} ); }; this._busClient.on('commands.registry', onCommandsRegistry); const isLoaded = yield this._busClient.connect('ee', null, next); if (isLoaded) { unsub(); _next(); } connected = true; yield next.sync(); this._unsubLineUpdated = this._busClient.events.subscribe( '*::warehouse.lines-updated', (msg) => { const {Router} = require('xcraft-core-transport'); Router.updateLines( msg.data.lines, msg.data.token, msg.data.generation, msg._xcraftHorde ); } ); xBus.notifyCmdsRegistry(); } *_startAndRunMainQuest(goblinUser, next) { /* Start the main quest (app bootstrap). */ yield this._busClient.command.send( this._xConfig.mainQuest, null, null, next, null, {_goblinUser: goblinUser} ); } *_bootstrapHorde() { const xHorde = require('xcraft-core-horde'); if (xHorde.config.autoload) { const resp = this._busClient.newResponse(moduleName); yield xHorde.autoload(resp); } } *_startQuests(xBus, start, next) { let alert; const goblinUser = this._getGoblinUser(); this._startAndRunMainQuest(goblinUser, next.parallel()); if (this._app) { const _next = next.parallel(); this._app .whenReady() .then(() => _next()) .catch(_next); const appArgs = require('./args-parsing.js')(); if (isElectron && appArgs['relaunch-reason']) { const {app} = require('electron'); let message = `${app.getName()} is restarting, please wait...`; if (appArgs) { switch (appArgs['relaunch-reason']) { case 'client-connection': message = `${app.getName()} is restarting after a connection lose, please wait...`; break; case 'server-restart': message = `${app.getName()} is restarting after a server restart, please wait...`; break; } } const MsgBox = require('./msgbox.js'); alert = new MsgBox(); alert.open(); alert.emit(message); } } if (isElectron) { const {protocol} = electron; for (const proto of Object.keys(this._xConfig.protocols)) { protocol.handle(proto, async (req) => await this._notifyProtocol(req)); } } yield next.sync(); if (this._xConfig.afterLoadQuests) { for (const quest of this._xConfig.afterLoadQuests) { yield this._busClient.command.send(quest, null, null, next, { forceNested: true, }); } } xBus.acceptIncoming(); for (const {args, workingDir, rawArgs} of this.#secondInstance) { try { this._notifyNewInstance(args, workingDir, rawArgs); } catch (err) { xLog.err(err.stack || err.message || err); } } this.#secondInstance.length = 0; for (const filePath of this.#filePath) { this._notifyOpenFile(filePath); } this.#filePath.length = 0; for (const openUrl of this.#openUrl) { this._notifyOpenUrl(openUrl); } this.#openUrl.length = 0; if (alert) { setTimeout(() => alert.close(), 4000); } if (this._xConfig.secondaryQuest) { const payload = start ? {_elfStart: start} : null; /* Start the secondary quest (electron ready) */ yield this._busClient.command.send( this._xConfig.secondaryQuest, payload, null, next, null, {_goblinUser: goblinUser} ); } } *boot(start, isTesting, next) { if (this._xConfig.prologModuleLoad) { /* Ensure that the event loop is empty (handle macOS open-file events), then continue */ yield setTimeout(next, 100); require(this._xConfig.prologModuleLoad)(this); } yield this._xServer.start(next); if (isElectron && !this._xConfig.disableGoblinWM) { if (!this.WM) { this.WM = require('./wm.js'); } this.WM.instance.init(this); } const {BusClient} = require('xcraft-core-busclient'); const xBus = require('xcraft-core-bus'); this._busClient = new BusClient(); this._busClient._orcName = xBus.generateOrcName(); if (xBus._commander.isModuleRegistered('horde')) { yield this._bootstrapHorde(); } if (this._xConfig.mainQuest) { const _next = next.parallel(); this.on('goblin.initialized', (err) => { if (err) { _next(err); return; } this._startQuests(xBus, start, _next); }); yield this._init(xBus); yield next.sync(); } else { yield this._init(xBus); xBus.acceptIncoming(); } } } /* Prepare GOBLINS_APP environment variable */ const appArg = process.argv.reduce((res, arg, it, arr) => { if (/^--app=/.test(arg)) { // --app=venture-trade-company res.unshift(arg.split('=')[1]); } else if ( /^-[b-z]+a$/.test(arg) || // -da venture-trade-company arg === '-a' || // -a venture-trade-company arg === '--app' // --app venture-trade-company ) { res.unshift(arr[it + 1]); } return res; }, []); if (appArg.length > 0) { process.env.GOBLINS_APP = appArg[0]; if (!process.env.GOBLINS_APP_MASTER) { process.env.GOBLINS_APP_MASTER = process.env.GOBLINS_APP; } } /** * @param {Function|null|undefined} start */ module.exports = async (start = null) => { if (isElectron) { xLog.info(`electron runtime detected,`); const {protocol, app} = electron; protocol.registerSchemesAsPrivileged([ {scheme: 'app', privileges: {standard: true}}, ]); /* Enable app indicator support with Linux and AppImage */ if (process.platform === 'linux' && process.env.APPIMAGE) { const {md5} = require('xcraft-core-utils/lib/crypto.js'); const id = md5(`file://${path.resolve(process.env.APPIMAGE).trim()}`); const desktopName = `appimagekit_${id}.desktop`; process.env['CHROME_DESKTOP'] = desktopName; app?.setDesktopName(desktopName); } } const initialConfig = await initialConfigLoader(appArg, isElectron); if (!initialConfig) { return; } const host = new Host(); await host.load(initialConfig); await host.boot(start, !!start); };