diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 22:23:00 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 22:23:00 +0100 |
| commit | 4887abc5773b33ab1e213ed5f528dfebd242e2cd (patch) | |
| tree | 8eb2316783a21817cce422c3fb58ec953206dd9a /web/index.php | |
| parent | v4.3.1: render Windows through wgpu and keep the installer window open (diff) | |
| download | zagan-bet-master.tar.gz zagan-bet-master.zip | |
Adds web/latest.php: a plain-text feed of the newest version with each artifact's filename and SHA-256. The per-release generated installers are replaced by stable web/install.sh and web/install.ps1; both read the feed, pick the newest build, verify it, install per-user and add a launcher. Publishing a new app version no longer regenerates or versions the installers.
publish.sh drops the installer renderer and Python dependency. deploy.sh now also verifies latest.php against the local artifacts and that the served installer scripts match the repository.
Diffstat (limited to '')
| -rw-r--r-- | web/index.php | 308 |
1 files changed, 30 insertions, 278 deletions
diff --git a/web/index.php b/web/index.php index 77edf95..3f0ab92 100644 --- a/web/index.php +++ b/web/index.php | |||
| @@ -1,63 +1,34 @@ | |||
| 1 | <?php | 1 | <?php |
| 2 | declare(strict_types=1); | 2 | declare(strict_types=1); |
| 3 | 3 | ||
| 4 | require __DIR__ . '/releases.php'; | ||
| 5 | |||
| 4 | /** | 6 | /** |
| 5 | * zagan.bet download page. | 7 | * zagan.bet download page. |
| 6 | * | 8 | * |
| 7 | * Scans ./bin/{gui,client,server} and renders every build it finds, with its | 9 | * Lists every published app, terminal client and server, with version, size |
| 8 | * version, size and SHA-256. The GPL source link points at the public git | 10 | * and SHA-256. The installers are stable links that ask latest.php for the |
| 9 | * repository. No database, no JavaScript, no dependencies. | 11 | * newest build. The GPL source link points at the public git repository. No |
| 10 | * | 12 | * database, no JavaScript, no dependencies. |
| 11 | * Expected file names (anything else is ignored): | ||
| 12 | * | ||
| 13 | * bin/gui/zagan-bet-gui-<version>-<platform>-<arch>[.exe] | ||
| 14 | * bin/gui/zagan-bet-gui-<version>-<platform>-<arch>-install.sh | ||
| 15 | * bin/gui/zagan-bet-gui-<version>-<platform>-<arch>-install.ps1 | ||
| 16 | * bin/client/zagan-bet-client-<version>-<platform>-<arch>[.exe] | ||
| 17 | * bin/server/zagan-bet-server-<version>-<platform>-<arch>[.exe] | ||
| 18 | * | ||
| 19 | * <version> is MAJOR.MINOR.PATCH, <platform> is linux|windows|macos and | ||
| 20 | * <arch> is x86_64|i686|aarch64. Example: | ||
| 21 | * | ||
| 22 | * bin/gui/zagan-bet-gui-4.3.0-linux-x86_64 | ||
| 23 | * bin/client/zagan-bet-client-4.3.0-linux-x86_64 | ||
| 24 | * | ||
| 25 | * SHA-256 values are cached in a JSON file keyed on size and mtime, so a file | ||
| 26 | * is only re-read when it changes. The cache lives in the system temp | ||
| 27 | * directory by default (the web root is often not writable by the PHP user); | ||
| 28 | * set ZAGAN_HASH_CACHE to put it somewhere else. | ||
| 29 | */ | 13 | */ |
| 30 | 14 | ||
| 31 | const KINDS = [ | 15 | $scan = scan_releases(); |
| 32 | 'gui' => [ | 16 | $releases = $scan['releases']; |
| 33 | 'title' => 'Desktop app', | 17 | $newest = $scan['newest_mtime']; |
| 34 | 'hint' => 'Windows 10 or newer, or a Linux desktop. No terminal needed.', | ||
| 35 | ], | ||
| 36 | 'client' => [ | ||
| 37 | 'title' => 'Client', | ||
| 38 | 'hint' => 'Needs a terminal of at least 80×24 cells.', | ||
| 39 | ], | ||
| 40 | 'server' => [ | ||
| 41 | 'title' => 'Server', | ||
| 42 | 'hint' => 'Generates its own TLS identity on first run.', | ||
| 43 | ], | ||
| 44 | ]; | ||
| 45 | 18 | ||
| 46 | const PLATFORMS = [ | 19 | $base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/')), '/'); |
| 47 | 'linux' => 'Linux', | 20 | if ($base === '.' || $base === '/') { |
| 48 | 'windows' => 'Windows', | 21 | $base = ''; |
| 49 | 'macos' => 'macOS', | 22 | } |
| 50 | ]; | ||
| 51 | 23 | ||
| 52 | const ARCHES = [ | 24 | foreach ($releases as $kind => &$found) { |
| 53 | 'x86_64' => '64-bit', | 25 | foreach ($found as &$release) { |
| 54 | 'i686' => '32-bit', | 26 | $release['href'] = $base . '/bin/' . $kind . '/' . rawurlencode($release['file']); |
| 55 | 'aarch64' => 'ARM64', | 27 | } |
| 56 | ]; | 28 | } |
| 29 | unset($found, $release); | ||
| 57 | 30 | ||
| 58 | // Newest version first, then 64-bit builds before 32-bit. | 31 | $all = array_merge($releases['gui'], $releases['client'], $releases['server']); |
| 59 | const PLATFORM_ORDER = ['linux' => 0, 'windows' => 1, 'macos' => 2]; | ||
| 60 | const ARCH_ORDER = ['x86_64' => 0, 'aarch64' => 1, 'i686' => 2]; | ||
| 61 | 32 | ||
| 62 | function e(?string $value): string | 33 | function e(?string $value): string |
| 63 | { | 34 | { |
| @@ -74,220 +45,6 @@ function human_size(int $bytes): string | |||
| 74 | } | 45 | } |
| 75 | return $bytes . ' B'; | 46 | return $bytes . ' B'; |
| 76 | } | 47 | } |
| 77 | |||
| 78 | /** | ||
| 79 | * Hash to use for a published file, reusing the cached value when the file has | ||
| 80 | * not changed since it was hashed. | ||
| 81 | * | ||
| 82 | * A cached hash is valid only when the size and mtime still match and the hash | ||
| 83 | * was taken at or after the file's mtime, so a restored or touched file is | ||
| 84 | * re-read. | ||
| 85 | * | ||
| 86 | * @param array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} $cache | ||
| 87 | */ | ||
| 88 | function cached_hash( | ||
| 89 | string $key, | ||
| 90 | string $path, | ||
| 91 | int $size, | ||
| 92 | int $mtime, | ||
| 93 | array &$cache, | ||
| 94 | bool &$dirty | ||
| 95 | ): ?string { | ||
| 96 | $entry = $cache['files'][$key] ?? null; | ||
| 97 | if ( | ||
| 98 | is_array($entry) | ||
| 99 | && ($entry['size'] ?? null) === $size | ||
| 100 | && ($entry['mtime'] ?? null) === $mtime | ||
| 101 | && (int) ($entry['hashed_at'] ?? 0) >= $mtime | ||
| 102 | && isset($entry['sha256']) | ||
| 103 | && is_string($entry['sha256']) | ||
| 104 | && preg_match('/\A[0-9a-f]{64}\z/', $entry['sha256']) === 1 | ||
| 105 | ) { | ||
| 106 | return $entry['sha256']; | ||
| 107 | } | ||
| 108 | |||
| 109 | $hash = hash_file('sha256', $path); | ||
| 110 | if ($hash === false) { | ||
| 111 | return null; | ||
| 112 | } | ||
| 113 | |||
| 114 | $cache['files'][$key] = [ | ||
| 115 | 'size' => $size, | ||
| 116 | 'mtime' => $mtime, | ||
| 117 | 'sha256' => $hash, | ||
| 118 | 'hashed_at' => time(), | ||
| 119 | ]; | ||
| 120 | $dirty = true; | ||
| 121 | |||
| 122 | return $hash; | ||
| 123 | } | ||
| 124 | |||
| 125 | /** @return array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} */ | ||
| 126 | function load_hash_cache(string $path): array | ||
| 127 | { | ||
| 128 | $raw = @file_get_contents($path); | ||
| 129 | if ($raw === false || $raw === '') { | ||
| 130 | return ['files' => []]; | ||
| 131 | } | ||
| 132 | $decoded = json_decode($raw, true); | ||
| 133 | if (!is_array($decoded) || !isset($decoded['files']) || !is_array($decoded['files'])) { | ||
| 134 | return ['files' => []]; | ||
| 135 | } | ||
| 136 | |||
| 137 | return ['files' => $decoded['files']]; | ||
| 138 | } | ||
| 139 | |||
| 140 | /** | ||
| 141 | * Best effort: a read-only web root, a full disk or a racing writer must never | ||
| 142 | * break the page, so failures are ignored and the next request recomputes. | ||
| 143 | * | ||
| 144 | * @param array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} $cache | ||
| 145 | */ | ||
| 146 | function save_hash_cache(string $path, array $cache): void | ||
| 147 | { | ||
| 148 | $json = json_encode($cache, JSON_UNESCAPED_SLASHES); | ||
| 149 | if ($json === false) { | ||
| 150 | return; | ||
| 151 | } | ||
| 152 | $directory = dirname($path); | ||
| 153 | $temporary = @tempnam($directory, '.zagan-hashes-'); | ||
| 154 | if ($temporary === false) { | ||
| 155 | return; | ||
| 156 | } | ||
| 157 | if (@file_put_contents($temporary, $json, LOCK_EX) === false || !@rename($temporary, $path)) { | ||
| 158 | @unlink($temporary); | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | /** | ||
| 163 | * @return array{version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null | ||
| 164 | */ | ||
| 165 | function read_release( | ||
| 166 | string $kind, | ||
| 167 | string $directory, | ||
| 168 | string $entry, | ||
| 169 | array &$cache, | ||
| 170 | bool &$dirty | ||
| 171 | ): ?array { | ||
| 172 | $pattern = '/^zagan-bet-' . preg_quote($kind, '/') | ||
| 173 | . '-v?(\d+\.\d+\.\d+)-([a-z]+)-([a-z0-9_]+)(-install\.sh|-install\.ps1|\.exe)?$/i'; | ||
| 174 | if (preg_match($pattern, $entry, $matches) !== 1) { | ||
| 175 | return null; | ||
| 176 | } | ||
| 177 | |||
| 178 | $realDirectory = realpath($directory); | ||
| 179 | $real = realpath($directory . '/' . $entry); | ||
| 180 | if ($realDirectory === false || $real === false) { | ||
| 181 | return null; | ||
| 182 | } | ||
| 183 | // Never serve anything that resolves outside the published directory. | ||
| 184 | if (!str_starts_with($real, $realDirectory . DIRECTORY_SEPARATOR)) { | ||
| 185 | return null; | ||
| 186 | } | ||
| 187 | if (!is_file($real)) { | ||
| 188 | return null; | ||
| 189 | } | ||
| 190 | |||
| 191 | $size = filesize($real); | ||
| 192 | $mtime = filemtime($real); | ||
| 193 | if ($size === false || $mtime === false) { | ||
| 194 | return null; | ||
| 195 | } | ||
| 196 | |||
| 197 | $hash = cached_hash($kind . '/' . $entry, $real, $size, $mtime, $cache, $dirty); | ||
| 198 | if ($hash === null) { | ||
| 199 | return null; | ||
| 200 | } | ||
| 201 | |||
| 202 | $suffix = strtolower($matches[4] ?? ''); | ||
| 203 | $variant = str_starts_with($suffix, '-install.') ? 'installer' : 'binary'; | ||
| 204 | |||
| 205 | return [ | ||
| 206 | 'version' => $matches[1], | ||
| 207 | 'platform' => strtolower($matches[2]), | ||
| 208 | 'arch' => strtolower($matches[3]), | ||
| 209 | 'variant' => $variant, | ||
| 210 | 'file' => $entry, | ||
| 211 | 'size' => $size, | ||
| 212 | 'mtime' => $mtime, | ||
| 213 | 'sha256' => $hash, | ||
| 214 | ]; | ||
| 215 | } | ||
| 216 | |||
| 217 | $base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/')), '/'); | ||
| 218 | if ($base === '.' || $base === '/') { | ||
| 219 | $base = ''; | ||
| 220 | } | ||
| 221 | |||
| 222 | $cachePath = getenv('ZAGAN_HASH_CACHE'); | ||
| 223 | if (!is_string($cachePath) || $cachePath === '') { | ||
| 224 | $cachePath = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) | ||
| 225 | . DIRECTORY_SEPARATOR . 'zagan-bet-hashes-' | ||
| 226 | . substr(hash('sha256', __DIR__), 0, 16) . '.json'; | ||
| 227 | } | ||
| 228 | $cache = load_hash_cache($cachePath); | ||
| 229 | $cacheDirty = false; | ||
| 230 | $seen = []; | ||
| 231 | |||
| 232 | $releases = []; | ||
| 233 | $newest = 0; | ||
| 234 | |||
| 235 | foreach (KINDS as $kind => $meta) { | ||
| 236 | $found = []; | ||
| 237 | $directory = __DIR__ . '/bin/' . $kind; | ||
| 238 | |||
| 239 | if (is_dir($directory)) { | ||
| 240 | $entries = scandir($directory); | ||
| 241 | if ($entries !== false) { | ||
| 242 | foreach ($entries as $entry) { | ||
| 243 | if ($entry === '.' || $entry === '..') { | ||
| 244 | continue; | ||
| 245 | } | ||
| 246 | $release = read_release($kind, $directory, $entry, $cache, $cacheDirty); | ||
| 247 | if ($release === null) { | ||
| 248 | continue; | ||
| 249 | } | ||
| 250 | $seen[$kind . '/' . $entry] = true; | ||
| 251 | $release['href'] = $base . '/bin/' . $kind . '/' . rawurlencode($release['file']); | ||
| 252 | $found[] = $release; | ||
| 253 | $newest = max($newest, $release['mtime']); | ||
| 254 | } | ||
| 255 | } | ||
| 256 | } | ||
| 257 | |||
| 258 | usort($found, static function (array $a, array $b): int { | ||
| 259 | $byVersion = version_compare($b['version'], $a['version']); | ||
| 260 | if ($byVersion !== 0) { | ||
| 261 | return $byVersion; | ||
| 262 | } | ||
| 263 | $byPlatform = (PLATFORM_ORDER[$a['platform']] ?? 9) <=> (PLATFORM_ORDER[$b['platform']] ?? 9); | ||
| 264 | if ($byPlatform !== 0) { | ||
| 265 | return $byPlatform; | ||
| 266 | } | ||
| 267 | $byArch = (ARCH_ORDER[$a['arch']] ?? 9) <=> (ARCH_ORDER[$b['arch']] ?? 9); | ||
| 268 | if ($byArch !== 0) { | ||
| 269 | return $byArch; | ||
| 270 | } | ||
| 271 | $byVariant = ($a['variant'] === 'installer' ? 1 : 0) <=> ($b['variant'] === 'installer' ? 1 : 0); | ||
| 272 | if ($byVariant !== 0) { | ||
| 273 | return $byVariant; | ||
| 274 | } | ||
| 275 | return strcmp($a['file'], $b['file']); | ||
| 276 | }); | ||
| 277 | |||
| 278 | $releases[$kind] = $found; | ||
| 279 | } | ||
| 280 | |||
| 281 | // Drop cache entries for files that are no longer published. | ||
| 282 | foreach (array_keys($cache['files']) as $key) { | ||
| 283 | if (!isset($seen[$key])) { | ||
| 284 | unset($cache['files'][$key]); | ||
| 285 | $cacheDirty = true; | ||
| 286 | } | ||
| 287 | } | ||
| 288 | if ($cacheDirty) { | ||
| 289 | save_hash_cache($cachePath, $cache); | ||
| 290 | } | ||
| 291 | ?> | 48 | ?> |
| 292 | <!doctype html> | 49 | <!doctype html> |
| 293 | <html lang="en"> | 50 | <html lang="en"> |
| @@ -308,24 +65,20 @@ if ($cacheDirty) { | |||
| 308 | 65 | ||
| 309 | <h2>Desktop app</h2> | 66 | <h2>Desktop app</h2> |
| 310 | <p>Windows 10 or newer, or a Linux desktop. No terminal needed.</p> | 67 | <p>Windows 10 or newer, or a Linux desktop. No terminal needed.</p> |
| 311 | <?php $guiBinaries = array_values(array_filter($releases['gui'], static fn (array $r): bool => $r['variant'] === 'binary')); ?> | 68 | <?php if ($releases['gui'] === []): ?> |
| 312 | <?php $guiInstallers = array_values(array_filter($releases['gui'], static fn (array $r): bool => $r['variant'] === 'installer')); ?> | ||
| 313 | <?php if ($guiBinaries === []): ?> | ||
| 314 | <p>Nothing published yet.</p> | 69 | <p>Nothing published yet.</p> |
| 315 | <?php else: ?> | 70 | <?php else: ?> |
| 316 | <ul> | 71 | <ul> |
| 317 | <?php foreach ($guiBinaries as $release): ?> | 72 | <?php foreach ($releases['gui'] as $release): ?> |
| 318 | <li><a href="<?= e($release['href']) ?>" download><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?> <?= e($release['arch']) ?><?php if (isset(ARCHES[$release['arch']])): ?> (<?= e(ARCHES[$release['arch']]) ?>)<?php endif; ?> v<?= e($release['version']) ?> — <?= e(human_size($release['size'])) ?></a></li> | 73 | <li><a href="<?= e($release['href']) ?>" download><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?> <?= e($release['arch']) ?><?php if (isset(ARCHES[$release['arch']])): ?> (<?= e(ARCHES[$release['arch']]) ?>)<?php endif; ?> v<?= e($release['version']) ?> — <?= e(human_size($release['size'])) ?></a></li> |
| 319 | <?php endforeach; ?> | 74 | <?php endforeach; ?> |
| 320 | </ul> | 75 | </ul> |
| 321 | <?php endif; ?> | 76 | |
| 322 | <?php if ($guiInstallers !== []): ?> | ||
| 323 | <h3>Installers</h3> | 77 | <h3>Installers</h3> |
| 324 | <p>The installer checks the download, installs Zagan-Bet for your user and adds it to your programs. Run it next to a downloaded app, or on its own to fetch one.</p> | 78 | <p>Downloads the newest build, checks its SHA-256 and installs it for your user.</p> |
| 325 | <ul> | 79 | <ul> |
| 326 | <?php foreach ($guiInstallers as $release): ?> | 80 | <li><a href="<?= e($base) ?>/install.sh" download>Linux installer (bash)</a></li> |
| 327 | <li><a href="<?= e($release['href']) ?>" download><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?> <?= e($release['arch']) ?> installer (<?= str_ends_with($release['file'], '.ps1') ? 'PowerShell' : 'bash' ?>) v<?= e($release['version']) ?> — <?= e(human_size($release['size'])) ?></a></li> | 81 | <li><a href="<?= e($base) ?>/install.ps1" download>Windows installer (PowerShell)</a></li> |
| 328 | <?php endforeach; ?> | ||
| 329 | </ul> | 82 | </ul> |
| 330 | <?php endif; ?> | 83 | <?php endif; ?> |
| 331 | 84 | ||
| @@ -353,7 +106,6 @@ if ($cacheDirty) { | |||
| 353 | </ul> | 106 | </ul> |
| 354 | <?php endif; ?> | 107 | <?php endif; ?> |
| 355 | 108 | ||
| 356 | <?php $all = array_merge($releases['gui'], $releases['client'], $releases['server']); ?> | ||
| 357 | <?php if ($all !== []): ?> | 109 | <?php if ($all !== []): ?> |
| 358 | <h2>SHA-256</h2> | 110 | <h2>SHA-256</h2> |
| 359 | <p>On Linux use <code>sha256sum <file></code>. On Windows use <code>Get-FileHash <file></code>.</p> | 111 | <p>On Linux use <code>sha256sum <file></code>. On Windows use <code>Get-FileHash <file></code>.</p> |
| @@ -372,11 +124,11 @@ echo e($sums); | |||
| 372 | 124 | ||
| 373 | <h2>Running it</h2> | 125 | <h2>Running it</h2> |
| 374 | <p>Desktop app on Linux:</p> | 126 | <p>Desktop app on Linux:</p> |
| 375 | <pre><code>chmod +x zagan-bet-gui-*-linux-x86_64-install.sh | 127 | <pre><code>chmod +x install.sh |
| 376 | ./zagan-bet-gui-*-linux-x86_64-install.sh</code></pre> | 128 | ./install.sh</code></pre> |
| 377 | <p>Desktop app on Windows (PowerShell):</p> | 129 | <p>Desktop app on Windows (PowerShell):</p> |
| 378 | <pre><code>powershell -ExecutionPolicy Bypass -File .\zagan-bet-gui-*-windows-x86_64-install.ps1</code></pre> | 130 | <pre><code>powershell -ExecutionPolicy Bypass -File .\install.ps1</code></pre> |
| 379 | <p>The installers verify the SHA-256 before installing. You can also run the downloaded app directly.</p> | 131 | <p>The installers fetch the newest build, verify its SHA-256, install it for your user and add a launcher.</p> |
| 380 | <p>Terminal client:</p> | 132 | <p>Terminal client:</p> |
| 381 | <pre><code>chmod +x <downloaded-client> | 133 | <pre><code>chmod +x <downloaded-client> |
| 382 | ./<downloaded-client></code></pre> | 134 | ./<downloaded-client></code></pre> |
