settings = app(SettingsService::class);
$this->settings->clearInstanceCache();
$this->clientRepo = 'duckietm/Nitro-V3';
$this->rendererRepo = 'duckietm/Nitro_Render_V3';
$this->gitlabHost = 'gitlab.epicnabbo.nl/duckietm';
$basePath = $this->detectBasePath();
$this->clientPath = $this->resolvePath(setting('nitro_client_path', $basePath . '/nitro-client'));
$this->rendererPath = $this->resolvePath(setting('nitro_renderer_path', $basePath . '/nitro-renderer'));
$this->clientWebRoot = $this->resolvePath(setting('nitro_webroot', '/var/www/Client'));
$this->historyService = app(UpdateHistoryService::class);
}
private function isDirAccessible(string $path): bool
{
if ($path === '' || $path === '0') {
return false;
}
$result = Process::timeout(5)->run('test -d ' . escapeshellarg($path) . ' && echo "yes" || echo "no"');
return trim($result->output()) === 'yes';
}
private function detectBasePath(): string
{
$possiblePaths = [
base_path(),
'/var/www/atomcms',
'/var/www/html',
'/var/www',
dirname(base_path()),
];
foreach ($possiblePaths as $path) {
if ($this->isDirAccessible($path)) {
$clientCheck = Process::timeout(5)->run('test -d ' . escapeshellarg($path . '/nitro-client') . ' && echo "yes" || echo "no"');
$rendererCheck = Process::timeout(5)->run('test -d ' . escapeshellarg($path . '/nitro-renderer') . ' && echo "yes" || echo "no"');
if (trim($clientCheck->output()) === 'yes' || trim($rendererCheck->output()) === 'yes') {
return $path;
}
}
}
return base_path();
}
private function resolvePath(string $path): string
{
if (str_starts_with($path, '/')) {
if ($this->isDirAccessible($path)) {
return $path;
}
$parts = explode('/', trim($path, '/'));
$root = '/' . $parts[0];
if (! $this->isDirAccessible($root)) {
return base_path() . '/' . ltrim($path, '/');
}
return $path;
}
return base_path() . '/' . ltrim($path, '/');
}
public function isConfigured(): bool
{
return $this->isDirAccessible($this->clientPath) || $this->isDirAccessible($this->rendererPath) || $this->isDirAccessible($this->clientWebRoot);
}
public function getClientPath(): string
{
return $this->clientPath;
}
public function getRendererPath(): string
{
return $this->rendererPath;
}
public function getClientWebRoot(): string
{
return $this->clientWebRoot;
}
public function getStatus(): array
{
$buildPath = setting('nitro_build_path', '/var/www/atomcms/nitro-client/dist');
$webroot = $this->clientWebRoot;
// Use shell commands to bypass open_basedir
$checkDir = function (string $path): bool {
$result = Process::timeout(5)->run('test -d ' . escapeshellarg($path) . ' && echo "yes" || echo "no"');
return trim($result->output()) === 'yes';
};
$checkFile = function (string $path): bool {
$result = Process::timeout(5)->run('test -f ' . escapeshellarg($path) . ' && echo "yes" || echo "no"');
return trim($result->output()) === 'yes';
};
// Check nginx config via shell to bypass open_basedir
$nginxCheck = Process::timeout(5)->run('test -f /etc/nginx/sites-enabled/cms.conf -o -f /etc/nginx/sites-enabled/atom.conf -o -f /etc/nginx/conf.d/atom.conf && echo "yes" || echo "no"');
$nginxValid = trim($nginxCheck->output()) === 'yes';
// Check open_basedir - fixed if empty OR includes all required paths
$openBaseDir = ini_get('open_basedir');
$clientPath = $this->clientPath;
$rendererPath = $this->rendererPath;
$openBasedirFixed = in_array($openBaseDir, ['', '0', false], true) || (
str_contains($openBaseDir, $clientPath) &&
str_contains($openBaseDir, $rendererPath)
);
Log::info('[NitroUpdate] open_basedir check:', [
'open_basedir' => $openBaseDir,
'clientPath' => $clientPath,
'rendererPath' => $rendererPath,
'fixed' => $openBasedirFixed,
]);
// Build files (check for hashed filenames like index-CLCACmcI.js)
$assetsPath = $buildPath . '/assets';
$indexJsCheck = Process::timeout(5)->run('test -f ' . escapeshellarg($buildPath . '/index.js') . ' && echo "yes" || echo "no"');
$indexJsHashCheck = Process::timeout(5)->run('ls ' . escapeshellarg($assetsPath) . '/index-*.js 2>/dev/null | head -1');
$hasIndexJs = trim($indexJsCheck->output()) === 'yes' || ! in_array(trim($indexJsHashCheck->output()), ['', '0'], true);
$rendererJsCheck = Process::timeout(5)->run('test -f ' . escapeshellarg($buildPath . '/renderer.js') . ' && echo "yes" || echo "no"');
$rendererJsHashCheck = Process::timeout(5)->run('ls ' . escapeshellarg($assetsPath) . '/nitro-renderer-*.js 2>/dev/null | head -1');
$hasRendererJs = trim($rendererJsCheck->output()) === 'yes' || ! in_array(trim($rendererJsHashCheck->output()), ['', '0'], true);
$vendorJsCheck = Process::timeout(5)->run('test -f ' . escapeshellarg($buildPath . '/vendor.js') . ' && echo "yes" || echo "no"');
$vendorJsHashCheck = Process::timeout(5)->run('ls ' . escapeshellarg($assetsPath) . '/vendor-*.js 2>/dev/null | head -1');
$hasVendorJs = trim($vendorJsCheck->output()) === 'yes' || ! in_array(trim($vendorJsHashCheck->output()), ['', '0'], true);
// Check for CSS files
$hasCssCheck = Process::timeout(5)->run('find ' . escapeshellarg((string) $buildPath) . " -name '*.css' -type f 2>/dev/null | head -1");
$hasCss = ! in_array(trim($hasCssCheck->output()), ['', '0'], true);
// Renderer (monorepo - packages are built into client, so just check node_modules)
$hasRendererDist = $checkDir($this->rendererPath . '/node_modules');
$hasRendererIndex = $checkDir($this->rendererPath . '/packages');
// Config files
$rendererConfigValid = $checkFile($webroot . '/renderer-config.json');
$uiConfigValid = $checkFile($webroot . '/ui-config.json');
$nitroConfigValid = $checkFile($webroot . '/UITexts.json');
$hasRendererExample = $checkFile($buildPath . '/renderer-config.example') || $checkFile($webroot . '/renderer-config.example');
$hasUiExample = $checkFile($buildPath . '/ui-config.example') || $checkFile($webroot . '/ui-config.example');
// Assets
$hasImageAssets = $checkDir($webroot . '/assets') || $checkDir($buildPath . '/assets');
$hasFavicon = $checkFile($webroot . '/favicon.ico') || $checkFile($buildPath . '/favicon.ico');
// System
$webrootExists = $checkDir($webroot);
$webrootWritableCheck = Process::timeout(5)->run('test -w ' . escapeshellarg($webroot) . ' && echo "yes" || echo "no"');
$webrootWritable = trim($webrootWritableCheck->output()) === 'yes';
$webrootFileCount = $this->countFiles($webroot);
$buildSize = $this->getDirSize($buildPath);
$webrootSize = $this->getDirSize($webroot);
// Socket URL from renderer config
$socketUrl = 'N/A';
if ($rendererConfigValid) {
$configResult = Process::timeout(5)->run('cat ' . escapeshellarg($webroot . '/renderer-config.json') . ' 2>/dev/null');
if ($configResult->successful()) {
$config = json_decode($configResult->output(), true);
$socketUrl = $config['socket.url'] ?? 'N/A';
}
}
// Check for gamedata - could be a symlink or directory
$symlinkValid = false;
// Check if gamedata exists as symlink or directory
$gamedataCheck = Process::timeout(5)->run('test -e ' . escapeshellarg($webroot . '/gamedata') . ' && echo "yes" || echo "no"');
if (trim($gamedataCheck->output()) === 'yes') {
$symlinkValid = true;
} else {
// Try alternative locations
$altPaths = [
'/var/www/Gamedata',
'/var/www/gamedata',
'/var/www/atomcms/public/gamedata',
];
foreach ($altPaths as $altPath) {
$altCheck = Process::timeout(5)->run('test -d ' . escapeshellarg($altPath) . ' && echo "yes" || echo "no"');
if (trim($altCheck->output()) === 'yes') {
$symlinkValid = true;
break;
}
}
}
return [
'client_path' => $this->clientPath,
'renderer_path' => $this->rendererPath,
'build_path' => $buildPath,
'webroot' => $webroot,
'client_commit' => $this->getCommit($this->clientPath),
'renderer_commit' => $this->getCommit($this->rendererPath),
'client_installed' => $checkDir($this->clientPath . '/.git'),
'renderer_installed' => $checkDir($this->rendererPath . '/.git'),
'build_exists' => $checkDir($buildPath),
'deployed' => $rendererConfigValid,
'symlink_valid' => $symlinkValid,
'client_node_modules' => $checkDir($this->clientPath . '/node_modules'),
'renderer_node_modules' => $checkDir($this->rendererPath . '/node_modules'),
'client_latest_commit' => null,
'renderer_latest_commit' => null,
'last_checked' => setting('nitro_last_checked'),
'nginx_config_valid' => $nginxValid,
'open_basedir_fixed' => $openBasedirFixed,
'has_index_js' => $hasIndexJs,
'has_renderer_js' => $hasRendererJs,
'has_vendor_js' => $hasVendorJs,
'has_css_file' => $hasCss,
'vite_config_valid' => $checkFile($this->clientPath . '/vite.config.js') || $checkFile($this->clientPath . '/vite.config.mjs'),
'renderer_config_valid' => $rendererConfigValid,
'ui_config_valid' => $uiConfigValid,
'nitro_config_valid' => $nitroConfigValid,
'has_uitexts_json' => $nitroConfigValid,
'has_renderer_example' => $hasRendererExample,
'has_uiconfig_example' => $hasUiExample,
'has_image_assets' => $hasImageAssets,
'has_favicon' => $hasFavicon,
'assets_writable' => $webrootWritable,
'assets_file_count' => $webrootFileCount,
'has_renderer_dist' => $hasRendererDist,
'has_renderer_index' => $hasRendererIndex,
'webroot_exists' => $webrootExists,
'webroot_writable' => $webrootWritable,
'webroot_file_count' => $webrootFileCount,
'build_size' => $buildSize,
'webroot_size' => $webrootSize,
'socket_url' => $socketUrl,
'websocket_accessible' => $this->checkWebSocketAccessible($socketUrl),
'emulator_connected' => $this->checkEmulatorConnected(),
];
}
private function checkWebSocketAccessible(string $socketUrl): bool
{
if (in_array($socketUrl, ['', '0', 'N/A'], true)) {
return false;
}
try {
$wsUrl = $socketUrl;
if (str_starts_with($wsUrl, 'wss://')) {
$wsUrl = 'https://' . substr($wsUrl, 6);
} elseif (str_starts_with($wsUrl, 'ws://')) {
$wsUrl = 'http://' . substr($wsUrl, 4);
}
$host = parse_url($wsUrl, PHP_URL_HOST);
$port = parse_url($wsUrl, PHP_URL_PORT);
if (! $port) {
$port = str_starts_with($socketUrl, 'wss://') ? 443 : 80;
}
$socket = @fsockopen($host, $port, $errno, $errstr, 5);
if ($socket) {
fclose($socket);
return true;
}
} catch (\Exception $e) {
// Fallback to curl check
}
// Fallback: try curl
try {
$ch = curl_init($socketUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
curl_setopt($ch, CURLOPT_NOBODY, true);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
return in_array($httpCode, [200, 301, 302, 400, 404]);
} catch (\Exception) {
return false;
}
}
private function checkEmulatorConnected(): bool
{
try {
$rconService = new RconService;
return $rconService->isConnected();
} catch (\Exception) {
return false;
}
}
private function countFiles(string $path): int
{
$check = Process::timeout(10)->run('test -d ' . escapeshellarg($path) . ' && echo "yes" || echo "no"');
if (trim($check->output()) !== 'yes') {
return 0;
}
$result = Process::timeout(10)->run('find ' . escapeshellarg($path) . ' -type f 2>/dev/null | wc -l');
return (int) trim($result->output() ?? '0');
}
private function getDirSize(string $path): int
{
$check = Process::timeout(10)->run('test -d ' . escapeshellarg($path) . ' && echo "yes" || echo "no"');
if (trim($check->output()) !== 'yes') {
return 0;
}
$result = Process::timeout(10)->run('du -sb ' . escapeshellarg($path) . ' 2>/dev/null | cut -f1');
return (int) trim($result->output() ?? '0');
}
public function updateNitro(bool $forceBuild = false): array
{
$lockFile = '/tmp/nitro-update.lock';
if (file_exists($lockFile)) {
$lockAge = time() - filemtime($lockFile);
if ($lockAge < 300) {
$pid = @file_get_contents($lockFile);
return ['success' => false, 'error' => "Update al bezig (PID: {$pid})"];
}
@unlink($lockFile);
}
file_put_contents($lockFile, getmypid());
try {
Log::info('[Nitro] Starting update');
$this->historyService->log('nitro', 'update', null, 'pending', 'Update gestart');
$this->ensureVpsRequirements();
$this->setupDirectories();
$this->syncRepos();
$this->installDependencies();
$this->fixKnownBugs();
$buildResult = $this->buildClient();
if ($buildResult['success']) {
$this->deployClient();
$this->generateConfigs();
$this->settings->set('nitro_client_commit', $this->getCommit($this->clientPath));
$this->settings->set('nitro_renderer_commit', $this->getCommit($this->rendererPath));
Log::info('[Nitro] Update completed successfully');
$this->historyService->log('nitro', 'update', 'client', 'success', 'Update succesvol');
return ['success' => true, 'message' => 'Nitro client succesvol geüpdatet!'];
}
$this->historyService->log('nitro', 'update', 'client', 'failed', $buildResult['error'] ?? 'Build mislukt');
return $buildResult;
} catch (\Exception $e) {
Log::error('[Nitro] Update failed', ['error' => $e->getMessage()]);
$this->historyService->log('nitro', 'update', null, 'failed', $e->getMessage());
return ['success' => false, 'error' => $e->getMessage()];
} finally {
@unlink($lockFile);
}
}
private function ensureVpsRequirements(): array
{
$actions = [];
$errors = [];
$nodeCheck = Process::timeout(5)->run('which node && node --version');
if (! $nodeCheck->successful()) {
$errors[] = 'Node.js niet geïnstalleerd';
Process::timeout(60)->run('curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs 2>&1');
$nodeCheck2 = Process::timeout(5)->run('which node && node --version');
if ($nodeCheck2->successful()) {
$actions[] = 'Node.js geïnstalleerd: ' . trim($nodeCheck2->output());
}
} else {
$actions[] = 'Node.js: ' . trim($nodeCheck->output());
}
$yarnCheck = Process::timeout(5)->run('which yarn && yarn --version');
if (! $yarnCheck->successful()) {
$actions[] = 'Yarn wordt geïnstalleerd...';
Process::timeout(60)->run('npm install -g yarn 2>&1');
$yarnCheck2 = Process::timeout(5)->run('which yarn && yarn --version');
if ($yarnCheck2->successful()) {
$actions[] = 'Yarn geïnstalleerd: ' . trim($yarnCheck2->output());
}
} else {
$actions[] = 'Yarn: ' . trim($yarnCheck->output());
}
$npmCacheDir = Process::timeout(5)->run('echo $HOME');
$npmHome = trim($npmCacheDir->output()) ?: '/root';
Process::timeout(5)->run('mkdir -p ' . escapeshellarg($npmHome . '/.npm'));
Process::timeout(5)->run('mkdir -p /var/www/.npm');
Process::timeout(5)->run('chown -R www-data:www-data /var/www/.npm');
Process::timeout(10)->run('git config --global --add safe.directory "*" 2>/dev/null || true');
return [
'actions' => $actions,
'errors' => $errors,
];
}
private function setupDirectories(): void
{
$dirs = [
$this->clientPath => 'Nitro Client',
$this->rendererPath => 'Nitro Renderer',
$this->clientWebRoot => 'Client Webroot',
];
foreach ($dirs as $path => $label) {
$existsCheck = Process::timeout(5)->run('test -d ' . escapeshellarg($path) . ' && echo "yes" || echo "no"');
if (trim($existsCheck->output()) !== 'yes') {
Log::info("[Nitro] Creating {$label} directory: {$path}");
Process::timeout(10)->run('mkdir -p ' . escapeshellarg($path));
}
Process::timeout(10)->run('chown -R www-data:www-data ' . escapeshellarg($path));
}
// Create gamedata symlink if it doesn't exist
$gamedataSymlink = $this->clientWebRoot . '/gamedata';
$gamedataCheck = Process::timeout(5)->run('test -e ' . escapeshellarg($gamedataSymlink) . ' && echo "yes" || echo "no"');
if (trim($gamedataCheck->output()) !== 'yes') {
// Try to find gamedata folder
$possibleGamedata = [
'/var/www/Gamedata',
'/var/www/gamedata',
'/var/www/atomcms/public/gamedata',
base_path() . '/../Gamedata',
];
foreach ($possibleGamedata as $gdPath) {
$gdCheck = Process::timeout(5)->run('test -d ' . escapeshellarg($gdPath) . ' && echo "yes" || echo "no"');
if (trim($gdCheck->output()) === 'yes') {
Process::timeout(10)->run('ln -s ' . escapeshellarg($gdPath) . ' ' . escapeshellarg($gamedataSymlink));
Log::info('[Nitro] Created gamedata symlink: ' . $gdPath . ' -> ' . $gamedataSymlink);
break;
}
}
}
}
private function syncRepos(): void
{
$repos = [
['path' => $this->rendererPath, 'repo' => $this->rendererRepo],
['path' => $this->clientPath, 'repo' => $this->clientRepo],
];
Process::timeout(5)->run('git config --global --add safe.directory "*" 2>/dev/null || true');
foreach ($repos as $r) {
$gitCheck = Process::timeout(5)->run('test -d ' . escapeshellarg($r['path']) . '/.git && echo "yes" || echo "no"');
$isGitRepo = trim($gitCheck->output()) === 'yes';
if ($isGitRepo) {
Log::info("[Nitro] Updating {$r['repo']}");
$fetchResult = Process::timeout(120)->run('cd ' . escapeshellarg($r['path']) . ' && git fetch nitro-v3 2>&1');
$resetResult = Process::timeout(60)->run('cd ' . escapeshellarg($r['path']) . ' && git checkout main 2>&1 || git checkout master 2>&1 || true');
$pullResult = Process::timeout(120)->run('cd ' . escapeshellarg($r['path']) . ' && git reset --hard nitro-v3/main 2>&1 || git reset --hard nitro-v3/master 2>&1 || true');
if (! $pullResult->successful()) {
Log::warning("[Nitro] Update failed, re-cloning {$r['repo']}");
Process::timeout(30)->run('rm -rf ' . escapeshellarg($r['path']));
$cloneResult = Process::timeout(300)->run(
"git clone --branch main --depth 1 https://{$this->gitlabHost}/{$r['repo']}.git " . escapeshellarg($r['path']) . ' 2>&1',
);
}
} else {
Log::info("[Nitro] Cloning {$r['repo']}");
$dirCheck = Process::timeout(5)->run('test -d ' . escapeshellarg($r['path']) . ' && echo "yes" || echo "no"');
if (trim($dirCheck->output()) === 'yes') {
Process::timeout(30)->run('rm -rf ' . escapeshellarg($r['path']));
}
Process::timeout(300)->run(
"git clone --branch main --depth 1 https://{$this->gitlabHost}/{$r['repo']}.git " . escapeshellarg($r['path']) . ' 2>&1',
);
}
Process::timeout(10)->run('chown -R www-data:www-data ' . escapeshellarg($r['path']));
}
}
private function fixKnownBugs(): void
{
$this->fixChatsCss();
// Fix 1: Chats.css case sensitivity
$indexFile = $this->clientPath . '/src/index.tsx';
if (file_exists($indexFile)) {
$content = file_get_contents($indexFile);
if (str_contains($content, './css/chat/Chats.css')) {
$content = str_replace('./css/chat/Chats.css', './css/chat/chats.css', $content);
file_put_contents($indexFile, $content);
Log::info('[Nitro] Fixed Chats.css case');
}
}
// Fix 2: Duplicate CatalogView export
$catalogFile = $this->clientPath . '/src/components/catalog/CatalogView.tsx';
if (file_exists($catalogFile)) {
$content = file_get_contents($catalogFile);
$lines = explode("\n", $content);
$cleanLines = [];
$seenExport = false;
foreach ($lines as $line) {
if (str_contains($line, 'export const CatalogView')) {
if ($seenExport) {
continue;
}
$seenExport = true;
}
$cleanLines[] = $line;
}
file_put_contents($catalogFile, implode("\n", $cleanLines));
Log::info('[Nitro] Fixed duplicate CatalogView');
}
// Fix 3: Create/update vite config
$this->updateViteConfig();
// Fix 4: Create symlink for renderer
$this->createYarnLink();
// Fix 5: Add missing renderer stubs
$this->addMissingRendererStubs();
// Fix 6: Duplicate FurniEditorResultEvent (case sensitivity conflict)
$duplicateFile = $this->rendererPath . '/packages/communication/src/messages/incoming/furniture/FurniEditorResultEvent.ts';
if (file_exists($duplicateFile)) {
unlink($duplicateFile);
$furnitureIndexFile = $this->rendererPath . '/packages/communication/src/messages/incoming/furniture/index.ts';
if (file_exists($furnitureIndexFile)) {
$content = file_get_contents($furnitureIndexFile);
$content = str_replace("export * from './FurniEditorResultEvent';\n", '', $content);
file_put_contents($furnitureIndexFile, $content);
}
Log::info('[Nitro] Fixed duplicate FurniEditorResultEvent');
}
// Fix 7: Rename Chats.css to lowercase
$upperCaseCss = $this->clientPath . '/src/css/chat/Chats.css';
$lowerCaseCss = $this->clientPath . '/src/css/chat/chats.css';
if (file_exists($upperCaseCss) && ! file_exists($lowerCaseCss)) {
rename($upperCaseCss, $lowerCaseCss);
Log::info('[Nitro] Fixed Chats.css case sensitivity');
}
// Fix 8: Remove all duplicate FurniEditor files from furniture/ (keep only furnieditor/)
$outgoingDir = $this->rendererPath . '/packages/communication/src/messages/outgoing/furniture';
$incomingDir = $this->rendererPath . '/packages/communication/src/messages/incoming/furniture';
$outgoingDupes = ['BySprite', 'Create', 'Delete', 'Detail', 'Interactions', 'Search', 'Update'];
foreach ($outgoingDupes as $name) {
$file = $outgoingDir . '/FurniEditor' . $name . 'Composer.ts';
if (file_exists($file)) {
unlink($file);
}
}
if (file_exists($outgoingDir . '/index.ts')) {
$content = file_get_contents($outgoingDir . '/index.ts');
$lines = explode("\n", $content);
$cleanLines = array_filter($lines, fn ($line) => ! str_contains((string) $line, 'FurniEditor'));
file_put_contents($outgoingDir . '/index.ts', implode("\n", $cleanLines));
}
$incomingDupes = ['Detail', 'Interactions', 'Result', 'Search'];
foreach ($incomingDupes as $name) {
$file = $incomingDir . '/FurniEditor' . $name . 'Event.ts';
if (file_exists($file)) {
unlink($file);
}
}
if (file_exists($incomingDir . '/index.ts')) {
$content = file_get_contents($incomingDir . '/index.ts');
$lines = explode("\n", $content);
$cleanLines = array_filter($lines, fn ($line) => ! str_contains((string) $line, 'FurniEditor'));
file_put_contents($incomingDir . '/index.ts', implode("\n", $cleanLines));
}
Log::info('[Nitro] Removed duplicate FurniEditor files from furniture/');
}
private function fixChatsCss(): void
{
$chatCssFile = $this->clientPath . '/src/css/chat/chats.css';
if (! file_exists($chatCssFile)) {
return;
}
$content = file_get_contents($chatCssFile);
$modified = false;
$content = preg_replace('/\/\/\s*normal\s*\n\s*\.message/', '.message', $content, -1, $count);
if ($count > 0) {
$modified = true;
}
$content = preg_replace('/\/\/\s*whisper\s*\n\s*\.message/', '.message', $content, -1, $count);
if ($count > 0) {
$modified = true;
}
$content = preg_replace('/\/\/\s*shout\s*\n\s*\.message/', '.message', $content, -1, $count);
if ($count > 0) {
$modified = true;
}
if ($modified) {
file_put_contents($chatCssFile, $content);
Log::info('[Nitro] Fixed chat CSS comments');
}
}
private function addMissingRendererStubs(): void
{
$outgoingDir = $this->rendererPath . '/packages/communication/src/messages/outgoing/furniture';
$incomingDir = $this->rendererPath . '/packages/communication/src/messages/incoming/furniture';
$outgoingFurnieditorDir = $this->rendererPath . '/packages/communication/src/messages/outgoing/furnieditor';
$incomingFurnieditorDir = $this->rendererPath . '/packages/communication/src/messages/incoming/furnieditor';
if (! is_dir($outgoingDir)) {
mkdir($outgoingDir, 0755, true);
}
if (! is_dir($incomingDir)) {
mkdir($incomingDir, 0755, true);
}
$composers = ['BySprite', 'Create', 'Delete', 'Detail', 'Interactions', 'Search', 'Update'];
$composerLines = [];
foreach ($composers as $name) {
$furnieditorFile = $outgoingFurnieditorDir . '/FurniEditor' . $name . 'Composer.ts';
$stubFile = $outgoingDir . '/FurniEditor' . $name . 'Composer.ts';
if (! file_exists($furnieditorFile) && ! file_exists($stubFile)) {
file_put_contents($stubFile, "import { IMessageComposer } from '@nitrots/api';\nexport class FurniEditor{$name}Composer implements IMessageComposer {\n constructor(k: any) {}\n dispose() {}\n getMessageArray() { return []; }\n}\n");
$composerLines[] = "export * from './FurniEditor{$name}Composer';";
}
}
if ($composerLines !== []) {
$composerContent = implode("\n", $composerLines) . "\n";
file_put_contents($outgoingDir . '/index.ts', $composerContent);
}
$events = ['Detail', 'Interactions', 'Result', 'Search'];
$eventLines = [];
foreach ($events as $name) {
$furnieditorFile = $incomingFurnieditorDir . '/FurniEditor' . $name . 'Event.ts';
$stubFile = $incomingDir . '/FurniEditor' . $name . 'Event.ts';
if (! file_exists($furnieditorFile) && ! file_exists($stubFile)) {
file_put_contents($stubFile, "import { IMessageEvent } from '@nitrots/api';\nimport { MessageEvent } from '@nitrots/events';\nexport class FurniEditor{$name}Event extends MessageEvent implements IMessageEvent {\n constructor(callBack: Function) { super(callBack, (k: any) => k); }\n getParser() { return this.parser; }\n}\n");
$eventLines[] = "export * from './FurniEditor{$name}Event';";
}
}
if ($eventLines !== []) {
$eventContent = implode("\n", $eventLines) . "\n";
file_put_contents($incomingDir . '/index.ts', $eventContent);
}
Log::info('[Nitro] Added missing renderer stubs');
}
private function updateViteConfig(): void
{
$viteConfigPath = $this->clientPath . '/vite.config.mjs';
$content = file_exists($viteConfigPath) ? file_get_contents($viteConfigPath) : '';
if (str_contains($content, '@nitrots/nitro-renderer') && str_contains($content, '/var/www/nitro-renderer')) {
return;
}
$viteConfig = <<<'CONFIG'
import react from "@vitejs/plugin-react";
import { resolve } from "path";
import { defineConfig } from "vite";
const rendererPath = "/var/www/nitro-renderer";
export default defineConfig({
plugins: [react()],
server: {
fs: { allow: [resolve(__dirname), rendererPath] },
proxy: { "/api": { target: "http://localhost:3000", changeOrigin: true } },
},
resolve: {
alias: [
{ find: "@nitrots/nitro-renderer", replacement: resolve(rendererPath, "src") },
{ find: "@nitrots/api", replacement: resolve(rendererPath, "packages", "api", "src") },
{ find: "@nitrots/assets", replacement: resolve(rendererPath, "packages", "assets", "src") },
{ find: "@nitrots/avatar", replacement: resolve(rendererPath, "packages", "avatar", "src") },
{ find: "@nitrots/camera", replacement: resolve(rendererPath, "packages", "camera", "src") },
{ find: "@nitrots/communication", replacement: resolve(rendererPath, "packages", "communication", "src") },
{ find: "@nitrots/configuration", replacement: resolve(rendererPath, "packages", "configuration", "src") },
{ find: "@nitrots/events", replacement: resolve(rendererPath, "packages", "events", "src") },
{ find: "@nitrots/localization", replacement: resolve(rendererPath, "packages", "localization", "src") },
{ find: "@nitrots/room", replacement: resolve(rendererPath, "packages", "room", "src") },
{ find: "@nitrots/session", replacement: resolve(rendererPath, "packages", "session", "src") },
{ find: "@nitrots/sound", replacement: resolve(rendererPath, "packages", "sound", "src") },
{ find: "@nitrots/utils/src", replacement: resolve(rendererPath, "packages", "utils", "src") },
{ find: "@nitrots/utils", replacement: resolve(rendererPath, "packages", "utils", "src") },
{ find: "@", replacement: resolve(__dirname, "src") },
{ find: "~", replacement: resolve(__dirname, "node_modules") },
{ find: "pixi.js", replacement: resolve(rendererPath, "node_modules", "pixi.js") },
{ find: "pixi-filters", replacement: resolve(rendererPath, "node_modules", "pixi-filters") },
{ find: "howler", replacement: resolve(rendererPath, "node_modules", "howler") },
],
},
build: {
assetsInlineLimit: 102400,
chunkSizeWarningLimit: 200000,
rollupOptions: {
output: {
assetFileNames: "src/assets/[name]-[hash].[ext]",
manualChunks: (id) => {
if (id.includes("node_modules")) {
if (id.includes("@nitrots") || id.includes("nitro-renderer")) {
return "nitro-renderer";
}
return "vendor";
}
},
},
},
},
});
CONFIG;
file_put_contents($this->clientPath . '/vite.config.mjs', $viteConfig);
Process::timeout(5)->run('chmod 644 ' . escapeshellarg($this->clientPath . '/vite.config.mjs'));
Process::timeout(5)->run('chown www-data:www-data ' . escapeshellarg($this->clientPath . '/vite.config.mjs'));
Log::info('[Nitro] Updated vite.config.mjs');
}
private function createYarnLink(): void
{
$nitrotsDir = $this->clientPath . '/node_modules/@nitrots';
if (! is_dir($nitrotsDir)) {
mkdir($nitrotsDir, 0755, true);
}
Process::timeout(10)->run('rm -rf ' . escapeshellarg($nitrotsDir) . '/*');
Process::timeout(30)->run(
'cp -r ' . escapeshellarg($this->rendererPath . '/node_modules/@nitrots/*') . ' ' . escapeshellarg($nitrotsDir) . '/',
);
Process::timeout(10)->run('chown -R www-data:www-data ' . escapeshellarg($nitrotsDir));
Log::info('[Nitro] Copied @nitrots packages');
}
private function installDependencies(): void
{
Log::info('[Nitro] Cleaning renderer node_modules');
Process::timeout(30)->run('rm -rf ' . escapeshellarg($this->rendererPath . '/node_modules'));
Log::info('[Nitro] Installing renderer deps');
$rendererYarn = Process::timeout(300)->run(
'cd ' . escapeshellarg($this->rendererPath) . ' && yarn install --ignore-engines 2>&1',
);
if (! $rendererYarn->successful()) {
Log::warning('[Nitro] Yarn failed for renderer, trying npm');
Process::timeout(300)->run(
'cd ' . escapeshellarg($this->rendererPath) . ' && npm install --legacy-peer-deps 2>&1',
);
}
Process::timeout(10)->run('chown -R www-data:www-data ' . escapeshellarg($this->rendererPath));
Log::info('[Nitro] Cleaning client node_modules');
Process::timeout(30)->run('rm -rf ' . escapeshellarg($this->clientPath . '/node_modules'));
Log::info('[Nitro] Installing client deps');
$clientYarn = Process::timeout(300)->run(
'cd ' . escapeshellarg($this->clientPath) . ' && yarn install --ignore-engines 2>&1',
);
if (! $clientYarn->successful()) {
Log::warning('[Nitro] Yarn failed for client, trying npm');
Process::timeout(300)->run(
'cd ' . escapeshellarg($this->clientPath) . ' && npm install --legacy-peer-deps 2>&1',
);
}
$this->createYarnLink();
Process::timeout(10)->run('chown -R www-data:www-data ' . escapeshellarg($this->clientPath));
}
public function buildClient(): array
{
Log::info('[Nitro] Building client');
$yarnBuild = Process::timeout(600)->run(
'cd ' . escapeshellarg($this->clientPath) . ' && yarn build 2>&1',
);
if ($yarnBuild->successful()) {
Process::timeout(10)->run('chown -R www-data:www-data ' . escapeshellarg($this->clientPath));
Log::info('[Nitro] Build successful (yarn)');
return ['success' => true, 'method' => 'yarn'];
}
Log::warning('[Nitro] Yarn build failed, trying npm build');
$npmBuild = Process::timeout(600)->run(
'cd ' . escapeshellarg($this->clientPath) . ' && npm run build 2>&1',
);
if ($npmBuild->successful()) {
Process::timeout(10)->run('chown -R www-data:www-data ' . escapeshellarg($this->clientPath));
Log::info('[Nitro] Build successful (npm)');
return ['success' => true, 'method' => 'npm'];
}
$output = $yarnBuild->output() . $yarnBuild->errorOutput() . "\n" . $npmBuild->output() . $npmBuild->errorOutput();
Log::error('[Nitro] Build failed', ['output' => substr($output, 0, 500)]);
return [
'success' => false,
'error' => 'Build mislukt: ' . substr($output, 0, 300),
];
}
public function deployClient(): void
{
Log::info('[Nitro] Deploying client');
// Clear and copy
Process::timeout(30)->run('rm -rf ' . escapeshellarg($this->clientWebRoot) . '/*');
Process::timeout(60)->run(
'cp -r ' . escapeshellarg($this->clientPath . '/dist') . '/* ' .
escapeshellarg($this->clientWebRoot) . '/ 2>&1',
);
// Always add/update toolbar color CSS variables to root
$indexFile = $this->clientWebRoot . '/index.html';
if (file_exists($indexFile)) {
$content = file_get_contents($indexFile);
$cssVars = '';
// Replace existing CSS vars or add new
if (preg_match('/