lastChecked = now()->format('H:i'); $channel = $this->channel(); $installed = (string) config('clusev.version'); $latest = $this->latestTag(); if ($latest === null) { $this->updateState = 'current'; $this->updateStatus = __('versions.status_none_tagged', ['installed' => $installed, 'channel' => $channel]); } elseif ($this->isNewer($latest, $installed)) { $this->updateState = 'update'; $this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]); } else { $this->updateState = 'current'; $this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]); } } /** Resolve the configured channel, clamped to the user-facing set. */ private function channel(): string { $channel = (string) (Setting::get('release_channel', config('clusev.channel')) ?? 'stable'); return in_array($channel, self::CHANNELS, true) ? $channel : 'stable'; } /** * Newest release tag from .git, without needing the git binary. Reads loose * refs under refs/tags plus packed-refs, normalises a leading "v", and sorts * by semantic version. Returns null when nothing is tagged yet. */ private function latestTag(): ?string { $root = base_path('.git'); $tags = []; $dir = $root.'/refs/tags'; if (is_dir($dir)) { foreach (scandir($dir) ?: [] as $entry) { if ($entry !== '.' && $entry !== '..' && is_file($dir.'/'.$entry)) { $tags[] = $entry; } } } foreach (preg_split('/\R/', (string) @file_get_contents($root.'/packed-refs')) as $line) { if (preg_match('#\srefs/tags/(\S+)$#', trim($line), $m)) { $tags[] = $m[1]; } } // Keep only vX.Y.Z / X.Y.Z style tags, normalise off the leading "v". $versions = []; foreach (array_unique($tags) as $tag) { $v = ltrim($tag, 'vV'); if (preg_match('/^\d+\.\d+\.\d+/', $v)) { $versions[] = $v; } } if ($versions === []) { return null; } usort($versions, fn (string $a, string $b): int => version_compare($b, $a)); return $versions[0]; } /** Is the given tag a newer semantic version than the installed one? */ private function isNewer(string $tag, string $installed): bool { return version_compare(ltrim($tag, 'vV'), ltrim($installed, 'vV'), '>'); } /** Resolve the current commit from .git without needing the git binary. */ private function build(): array { $root = base_path('.git'); $head = @file_get_contents($root.'/HEAD'); if ($head === false) { return ['sha' => null, 'branch' => $this->channel()]; } $head = trim($head); if (! str_starts_with($head, 'ref: ')) { return ['sha' => substr($head, 0, 7), 'branch' => $this->channel()]; } $ref = substr($head, 5); $branch = preg_replace('#^refs/heads/#', '', $ref); $sha = @file_get_contents($root.'/'.$ref); if ($sha === false) { // packed-refs fallback foreach (preg_split('/\R/', (string) @file_get_contents($root.'/packed-refs')) as $line) { if (str_ends_with(trim($line), ' '.$ref)) { $sha = explode(' ', trim($line))[0]; break; } } } return ['sha' => $sha ? substr(trim($sha), 0, 7) : null, 'branch' => $branch]; } /** * Parse CHANGELOG.md BY VERSION into release nodes. Each node carries its * version, optional date, and the changes grouped by Keep-a-Changelog * subsection (Hinzugefügt / Geändert / Behoben / Sicherheit / …), newest * first (Unreleased on top). * * @return array>}> */ private function releases(): array { $path = base_path('CHANGELOG.md'); if (! is_file($path)) { return []; } $releases = []; $current = null; $group = null; foreach (preg_split('/\R/', (string) file_get_contents($path)) as $line) { // Version header: "## [0.1.0] - 2026-06-13" or "## [Unreleased]" if (preg_match('/^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/', $line, $m)) { if ($current !== null) { $releases[] = $current; } $version = trim($m[1]); $unreleased = strcasecmp($version, 'Unreleased') === 0; $current = [ 'version' => $version, 'date' => isset($m[2]) ? trim($m[2]) : null, 'unreleased' => $unreleased, 'groups' => [], ]; $group = null; continue; } if ($current === null) { continue; } // Subsection header: "### Hinzugefügt" if (preg_match('/^###\s+(.+?)\s*$/', $line, $m)) { $group = trim($m[1]); $current['groups'][$group] ??= []; continue; } // Bullet line under the active subsection (handles wrapped continuations). if ($group !== null && preg_match('/^\s*-\s+(.+?)\s*$/', $line, $m)) { $current['groups'][$group][] = trim($m[1]); continue; } if ($group !== null && $current['groups'][$group] !== [] && preg_match('/^\s{2,}(\S.*)$/', $line, $m)) { // Continuation of the previous bullet (Keep-a-Changelog wrapping). $last = array_key_last($current['groups'][$group]); $current['groups'][$group][$last] .= ' '.trim($m[1]); } } if ($current !== null) { $releases[] = $current; } // Drop empty nodes (e.g. a bare Unreleased) and link-reference tails. return array_values(array_filter($releases, fn (array $r): bool => $r['groups'] !== [])); } public function render() { $releases = $this->releases(); $latestTag = $this->latestTag(); return view('livewire.versions.index', [ 'version' => config('clusev.version'), 'channel' => $this->channel(), 'repository' => config('clusev.repository'), 'license' => config('clusev.license'), 'build' => $this->build(), 'releases' => $releases, 'latestTag' => $latestTag, ])->title(__('versions.title')); } }