Files
LuHost/utils/paths.js
Nathan Schneider 2d3b1166fe Fix server management issues and improve overall stability
Major server management fixes:
- Replace Flatpak-specific pkill with universal process tree termination using pstree + process.kill()
- Fix signal format errors (SIGTERM/SIGKILL instead of TERM/KILL strings)
- Add 5-second cooldown after server stop to prevent race conditions with external detection
- Enable Stop Server button for external servers in UI
- Implement proper timeout handling with process tree killing

ContentDB improvements:
- Fix download retry logic and "closed" error by preventing concurrent zip extraction
- Implement smart root directory detection and stripping during package extraction
- Add game-specific timeout handling (8s for VoxeLibre vs 3s for simple games)

World creation fixes:
- Make world creation asynchronous to prevent browser hangs
- Add WebSocket notifications for world creation completion status

Other improvements:
- Remove excessive debug logging
- Improve error handling and user feedback throughout the application
- Clean up temporary files and unnecessary logging

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 19:17:38 -06:00

273 lines
9.5 KiB
JavaScript

const path = require('path');
const os = require('os');
const fs = require('fs');
const appConfig = require('./app-config');
class LuantiPaths {
constructor() {
// Initialize with default, will be updated when app config loads
this.setDataDirectory(this.getDefaultDataDirectory());
}
async initialize() {
// Load app config and update data directory
await appConfig.load();
const configuredDataDir = appConfig.getDataDirectory();
this.setDataDirectory(configuredDataDir);
console.log(`Paths initialized with data directory: ${configuredDataDir}`);
}
async forceReload() {
// Force reload the app config and reinitialize paths
delete require.cache[require.resolve('./app-config')];
const appConfig = require('./app-config');
await appConfig.load();
const configuredDataDir = appConfig.getDataDirectory();
this.setDataDirectory(configuredDataDir);
console.log(`Paths force reloaded with data directory: ${configuredDataDir}`);
}
getDefaultDataDirectory() {
const validDirs = this.detectLuantiDataDirectories();
return validDirs.length > 0 ? validDirs[0].path : path.join(os.homedir(), '.minetest');
}
detectLuantiDataDirectories() {
const homeDir = os.homedir();
const candidateDirs = [
// Flatpak installations
{
path: path.join(homeDir, '.var/app/org.luanti.luanti/.minetest'),
type: 'Flatpak (org.luanti.luanti)',
description: 'Luanti installed via Flatpak'
},
{
path: path.join(homeDir, '.var/app/net.minetest.Minetest/.minetest'),
type: 'Flatpak (net.minetest.Minetest)',
description: 'Minetest installed via Flatpak'
},
// Snap installations (they typically use ~/snap/app-name/current/.local/share/minetest)
{
path: path.join(homeDir, 'snap/luanti/current/.local/share/minetest'),
type: 'Snap (luanti)',
description: 'Luanti installed via Snap'
},
{
path: path.join(homeDir, 'snap/minetest/current/.local/share/minetest'),
type: 'Snap (minetest)',
description: 'Minetest installed via Snap'
},
// System package installations
{
path: path.join(homeDir, '.luanti'),
type: 'System Package',
description: 'System-wide Luanti installation'
},
{
path: path.join(homeDir, '.minetest'),
type: 'System Package',
description: 'System-wide Minetest installation'
},
// AppImage or manual installations might use these
{
path: path.join(homeDir, '.local/share/minetest'),
type: 'User Installation',
description: 'User-local installation'
}
];
// Check which directories exist and contain expected Luanti files
const validDirs = [];
for (const candidate of candidateDirs) {
if (fs.existsSync(candidate.path)) {
// Check if it looks like a valid Luanti data directory
const hasConfig = fs.existsSync(path.join(candidate.path, 'minetest.conf'));
const hasWorlds = fs.existsSync(path.join(candidate.path, 'worlds'));
const hasDebug = fs.existsSync(path.join(candidate.path, 'debug.txt'));
// Even if some files don't exist, the directory might be valid if it exists
// (Luanti creates files on first run)
candidate.confidence = (hasConfig ? 1 : 0) + (hasWorlds ? 1 : 0) + (hasDebug ? 0.5 : 0);
candidate.exists = true;
candidate.hasConfig = hasConfig;
candidate.hasWorlds = hasWorlds;
candidate.hasDebug = hasDebug;
validDirs.push(candidate);
}
}
// Sort by confidence (directories with more Luanti files first)
validDirs.sort((a, b) => b.confidence - a.confidence);
return validDirs;
}
async getGameDisplayName(gameId) {
try {
const games = await this.getInstalledGames();
const game = games.find(g => g.name === gameId);
return game ? game.title : gameId;
} catch (error) {
console.error('Error getting game display name:', error);
return gameId;
}
}
setDataDirectory(dataDir) {
this.minetestDir = path.resolve(dataDir);
this.worldsDir = path.join(this.minetestDir, 'worlds');
this.modsDir = path.join(this.minetestDir, 'mods');
this.gamesDir = path.join(this.minetestDir, 'games');
this.texturesDir = path.join(this.minetestDir, 'textures');
this.configFile = path.join(this.minetestDir, 'minetest.conf');
this.debugFile = path.join(this.minetestDir, 'debug.txt');
}
getDataDirectory() {
return this.minetestDir;
}
getWorldPath(worldName) {
return path.join(this.worldsDir, worldName);
}
getWorldConfigPath(worldName) {
return path.join(this.getWorldPath(worldName), 'world.mt');
}
getWorldModsPath(worldName) {
return path.join(this.getWorldPath(worldName), 'worldmods');
}
getModPath(modName) {
return path.join(this.modsDir, modName);
}
getModConfigPath(modName) {
return path.join(this.getModPath(modName), 'mod.conf');
}
getGamePath(gameName) {
return path.join(this.gamesDir, gameName);
}
getGameConfigPath(gameName) {
return path.join(this.getGamePath(gameName), 'game.conf');
}
ensureDirectories() {
const dirs = [this.minetestDir, this.worldsDir, this.modsDir, this.gamesDir, this.texturesDir];
dirs.forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
}
isValidWorldName(name) {
if (!name || typeof name !== 'string') return false;
return /^[a-zA-Z0-9_-]+$/.test(name) && name.length >= 3 && name.length <= 50;
}
isValidModName(name) {
if (!name || typeof name !== 'string') return false;
return /^[a-zA-Z0-9_-]+$/.test(name) && name.length <= 50;
}
isPathSafe(targetPath) {
const resolvedPath = path.resolve(targetPath);
return resolvedPath.startsWith(path.resolve(this.minetestDir));
}
mapToActualGameId(directoryName) {
// Map directory names to the actual game IDs that Luanti recognizes
// For most cases, the directory name IS the game ID
const gameIdMap = {
// Only add mappings here if you're certain they're needed
// 'minetest_game': 'minetest', // This mapping was incorrect
};
return gameIdMap[directoryName] || directoryName;
}
async getInstalledGames() {
const games = [];
// For Flatpak and other sandboxed installations, only look in the configured data directory
// System installations might also have access to additional directories, but we should
// primarily focus on the configured data directory to match what Luanti actually sees
const possibleGameDirs = [
this.gamesDir // Only the configured games directory
];
for (const gameDir of possibleGameDirs) {
try {
const exists = fs.existsSync(gameDir);
if (!exists) continue;
const gameDirs = fs.readdirSync(gameDir);
for (const gameName of gameDirs) {
const possibleConfigPaths = [
path.join(gameDir, gameName, 'game.conf'),
path.join(gameDir, gameName, gameName, 'game.conf') // Handle nested structure
];
for (const gameConfigPath of possibleConfigPaths) {
try {
if (fs.existsSync(gameConfigPath)) {
const ConfigParser = require('./config-parser');
const gameConfig = await ConfigParser.parseGameConfig(gameConfigPath);
// Map directory names to actual game IDs that Luanti recognizes
const actualGameId = this.mapToActualGameId(gameName);
// Check if we already have this game (avoid duplicates by game ID, title, and resolved path)
const resolvedPath = fs.realpathSync(path.dirname(gameConfigPath));
const existingGame = games.find(g =>
g.name === actualGameId ||
(g.title === (gameConfig.title || gameConfig.name || gameName) && g.resolvedPath === resolvedPath)
);
if (!existingGame) {
games.push({
name: actualGameId, // Use the ID that Luanti recognizes
directoryName: gameName, // Keep original for path resolution
title: gameConfig.title || gameConfig.name || gameName,
description: gameConfig.description || '',
author: gameConfig.author || '',
path: path.dirname(gameConfigPath),
resolvedPath: resolvedPath,
isSystemGame: !gameDir.includes(this.minetestDir)
});
}
break; // Found valid config, stop checking other paths
}
} catch (gameError) {
// Skip invalid games
console.warn(`Invalid game at ${gameConfigPath}:`, gameError.message);
}
}
}
} catch (dirError) {
// Skip directories that can't be read
continue;
}
}
// Sort games: system games first, then minetest_game first, then alphabetically
games.sort((a, b) => {
if (a.isSystemGame !== b.isSystemGame) {
return a.isSystemGame ? -1 : 1;
}
// Put minetest_game first as it's the default
if (a.name === 'minetest_game') return -1;
if (b.name === 'minetest_game') return 1;
return a.title.localeCompare(b.title);
});
return games;
}
}
module.exports = new LuantiPaths();