You've already forked Atomcms-edit
797 lines
33 KiB
PHP
Executable File
797 lines
33 KiB
PHP
Executable File
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Services\NitroUpdateService;
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Support\Facades\Process;
|
||
|
||
class GenerateNitroConfigs extends Command
|
||
{
|
||
#[\Override]
|
||
protected $signature = 'app:generate-nitro-configs
|
||
{--site-url= : The site URL to use}';
|
||
|
||
#[\Override]
|
||
protected $description = 'Generate Nitro configuration files (renderer-config.json, ui-config.json, UITexts.json)';
|
||
|
||
private array $checks = [];
|
||
|
||
public function handle(): int
|
||
{
|
||
$siteUrl = $this->option('site-url') ?? setting('nitro_site_url', config('app.url', 'https://epicnabbo.nl'));
|
||
|
||
$this->info('🔧 Nitro Config Generator');
|
||
$this->line('═══════════════════════════════════════════════');
|
||
|
||
// Check 1: Validate URL
|
||
$this->addCheck(function () use ($siteUrl) {
|
||
if (empty($siteUrl) || ! filter_var($siteUrl, FILTER_VALIDATE_URL)) {
|
||
throw new \Exception("Invalid URL: {$siteUrl}");
|
||
}
|
||
$this->line(" ✓ URL: {$siteUrl}");
|
||
});
|
||
|
||
$nitroService = new NitroUpdateService;
|
||
$status = $nitroService->getStatus();
|
||
$webroot = $status['webroot'] ?? '/var/www/Client';
|
||
$buildPath = $status['build_path'] ?? '/var/www/atomcms/nitro-client/dist';
|
||
|
||
// Check 2: Webroot exists
|
||
$this->addCheck(function () use ($webroot) {
|
||
$result = Process::timeout(5)->run('test -d ' . escapeshellarg((string) $webroot));
|
||
if ($result->exitCode() !== 0) {
|
||
throw new \Exception("Webroot does not exist: {$webroot}");
|
||
}
|
||
$this->line(" ✓ Webroot exists: {$webroot}");
|
||
});
|
||
|
||
// Check 3: Webroot is writable
|
||
$this->addCheck(function () use ($webroot) {
|
||
$result = Process::timeout(5)->run('test -w ' . escapeshellarg((string) $webroot));
|
||
if ($result->exitCode() !== 0) {
|
||
throw new \Exception("Webroot is not writable: {$webroot}");
|
||
}
|
||
$this->line(' ✓ Webroot is writable');
|
||
});
|
||
|
||
// Check 4: Build path exists
|
||
$this->addCheck(function () use ($buildPath) {
|
||
$result = Process::timeout(5)->run('test -d ' . escapeshellarg((string) $buildPath));
|
||
if ($result->exitCode() !== 0) {
|
||
throw new \Exception("Build path does not exist: {$buildPath}");
|
||
}
|
||
$this->line(" ✓ Build path exists: {$buildPath}");
|
||
});
|
||
|
||
// Check both buildPath and webroot for example files
|
||
$this->addCheck(function () use ($buildPath, $webroot) {
|
||
// Map .example to .json fallback
|
||
$fileMap = [
|
||
'renderer-config.example' => 'renderer-config.json',
|
||
'ui-config.example' => 'ui-config.json',
|
||
'UITexts.example' => 'UITexts.example',
|
||
];
|
||
|
||
$allFound = true;
|
||
|
||
foreach ($fileMap as $exampleFile => $jsonFile) {
|
||
// Check .example file first
|
||
$buildPathResult = Process::timeout(5)->run('test -f ' . escapeshellarg($buildPath . '/' . $exampleFile));
|
||
$webrootResult = Process::timeout(5)->run('test -f ' . escapeshellarg($webroot . '/' . $exampleFile));
|
||
|
||
if ($buildPathResult->exitCode() === 0) {
|
||
$this->line(" ✓ Found in build path: {$exampleFile}");
|
||
} elseif ($webrootResult->exitCode() === 0) {
|
||
$this->line(" ✓ Found in webroot: {$exampleFile}");
|
||
} elseif ($jsonFile !== $exampleFile) {
|
||
// Check json fallback for .example files
|
||
$jsonBuildResult = Process::timeout(5)->run('test -f ' . escapeshellarg($buildPath . '/' . $jsonFile));
|
||
$jsonWebrootResult = Process::timeout(5)->run('test -f ' . escapeshellarg($webroot . '/' . $jsonFile));
|
||
if ($jsonBuildResult->exitCode() === 0) {
|
||
$this->line(" ✓ Found in build path: {$jsonFile}");
|
||
} elseif ($jsonWebrootResult->exitCode() === 0) {
|
||
$this->line(" ✓ Found in webroot: {$jsonFile}");
|
||
} else {
|
||
$this->warn(" ⚠ Missing: {$exampleFile} or {$jsonFile}");
|
||
$allFound = false;
|
||
}
|
||
} else {
|
||
$this->warn(" ⚠ Missing: {$exampleFile}");
|
||
$allFound = false;
|
||
}
|
||
}
|
||
|
||
if (! $allFound) {
|
||
throw new \Exception('Some example files are missing');
|
||
}
|
||
});
|
||
|
||
// Check 6: Check disk space
|
||
$this->addCheck(function () use ($webroot) {
|
||
$result = Process::timeout(10)->run("df -h {$webroot} | tail -1 | awk '{print $4}'");
|
||
if ($result->successful()) {
|
||
$free = trim($result->output());
|
||
$this->line(" ✓ Free space: {$free}");
|
||
}
|
||
});
|
||
|
||
// Check 7: Validate bundled/config gamedata files
|
||
$this->addCheck(function () {
|
||
$bundledConfigDir = '/var/www/Gamedata/bundled/config';
|
||
$configDir = '/var/www/Gamedata/config';
|
||
$requiredFiles = ['HabboAvatarActions.json', 'FurnitureData.json', 'ExternalTexts.json', 'ProductData.json', 'FigureData.json', 'FigureMap.json', 'EffectMap.json'];
|
||
|
||
// Create bundled/config if it doesn't exist
|
||
if (! is_dir($bundledConfigDir)) {
|
||
mkdir($bundledConfigDir, 0755, true);
|
||
$this->line(" 📁 Created directory: {$bundledConfigDir}");
|
||
}
|
||
|
||
// Copy missing files from config to bundled/config
|
||
foreach ($requiredFiles as $file) {
|
||
$targetPath = $bundledConfigDir . '/' . $file;
|
||
if (! file_exists($targetPath)) {
|
||
$sourcePath = $configDir . '/' . $file;
|
||
if (file_exists($sourcePath)) {
|
||
copy($sourcePath, $targetPath);
|
||
$this->line(" 📋 Copied: {$file} to bundled/config");
|
||
}
|
||
}
|
||
}
|
||
|
||
$this->line(' ✓ Gamedata bundled/config files ready');
|
||
});
|
||
|
||
// Check 8: Validate existing config files
|
||
$this->addCheck(function () use ($webroot) {
|
||
$configFiles = ['renderer-config.json', 'ui-config.json', 'UITexts.json'];
|
||
foreach ($configFiles as $file) {
|
||
$path = $webroot . '/' . $file;
|
||
$result = Process::timeout(5)->run('test -f ' . escapeshellarg($path));
|
||
if ($result->exitCode() === 0) {
|
||
// Validate JSON
|
||
$jsonCheck = Process::timeout(5)->run('python3 -c "import json; json.load(open(\'' . $path . '\'))" 2>&1 || echo "INVALID"');
|
||
if (str_contains($jsonCheck->output(), 'INVALID')) {
|
||
$this->warn(" ⚠ Invalid JSON: {$file}");
|
||
} else {
|
||
$this->line(" ✓ Valid JSON: {$file}");
|
||
}
|
||
} else {
|
||
$this->line(" - New file: {$file}");
|
||
}
|
||
}
|
||
});
|
||
|
||
// Run all checks
|
||
$this->line('');
|
||
$this->info('Running pre-flight checks...');
|
||
$this->line('───────────────────────────────────────────────');
|
||
|
||
try {
|
||
foreach ($this->checks as $check) {
|
||
$check();
|
||
}
|
||
$this->line('───────────────────────────────────────────────');
|
||
$this->info('✅ All checks passed!');
|
||
} catch (\Exception $e) {
|
||
$this->error('❌ Check failed: ' . $e->getMessage());
|
||
$this->newLine();
|
||
$this->error('Please fix the issue before generating configs.');
|
||
|
||
return 1;
|
||
}
|
||
|
||
// Generate configs
|
||
$this->newLine();
|
||
$this->info('Generating configuration files...');
|
||
$this->line('───────────────────────────────────────────────');
|
||
|
||
$protocol = str_starts_with((string) $siteUrl, 'https') ? 'https' : 'http';
|
||
$host = preg_replace('/^https?:\/\//', '', (string) $siteUrl);
|
||
|
||
try {
|
||
// Step 0: Sync latest example files from GitHub
|
||
$this->syncExampleFromGithub($buildPath, $webroot);
|
||
|
||
$rendererConfig = $this->generateRendererConfig($protocol, $host, $buildPath, $webroot);
|
||
$uiConfig = $this->generateUiConfig($protocol, $host, $buildPath, $webroot);
|
||
|
||
// Compare with existing deployed configs
|
||
$this->compareConfigs($rendererConfig, 'renderer-config', $webroot);
|
||
$this->compareConfigs($uiConfig, 'ui-config', $webroot);
|
||
|
||
// Validate generated JSON
|
||
$this->validateJson($rendererConfig, 'renderer-config');
|
||
$this->validateJson($uiConfig, 'ui-config');
|
||
|
||
$tempFile = '/tmp/nitro_config_' . uniqid();
|
||
file_put_contents($tempFile . '_renderer', json_encode($rendererConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||
file_put_contents($tempFile . '_ui', json_encode($uiConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||
|
||
// Copy generated configs
|
||
$commands = [
|
||
'cp "' . $tempFile . '_renderer" "' . $webroot . '/renderer-config.json"',
|
||
'cp "' . $tempFile . '_ui" "' . $webroot . '/ui-config.json"',
|
||
];
|
||
|
||
// Copy UITexts.json if it exists (check both buildPath and webroot)
|
||
$uitextsInBuild = Process::timeout(5)->run('test -f "' . $buildPath . '/UITexts.example"')->exitCode() === 0;
|
||
$uitextsInWebroot = Process::timeout(5)->run('test -f "' . $webroot . '/UITexts.example"')->exitCode() === 0;
|
||
if ($uitextsInBuild) {
|
||
$commands[] = 'cp "' . $buildPath . '/UITexts.example" "' . $webroot . '/UITexts.json"';
|
||
$this->line(' ✓ Generated: UITexts.json (from build path)');
|
||
} elseif ($uitextsInWebroot) {
|
||
$commands[] = 'cp "' . $webroot . '/UITexts.example" "' . $webroot . '/UITexts.json"';
|
||
$this->line(' ✓ Generated: UITexts.json (from webroot)');
|
||
}
|
||
|
||
$commands[] = 'rm "' . $tempFile . '_renderer" "' . $tempFile . '_ui"';
|
||
|
||
Process::timeout(10)->run(implode(' && ', $commands));
|
||
|
||
// Set proper ownership
|
||
Process::timeout(10)->run("chown www-data:www-data {$webroot}/renderer-config.json {$webroot}/ui-config.json {$webroot}/UITexts.json 2>/dev/null");
|
||
|
||
setting('nitro_config_generated_at', now()->toIso8601String());
|
||
|
||
$this->line(' ✓ Generated: renderer-config.json');
|
||
$this->line(' ✓ Generated: ui-config.json');
|
||
|
||
// Verify generated files
|
||
$this->verifyGeneratedFiles($webroot);
|
||
|
||
$this->line('───────────────────────────────────────────────');
|
||
$this->info('✅ Configs generated successfully!');
|
||
|
||
Log::info('[NitroConfig] Generated successfully', [
|
||
'webroot' => $webroot,
|
||
'host' => $host,
|
||
]);
|
||
|
||
return 0;
|
||
} catch (\Exception $e) {
|
||
$this->error('❌ Generation failed: ' . $e->getMessage());
|
||
Log::error('[NitroConfig] Generation failed', ['error' => $e->getMessage()]);
|
||
|
||
return 1;
|
||
}
|
||
}
|
||
|
||
private function addCheck(callable $check): void
|
||
{
|
||
$this->checks[] = function () use ($check) {
|
||
$check();
|
||
};
|
||
}
|
||
|
||
private function validateJson(array $data, string $name): void
|
||
{
|
||
$encoded = json_encode($data, JSON_THROW_ON_ERROR);
|
||
$decoded = json_decode($encoded, true);
|
||
|
||
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
|
||
throw new \Exception("Invalid JSON generated for {$name}");
|
||
}
|
||
|
||
$this->line(" ✓ Validated: {$name}");
|
||
}
|
||
|
||
private function verifyGeneratedFiles(string $webroot): void
|
||
{
|
||
$files = ['renderer-config.json', 'ui-config.json', 'UITexts.json'];
|
||
$allValid = true;
|
||
|
||
foreach ($files as $file) {
|
||
$path = $webroot . '/' . $file;
|
||
$result = Process::timeout(5)->run('test -f ' . escapeshellarg($path));
|
||
|
||
if ($result->exitCode() === 0) {
|
||
$size = Process::timeout(5)->run('stat -c%s ' . escapeshellarg($path));
|
||
$sizeStr = trim($size->output()) . ' bytes';
|
||
$this->line(" ✓ Verified: {$file} ({$sizeStr})");
|
||
} else {
|
||
$this->warn(" ⚠ Missing: {$file}");
|
||
$allValid = false;
|
||
}
|
||
}
|
||
|
||
if (! $allValid) {
|
||
throw new \Exception('Some files were not generated');
|
||
}
|
||
}
|
||
|
||
private function generateRendererConfig(string $protocol, string $host, string $buildPath, string $webroot): array
|
||
{
|
||
$httpProtocol = $protocol === 'https' ? 'https' : 'http';
|
||
$wssProtocol = $protocol === 'https' ? 'wss' : 'ws';
|
||
$wsHost = 'ws.' . $host;
|
||
|
||
$rendererConfig = [];
|
||
|
||
// Check build path first, then webroot for .example file
|
||
$examplePath = $buildPath . '/renderer-config.example';
|
||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() !== 0) {
|
||
$examplePath = $webroot . '/renderer-config.example';
|
||
}
|
||
|
||
// Fallback to .json version (newer Nitro-V3 format)
|
||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() !== 0) {
|
||
$jsonPath = $buildPath . '/renderer-config.json';
|
||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($jsonPath))->exitCode() === 0) {
|
||
$examplePath = $jsonPath;
|
||
} else {
|
||
$jsonPath = $webroot . '/renderer-config.json';
|
||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($jsonPath))->exitCode() === 0) {
|
||
$examplePath = $jsonPath;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Load COMPLETE example file as base
|
||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() === 0) {
|
||
$content = file_get_contents($examplePath);
|
||
// Fix invalid escape sequences (literal \n, \r, \t in JSON values)
|
||
$content = $this->fixInvalidJsonEscapeSequences($content);
|
||
$rendererConfig = @json_decode($content, true) ?: [];
|
||
$this->line(' ✓ Loaded renderer config from: ' . basename($examplePath) . ' (' . count($rendererConfig) . ' keys)');
|
||
} else {
|
||
$this->warn(' ⚠ renderer-config.example/json not found');
|
||
|
||
return [];
|
||
}
|
||
|
||
// Recursively replace ALL URLs in the entire config
|
||
$rendererConfig = $this->replaceUrlsRecursively($rendererConfig, $httpProtocol, $wssProtocol, $host, $wsHost);
|
||
|
||
// Auto-detect asset directory names on disk and fix paths
|
||
$rendererConfig = $this->autoDetectAssetPaths($rendererConfig, $webroot);
|
||
|
||
// Special handling for socket.url - use ws subdomain
|
||
if (isset($rendererConfig['socket.url'])) {
|
||
$rendererConfig['socket.url'] = $wssProtocol . '://ws.' . $host;
|
||
}
|
||
|
||
// Auto-detect local asset paths from Gamedata directory
|
||
$gamedataBase = $httpProtocol . '://' . $host;
|
||
|
||
// Check what directories exist in Gamedata and set URLs accordingly
|
||
$gamedataPath = '/var/www/Gamedata';
|
||
if (is_dir($gamedataPath)) {
|
||
// Check for bundled directory - serve via /gamedata/bundled (nginx alias)
|
||
if (is_dir($gamedataPath . '/bundled')) {
|
||
$rendererConfig['asset.url'] = $gamedataBase . '/gamedata/bundled';
|
||
}
|
||
// JSON config files are in /gamedata/config/
|
||
if (is_dir($gamedataPath . '/config')) {
|
||
$rendererConfig['gamedata.url'] = $gamedataBase . '/gamedata/config';
|
||
}
|
||
// Check for c_images directory - serve via /gamedata/c_images
|
||
if (is_dir($gamedataPath . '/c_images')) {
|
||
$rendererConfig['image.library.url'] = $gamedataBase . '/gamedata/c_images/';
|
||
// Use icons folder for furni icons
|
||
if (is_dir($gamedataPath . '/icons')) {
|
||
$rendererConfig['hof.furni.url'] = $gamedataBase . '/gamedata/icons';
|
||
} else {
|
||
$rendererConfig['hof.furni.url'] = $gamedataBase . '/gamedata/c_images/dcr/hof_furni';
|
||
}
|
||
}
|
||
// Fix furni icon path - icons are directly in hof.furni folder, not in icons subfolder
|
||
if (isset($rendererConfig['furni.asset.icon.url'])) {
|
||
$rendererConfig['furni.asset.icon.url'] = '${hof.furni.url}/%libname%%param%_icon.png';
|
||
}
|
||
// Fix sound machine samples path - sounds are in /gamedata/sounds/
|
||
if (isset($rendererConfig['external.samples.url'])) {
|
||
$rendererConfig['external.samples.url'] = $gamedataBase . '/gamedata/sounds/sound_machine_sample_%sample%.mp3';
|
||
}
|
||
// Check for images directory
|
||
if (is_dir($gamedataPath . '/images')) {
|
||
$rendererConfig['images.url'] = $gamedataBase . '/gamedata/images';
|
||
}
|
||
}
|
||
|
||
// Add missing keys that might not be in the example
|
||
if (! isset($rendererConfig['external.plugins'])) {
|
||
$rendererConfig['external.plugins'] = [];
|
||
}
|
||
|
||
// Add YouTube API key from settings
|
||
$youtubeApiKey = setting('youtube_api_key', '');
|
||
if (! empty($youtubeApiKey)) {
|
||
$rendererConfig['youtube.api.key'] = $youtubeApiKey;
|
||
$this->line(' ✓ Added YouTube API key to renderer config');
|
||
}
|
||
|
||
// Ensure pet.types matches the exact required list in order
|
||
if (isset($rendererConfig['pet.types'])) {
|
||
$requiredPetTypes = [
|
||
'dog',
|
||
'cat',
|
||
'croco',
|
||
'terrier',
|
||
'bear',
|
||
'pig',
|
||
'lion',
|
||
'rhino',
|
||
'spider',
|
||
'turtle',
|
||
'chicken',
|
||
'frog',
|
||
'dragon',
|
||
'monster',
|
||
'monkey',
|
||
'horse',
|
||
'monsterplant',
|
||
'bunnyeaster',
|
||
'bunnyevil',
|
||
'bunnydepressed',
|
||
'bunnylove',
|
||
'pigeongood',
|
||
'pigeonevil',
|
||
'demonmonkey',
|
||
'bearbaby',
|
||
'terrierbaby',
|
||
'gnome',
|
||
'leprechaun',
|
||
'kittenbaby',
|
||
'puppybaby',
|
||
'pigletbaby',
|
||
'haloompa',
|
||
'fools',
|
||
'pterosaur',
|
||
'velociraptor',
|
||
'cow',
|
||
'dragondog',
|
||
'pkmshaymin2',
|
||
'LeetEendjes',
|
||
'pkmnentei',
|
||
'squirtle',
|
||
'LeetBH',
|
||
'LeetCaviaaa',
|
||
'LeetFantj',
|
||
'LeetHotelMario',
|
||
'LeetUil',
|
||
'LeetWolf',
|
||
'pokemon_mewblu',
|
||
'LeetBB',
|
||
'LeetHotelMari1',
|
||
'LeetMewtw',
|
||
'LeetPikachu',
|
||
'LeetYos',
|
||
'LeetE',
|
||
'LeetMewt1',
|
||
'LeetPen',
|
||
'slendermn',
|
||
'pkmnPAPI0',
|
||
'pkmnPAPI1',
|
||
'pkmnPAPI2',
|
||
'pkmnPAPI3',
|
||
'pkmnPAPI4',
|
||
'pokmn_mew',
|
||
'pkmashhhpet',
|
||
'pkmbeautfly',
|
||
'pkmcelebipe',
|
||
'pkmdarkraip',
|
||
'pkmeeveepet',
|
||
'pkmjirachip',
|
||
'pkmpichupet',
|
||
'pkmriolupet',
|
||
'pkmshayminp',
|
||
'pkmtogepipe',
|
||
'pkmvictinip',
|
||
'slenderm1',
|
||
'LeetEendj16',
|
||
'pkmnente1',
|
||
'LeetBa',
|
||
'babymeisje',
|
||
'babyBH',
|
||
'bb_hbx',
|
||
'LeetUi1',
|
||
];
|
||
|
||
$rendererConfig['pet.types'] = $requiredPetTypes;
|
||
$this->line(' ✓ Set pet.types to exact required list');
|
||
}
|
||
|
||
return $rendererConfig;
|
||
}
|
||
|
||
private function generateUiConfig(string $protocol, string $host, string $buildPath, string $webroot): array
|
||
{
|
||
$httpProtocol = $protocol === 'https' ? 'https' : 'http';
|
||
$wssProtocol = $protocol === 'https' ? 'wss' : 'ws';
|
||
$wsHost = 'ws.' . $host;
|
||
|
||
$uiConfig = [];
|
||
|
||
// Check build path first, then webroot for .example file
|
||
$examplePath = $buildPath . '/ui-config.example';
|
||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() !== 0) {
|
||
$examplePath = $webroot . '/ui-config.example';
|
||
}
|
||
|
||
// Fallback to .json version (newer Nitro-V3 format)
|
||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() !== 0) {
|
||
$jsonPath = $buildPath . '/ui-config.json';
|
||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($jsonPath))->exitCode() === 0) {
|
||
$examplePath = $jsonPath;
|
||
} else {
|
||
$jsonPath = $webroot . '/ui-config.json';
|
||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($jsonPath))->exitCode() === 0) {
|
||
$examplePath = $jsonPath;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Load COMPLETE example file as base
|
||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() === 0) {
|
||
$content = file_get_contents($examplePath);
|
||
// Fix invalid escape sequences
|
||
$content = $this->fixInvalidJsonEscapeSequences($content);
|
||
$uiConfig = @json_decode($content, true) ?: [];
|
||
$this->line(' ✓ Loaded ui config from: ' . basename($examplePath) . ' (' . count($uiConfig) . ' keys)');
|
||
} else {
|
||
$this->warn(' ⚠ ui-config.example/json not found');
|
||
|
||
return [];
|
||
}
|
||
|
||
// Recursively replace ALL URLs in the entire config
|
||
$uiConfig = $this->replaceUrlsRecursively($uiConfig, $httpProtocol, $wssProtocol, $host, $wsHost);
|
||
|
||
return $uiConfig;
|
||
}
|
||
|
||
private function fixInvalidJsonEscapeSequences(string $content): string
|
||
{
|
||
// The example file has literal backslash + any letter in JSON values
|
||
// which breaks JSON. We need to escape all of these.
|
||
|
||
$backslash = chr(92);
|
||
$letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'];
|
||
|
||
foreach ($letters as $letter) {
|
||
$content = str_replace($backslash . $letter, $backslash . $backslash . $letter, $content);
|
||
}
|
||
|
||
return $content;
|
||
}
|
||
|
||
private function replaceUrlsRecursively(array $config, string $httpProtocol, string $wsProtocol, string $host, string $wsHost): array
|
||
{
|
||
foreach ($config as &$value) {
|
||
if (is_array($value)) {
|
||
// Handle nested arrays (like navigator.room.models)
|
||
if ($this->isAssociativeArray($value)) {
|
||
$value = $this->replaceUrlsRecursively($value, $httpProtocol, $wsProtocol, $host, $wsHost);
|
||
} else {
|
||
// Handle arrays of URLs
|
||
$value = array_map(function ($item) use ($httpProtocol, $wsProtocol, $host, $wsHost) {
|
||
if (is_string($item)) {
|
||
return $this->replaceUrl($item, $httpProtocol, $wsProtocol, $host, $wsHost);
|
||
}
|
||
|
||
return $item;
|
||
}, $value);
|
||
}
|
||
} elseif (is_string($value)) {
|
||
$value = $this->replaceUrl($value, $httpProtocol, $wsProtocol, $host, $wsHost);
|
||
}
|
||
}
|
||
|
||
return $config;
|
||
}
|
||
|
||
private function replaceUrl(string $url, string $httpProtocol, string $wsProtocol, string $host, string $wsHost): string
|
||
{
|
||
// Replace ws/wss URLs with ws subdomain
|
||
if (str_starts_with($url, 'ws://') || str_starts_with($url, 'wss://')) {
|
||
$path = parse_url($url, PHP_URL_PATH) ?? '/';
|
||
|
||
return $wsProtocol . '://' . $wsHost . ($path !== '/' ? $path : '');
|
||
}
|
||
|
||
// Replace localhost in all URLs
|
||
$url = preg_replace('#https?://localhost(?::\d+)?#', $httpProtocol . '://' . $host, $url);
|
||
$url = preg_replace('#wss?://localhost(?::\d+)?#', $wsProtocol . '://' . $wsHost, (string) $url);
|
||
$url = preg_replace('#localhost(?::\d+)?#', $host, (string) $url);
|
||
|
||
// Fix broken escape sequences in URL paths (from invalid JSON in example files)
|
||
// Pattern: /public\nitro-assets\gamedata or any variation
|
||
$url = preg_replace('#/public\\n[a-z_-]+\\\\gamedata#i', '/gamedata', (string) $url);
|
||
$url = preg_replace('#/public\\\\[a-z_-]+\\\\gamedata#i', '/gamedata', (string) $url);
|
||
$url = preg_replace('#/nitro-assets\\\\gamedata#i', '/gamedata', (string) $url);
|
||
$url = preg_replace('#/nitro\\\\[a-z_-]+#i', '/gamedata', (string) $url);
|
||
|
||
// Fix known asset path patterns
|
||
$url = str_replace('/public/nitro-assets/gamedata', '/gamedata', $url);
|
||
$url = str_replace('/swf/gamedata', '/gamedata', $url);
|
||
|
||
// Clean up any remaining backslashes in URLs
|
||
$url = str_replace('\\', '/', $url);
|
||
|
||
// Clean up any remaining double slashes (but keep protocol slashes)
|
||
$url = preg_replace('#([^:])//+#', '$1/', $url);
|
||
|
||
return $url;
|
||
}
|
||
|
||
private function isAssociativeArray(array $arr): bool
|
||
{
|
||
if ($arr === []) {
|
||
return false;
|
||
}
|
||
|
||
return array_keys($arr) !== range(0, count($arr) - 1);
|
||
}
|
||
|
||
private function autoDetectAssetPaths(array $config, string $webroot): array
|
||
{
|
||
// Check multiple possible gamedata locations
|
||
$possiblePaths = [
|
||
$webroot . '/gamedata',
|
||
'/var/www/Gamedata',
|
||
'/var/www/gamedata',
|
||
];
|
||
$gamedataPath = array_find($possiblePaths, fn ($path) => is_dir($path));
|
||
|
||
if (! $gamedataPath) {
|
||
return $config;
|
||
}
|
||
|
||
$assetDir = opendir($gamedataPath);
|
||
$actualDirs = [];
|
||
while (($entry = readdir($assetDir)) !== false) {
|
||
if ($entry !== '.' && $entry !== '..' && is_dir($gamedataPath . '/' . $entry)) {
|
||
$actualDirs[strtolower($entry)] = $entry;
|
||
}
|
||
}
|
||
closedir($assetDir);
|
||
|
||
$pathChecks = [
|
||
'pet.asset.url' => 'pets',
|
||
'furni.asset.url' => 'furniture',
|
||
'avatar.asset.url' => 'clothes',
|
||
'avatar.asset.effect.url' => 'effect',
|
||
'generic.asset.url' => 'generic_custom',
|
||
];
|
||
|
||
foreach ($config as $key => &$value) {
|
||
if (! is_string($value) || ! isset($pathChecks[$key])) {
|
||
continue;
|
||
}
|
||
|
||
$expectedDir = $pathChecks[$key];
|
||
$lowerExpected = strtolower($expectedDir);
|
||
$actualName = null;
|
||
|
||
// Special case: "figure" is often used instead of "clothes" for avatars
|
||
if ($lowerExpected === 'clothes' && isset($actualDirs['figure'])) {
|
||
$actualName = $actualDirs['figure'];
|
||
} elseif (isset($actualDirs[$lowerExpected])) {
|
||
$actualName = $actualDirs[$lowerExpected];
|
||
} else {
|
||
foreach ($actualDirs as $actualLower => $actual) {
|
||
if (str_starts_with($actualLower, rtrim($lowerExpected, 's')) ||
|
||
str_starts_with(rtrim($actualLower, 's'), rtrim($lowerExpected, 's'))) {
|
||
$actualName = $actual;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($actualName && $actualName !== $expectedDir) {
|
||
$value = str_replace("/{$expectedDir}/", "/{$actualName}/", $value);
|
||
$this->line(" 🔍 Auto-detected: {$key} -> /{$actualName}/ (was /{$expectedDir}/)");
|
||
}
|
||
}
|
||
|
||
return $config;
|
||
}
|
||
|
||
private function syncExampleFromGithub(string $buildPath, string $webroot): void
|
||
{
|
||
$this->info('Syncing latest examples from GitHub...');
|
||
|
||
$rendererRepo = setting('nitro_github_url', '');
|
||
$repo = $this->parseRepoFromUrl($rendererRepo);
|
||
if (! $repo) {
|
||
$this->warn(' ⚠ No GitHub repo configured, skipping sync');
|
||
|
||
return;
|
||
}
|
||
|
||
$branch = setting('nitro_github_branch', 'main');
|
||
|
||
$examples = [
|
||
'renderer-config.example' => 'renderer-config.example',
|
||
'ui-config.example' => 'ui-config.example',
|
||
'UITexts.example' => 'UITexts.json',
|
||
];
|
||
|
||
foreach (array_keys($examples) as $remoteFile) {
|
||
$tempFile = '/tmp/' . $remoteFile . '_' . uniqid();
|
||
$fetched = false;
|
||
|
||
// Try multiple paths: root, public/, nitro-client/dist/, Nitro-V3 paths
|
||
$paths = ['', 'public/', 'nitro-client/dist/', 'dist/', 'src/', 'assets/'];
|
||
foreach ($paths as $path) {
|
||
$url = "https://raw.githubusercontent.com/{$repo}/{$branch}/{$path}{$remoteFile}";
|
||
$result = Process::timeout(15)->run("curl -sL -o {$tempFile} '{$url}'");
|
||
|
||
if ($result->successful() && file_exists($tempFile) && filesize($tempFile) > 10) {
|
||
$content = file_get_contents($tempFile);
|
||
$data = @json_decode($content, true);
|
||
|
||
if (is_array($data) && $data !== []) {
|
||
// Save to both buildPath and webroot
|
||
file_put_contents($buildPath . '/' . $remoteFile, $content);
|
||
file_put_contents($webroot . '/' . $remoteFile, $content);
|
||
$this->line(" ✓ Synced: {$remoteFile} (" . count($data) . " keys from {$path}{$remoteFile})");
|
||
$fetched = true;
|
||
break;
|
||
}
|
||
}
|
||
@unlink($tempFile);
|
||
}
|
||
|
||
if (! $fetched) {
|
||
$this->line(" - Skipped: {$remoteFile} (not found in any path)");
|
||
}
|
||
}
|
||
}
|
||
|
||
private function parseRepoFromUrl(string $url): ?string
|
||
{
|
||
if (preg_match('/github\.com\/([^\/]+\/[^\/\?#]+)/', $url, $matches)) {
|
||
return rtrim($matches[1], '/');
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function compareConfigs(array $generated, string $name, string $webroot): void
|
||
{
|
||
$currentPath = $webroot . '/' . $name . '.json';
|
||
if (! file_exists($currentPath)) {
|
||
$this->line(" ℹ {$name}: Geen bestaande config — nieuwe generatie");
|
||
|
||
return;
|
||
}
|
||
|
||
$current = @json_decode(file_get_contents($currentPath), true);
|
||
if (! is_array($current)) {
|
||
$this->warn(" ⚠ {$name}: Bestaande config is ongeldig JSON");
|
||
|
||
return;
|
||
}
|
||
|
||
$newKeys = array_diff(array_keys($generated), array_keys($current));
|
||
$removedKeys = array_diff(array_keys($current), array_keys($generated));
|
||
$changedKeys = [];
|
||
|
||
foreach (array_intersect(array_keys($generated), array_keys($current)) as $key) {
|
||
if ($generated[$key] !== $current[$key]) {
|
||
$changedKeys[] = $key;
|
||
}
|
||
}
|
||
|
||
if ($newKeys !== []) {
|
||
$this->line(" 🆕 {$name}: " . count($newKeys) . ' nieuwe key(s): ' . implode(', ', array_slice($newKeys, 0, 10)));
|
||
if (count($newKeys) > 10) {
|
||
$this->line(' ... en ' . (count($newKeys) - 10) . ' meer');
|
||
}
|
||
}
|
||
if ($removedKeys !== []) {
|
||
$this->line(" 🗑 {$name}: " . count($removedKeys) . ' verwijderde key(s): ' . implode(', ', array_slice($removedKeys, 0, 5)));
|
||
}
|
||
if ($changedKeys !== []) {
|
||
$this->line(" 🔄 {$name}: " . count($changedKeys) . ' gewijzigde key(s): ' . implode(', ', array_slice($changedKeys, 0, 5)));
|
||
}
|
||
if ($newKeys === [] && $removedKeys === [] && $changedKeys === []) {
|
||
$this->line(" ✓ {$name}: Geen wijzigingen");
|
||
}
|
||
}
|
||
}
|