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();