clusev/app/Livewire/Versions/Index.php

232 lines
8.0 KiB
PHP

<?php
namespace App\Livewire\Versions;
use App\Models\Setting;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Version & releases page. Everything shown here is real: the installed version
* comes from config, the latest published release is the newest git TAG read from
* .git at runtime, and the release history is the project's own CHANGELOG.md parsed
* BY VERSION. There is no in-app updater (Clusev ships as a Docker image) and no
* fake release server — the update check honestly compares the installed version
* against the newest tag in the configured channel.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** Only these channels are ever offered to users — there is no 'dev' channel. */
private const CHANNELS = ['stable', 'beta'];
public ?string $lastChecked = null;
public ?string $updateStatus = null;
/** 'current' | 'update' | null — drives the banner tone. */
public ?string $updateState = null;
/**
* Honest update check: compare the installed version against the newest release
* tag visible in the channel. No external server, no stars, no CVE feed.
*/
public function checkUpdates(): void
{
$this->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<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
*/
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'));
}
}