diff --git a/.env.example b/.env.example index 9ae86e9..68653da 100644 --- a/.env.example +++ b/.env.example @@ -12,4 +12,8 @@ DEPLOY_PATH=./server-data/resources/[dev-resources]/fxManager # Config file inside server-data FXSERVER_CFG=server.cfg +# Auto-start the fxServer when the panel launches (once setup is complete). +# Default: on. The in-panel toggle (fxserver.autostart) overrides this. +FXSERVER_AUTOSTART=true + NODE_ENV=production diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 0afc944..0702eb1 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -12,6 +12,7 @@ import { checkVersion, getCurrentVersion } from './common/version_check'; import apiRoutes from './routes/api'; import internalRoutes from './routes/internal'; import { processManager } from './modules/process/manager'; +import { shouldAutostart } from './modules/process/autostart'; import { gameManager } from './modules/game/manager'; import { ConfigManager } from './modules/config/manager'; import { perfManager } from './modules/perf/manager'; @@ -174,6 +175,30 @@ if (!isProduction) { }); } +const maybeAutostart = async () => { + const setupComplete = isFxManagerSetup(); + const enabled = cm.isAutostartEnabled(); + const status = pm.getState().status; + + if (!shouldAutostart({ setupComplete, enabled, status })) { + const reason = !setupComplete + ? 'first-run setup not complete' + : !enabled + ? 'disabled (fxserver.autostart / FXSERVER_AUTOSTART)' + : `server already '${status}'`; + console.log(`[core] Auto-start skipped: ${reason}`); + return; + } + + console.log('[core] Auto-starting fxServer'); + try { + await pm.start(); + } catch (err) { + // A failed auto-start must never take the panel down with it. + console.error('[core] Auto-start failed', err); + } +}; + const start = async () => { try { await fastify.listen({ port: webServerPort, host: '0.0.0.0' }); @@ -182,6 +207,7 @@ const start = async () => { `\thttp://localhost:${webServerPort}\n` + `\thttp://${ip}:${webServerPort}`, ); + await maybeAutostart(); } catch (err) { fastify.log.error(err); process.exit(1); diff --git a/apps/core/src/modules/config/manager.ts b/apps/core/src/modules/config/manager.ts index 7e83657..64f3c95 100644 --- a/apps/core/src/modules/config/manager.ts +++ b/apps/core/src/modules/config/manager.ts @@ -5,6 +5,7 @@ import type { ServerConfig, } from '@fxmanager/shared/types'; import crypto from 'node:crypto'; +import { resolveAutostartEnabled } from '../process/autostart'; type CoreSettings = CoreConfig & ServerConfig & { @@ -53,6 +54,13 @@ export class ConfigManager { this.systemValues.resourceApiToken = crypto.randomUUID(); } + isAutostartEnabled(): boolean { + return resolveAutostartEnabled( + repo.settings.get('fxserver.autostart'), + process.env.FXSERVER_AUTOSTART, + ); + } + getSystemValues() { return this.systemValues; } diff --git a/apps/core/src/modules/process/autostart.test.ts b/apps/core/src/modules/process/autostart.test.ts new file mode 100644 index 0000000..e7eea80 --- /dev/null +++ b/apps/core/src/modules/process/autostart.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'bun:test'; +import { + parseBoolFlag, + resolveAutostartEnabled, + shouldAutostart, +} from './autostart'; + +describe('parseBoolFlag', () => { + it('treats true/1/yes/on (any case, trimmed) as true', () => { + for (const value of ['true', 'TRUE', '1', 'yes', 'YES', 'on', ' On ']) { + expect(parseBoolFlag(value)).toBe(true); + } + }); + + it('treats everything else as false', () => { + for (const value of ['false', '0', 'no', 'off', '', ' ', 'nope']) { + expect(parseBoolFlag(value)).toBe(false); + } + }); +}); + +describe('resolveAutostartEnabled', () => { + it('defaults to enabled when nothing is configured', () => { + expect(resolveAutostartEnabled(undefined, undefined)).toBe(true); + }); + + it('uses the env value when no persisted setting exists', () => { + expect(resolveAutostartEnabled(undefined, 'false')).toBe(false); + expect(resolveAutostartEnabled(undefined, 'true')).toBe(true); + }); + + it('lets the persisted panel setting win over the env value', () => { + expect(resolveAutostartEnabled('false', 'true')).toBe(false); + expect(resolveAutostartEnabled('true', 'false')).toBe(true); + }); + + it('ignores empty strings and falls through to the next source', () => { + expect(resolveAutostartEnabled('', 'false')).toBe(false); + expect(resolveAutostartEnabled('', '')).toBe(true); + }); +}); + +describe('shouldAutostart', () => { + it('starts when setup is complete, enabled, and the server is stopped', () => { + expect( + shouldAutostart({ + setupComplete: true, + enabled: true, + status: 'stopped', + }), + ).toBe(true); + }); + + it('also starts from a crashed state', () => { + expect( + shouldAutostart({ + setupComplete: true, + enabled: true, + status: 'crashed', + }), + ).toBe(true); + }); + + it('does not start before first-run setup is complete', () => { + expect( + shouldAutostart({ + setupComplete: false, + enabled: true, + status: 'stopped', + }), + ).toBe(false); + }); + + it('does not start when disabled', () => { + expect( + shouldAutostart({ + setupComplete: true, + enabled: false, + status: 'stopped', + }), + ).toBe(false); + }); + + it('does not start when the server is already running or busy', () => { + for (const status of ['running', 'starting', 'stopping'] as const) { + expect( + shouldAutostart({ setupComplete: true, enabled: true, status }), + ).toBe(false); + } + }); +}); diff --git a/apps/core/src/modules/process/autostart.ts b/apps/core/src/modules/process/autostart.ts new file mode 100644 index 0000000..e0db61d --- /dev/null +++ b/apps/core/src/modules/process/autostart.ts @@ -0,0 +1,28 @@ +import type { ProcessState } from '@fxmanager/shared/types'; + +export function parseBoolFlag(value: string): boolean { + return /^(1|true|yes|on)$/i.test(value.trim()); +} + +// Resolves whether auto-start is enabled. +export function resolveAutostartEnabled( + dbValue: string | undefined, + envValue: string | undefined, +): boolean { + if (dbValue !== undefined && dbValue !== '') return parseBoolFlag(dbValue); + if (envValue !== undefined && envValue !== '') return parseBoolFlag(envValue); + return true; +} + +// Whether the FXServer should be auto-started at boot. +export function shouldAutostart(params: { + setupComplete: boolean; + enabled: boolean; + status: ProcessState; +}): boolean { + return ( + params.setupComplete && + params.enabled && + (params.status === 'stopped' || params.status === 'crashed') + ); +} diff --git a/apps/webpanel/src/pages/settings/tabs/fxserver.tsx b/apps/webpanel/src/pages/settings/tabs/fxserver.tsx index fa7905f..371a110 100644 --- a/apps/webpanel/src/pages/settings/tabs/fxserver.tsx +++ b/apps/webpanel/src/pages/settings/tabs/fxserver.tsx @@ -10,6 +10,7 @@ import { SETTINGS_DEFAULTS } from '@fxmanager/shared/constants'; import type { SettingsTabProps } from '@/types/settings'; import { Separator } from '@fxmanager/ui/components/separator'; import { Input } from '@fxmanager/ui/components/input'; +import { Switch } from '@fxmanager/ui/components/switch'; import { Field, FieldDescription } from '@fxmanager/ui/components/field'; import { validateStartupArguments } from '@fxmanager/shared/utils'; import { useState } from 'react'; @@ -72,6 +73,9 @@ export default function FXServerTab({ const serverConfigPath = data['fxserver.serverConfigPath'] ?? SETTINGS_DEFAULTS['fxserver.serverConfigPath']; + const autostart = + (data['fxserver.autostart'] ?? SETTINGS_DEFAULTS['fxserver.autostart']) === + 'true'; return (
@@ -104,6 +108,16 @@ export default function FXServerTab({ /> + + + onChange('fxserver.autostart', checked ? 'true' : 'false') + } + /> + + diff --git a/packages/shared/src/constants/settings.ts b/packages/shared/src/constants/settings.ts index 7ad8bcd..14b0b30 100644 --- a/packages/shared/src/constants/settings.ts +++ b/packages/shared/src/constants/settings.ts @@ -8,6 +8,7 @@ export const SETTINGS_SCOPES = { 'executablePath', 'serverDataPath', 'serverConfigPath', + 'autostart', ], whitelist: ['mode', 'discordBotToken', 'discordGuildId', 'discordRoleIds'], restarts: ['enabled', 'times'], @@ -25,6 +26,7 @@ export const SETTINGS_DEFAULTS = { 'fxserver.executablePath': './FXServer', 'fxserver.serverDataPath': './server-data', 'fxserver.serverConfigPath': 'server.cfg', + 'fxserver.autostart': 'true', 'whitelist.mode': 'none', 'restarts.enabled': 'false', 'restarts.times': '',