234 lines
8.0 KiB
PHP
234 lines
8.0 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Versions;
|
|
|
|
use App\Models\Setting;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\Title;
|
|
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')]
|
|
#[Title('Version — Clusev')]
|
|
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 = 'Noch kein Release getaggt — installierte Version v'.$installed.' (Kanal: '.$channel.').';
|
|
} elseif ($this->isNewer($latest, $installed)) {
|
|
$this->updateState = 'update';
|
|
$this->updateStatus = 'Update verfügbar: v'.$latest.' (Kanal: '.$channel.').';
|
|
} else {
|
|
$this->updateState = 'current';
|
|
$this->updateStatus = 'Aktuell — v'.$installed.' (Kanal: '.$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,
|
|
]);
|
|
}
|
|
}
|