summaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--web/README.md38
-rw-r--r--web/index.php308
-rw-r--r--web/install.ps1 (renamed from scripts/installers/windows.ps1.in)45
-rw-r--r--web/install.sh (renamed from scripts/installers/linux.sh.in)50
-rw-r--r--web/latest.php50
-rw-r--r--web/releases.php262
6 files changed, 430 insertions, 323 deletions
diff --git a/web/README.md b/web/README.md
index 1ee794f..13f5f85 100644
--- a/web/README.md
+++ b/web/README.md
@@ -8,10 +8,14 @@ browser defaults: black text, blue links, monospace checksums. PHP 8.0+.
8 8
9``` 9```
10web/ 10web/
11 index.php 11 index.php the download page
12 bin/gui/ served at /bin/gui/... (app and installers) 12 latest.php newest-version feed for the installers
13 bin/client/ served at /bin/client/... 13 releases.php shared scanner and hash cache
14 bin/server/ served at /bin/server/... 14 install.sh stable Linux installer
15 install.ps1 stable Windows installer
16 bin/gui/ apps, served at /bin/gui/...
17 bin/client/ terminal clients
18 bin/server/ servers
15``` 19```
16 20
17Serve `web/` as the document root. Binaries in `bin/` are gitignored. 21Serve `web/` as the document root. Binaries in `bin/` are gitignored.
@@ -23,8 +27,6 @@ those directories is ignored.
23 27
24``` 28```
25bin/gui/zagan-bet-gui-<version>-<platform>-<arch>[.exe] 29bin/gui/zagan-bet-gui-<version>-<platform>-<arch>[.exe]
26bin/gui/zagan-bet-gui-<version>-<platform>-<arch>-install.sh
27bin/gui/zagan-bet-gui-<version>-<platform>-<arch>-install.ps1
28bin/client/zagan-bet-client-<version>-<platform>-<arch>[.exe] 30bin/client/zagan-bet-client-<version>-<platform>-<arch>[.exe]
29bin/server/zagan-bet-server-<version>-<platform>-<arch>[.exe] 31bin/server/zagan-bet-server-<version>-<platform>-<arch>[.exe]
30``` 32```
@@ -32,12 +34,14 @@ bin/server/zagan-bet-server-<version>-<platform>-<arch>[.exe]
32`<version>` is `MAJOR.MINOR.PATCH`, `<platform>` is `linux`, `windows` or 34`<version>` is `MAJOR.MINOR.PATCH`, `<platform>` is `linux`, `windows` or
33`macos`, `<arch>` is `x86_64`, `i686` or `aarch64`. 35`macos`, `<arch>` is `x86_64`, `i686` or `aarch64`.
34 36
35The installers are generated next to their app. They embed the app's SHA-256, 37The installers are not versioned. They read `latest.php`, pick the newest build
36check the file (or download it) and install it for the current user: Linux 38for the machine, verify its SHA-256 and install it for the current user: Linux
37writes `~/.local/bin/zagan-bet-gui` plus a `.desktop` launcher, Windows writes 39writes `~/.local/bin/zagan-bet-gui` plus a `.desktop` launcher, Windows writes
38`%LOCALAPPDATA%\Programs\Zagan-Bet` plus a Start-menu shortcut. The Windows 40`%LOCALAPPDATA%\Programs\Zagan-Bet` plus a Start-menu shortcut. Updating the
39installer waits for Enter before closing (`-NoPause` for automation). The page 41site only needs `publish.sh`; the installer links never change. Set
40links the public git repository for the GPL corresponding source. 42`ZAGAN_SITE` (Linux) or `-Site` (Windows) to test against a staging site. The
43Windows installer waits for Enter before closing (`-NoPause` for automation).
44The page links the public git repository for the GPL corresponding source.
41 45
42## Publishing a build 46## Publishing a build
43 47
@@ -49,19 +53,17 @@ From the repository root:
49 53
50It reads the version from `Cargo.toml`, builds every artifact and installs 54It reads the version from `Cargo.toml`, builds every artifact and installs
51them under `web/bin` with the names above. The GUI is built with the 55them under `web/bin` with the names above. The GUI is built with the
52workspace's `gui-release` profile (LTO, stripped) and the installers are 56workspace's `gui-release` profile (LTO, stripped). Run it from a clean tree so
53rendered with Python 3 and the published SHA-256. Run it from a clean tree so
54the release commit is the source the page links to. Override the destination 57the release commit is the source the page links to. Override the destination
55with `DEST=/srv/www/bin ./scripts/publish.sh`, and the download origin baked 58with `DEST=/srv/www/bin ./scripts/publish.sh`; `ALLOW_DIRTY=1` skips the
56into the installers with `URL_BASE=https://staging.example/ ./scripts/publish.sh`. 59clean-tree check for a preview. The installers are static and need no
57`ALLOW_DIRTY=1` skips the clean-tree check for a preview. 60regeneration.
58 61
59Builds are per target triple, so these must be installed: 62Builds are per target triple, so these must be installed:
60`x86_64-unknown-linux-gnu`, `i686-unknown-linux-gnu` and 63`x86_64-unknown-linux-gnu`, `i686-unknown-linux-gnu` and
61`x86_64-pc-windows-gnu`, plus the mingw linker for the Windows build. Client 64`x86_64-pc-windows-gnu`, plus the mingw linker for the Windows build. Client
62builds always pass `--no-default-features`, which drops the server's OpenSSL 65builds always pass `--no-default-features`, which drops the server's OpenSSL
63dependency; the client is rustls-only. The GUI needs Python 3 at publish time 66dependency; the client is rustls-only.
64for the installer templates in `scripts/installers/`.
65 67
66Cargo itself cannot produce these names: it always writes 68Cargo itself cannot produce these names: it always writes
67`target/<triple>/release/<bin>`. The script renames after each build. 69`target/<triple>/release/<bin>`. The script renames after each build.
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
2declare(strict_types=1); 2declare(strict_types=1);
3 3
4require __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
31const 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
46const PLATFORMS = [ 19$base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/')), '/');
47 'linux' => 'Linux', 20if ($base === '.' || $base === '/') {
48 'windows' => 'Windows', 21 $base = '';
49 'macos' => 'macOS', 22}
50];
51 23
52const ARCHES = [ 24foreach ($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}
29unset($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']);
59const PLATFORM_ORDER = ['linux' => 0, 'windows' => 1, 'macos' => 2];
60const ARCH_ORDER = ['x86_64' => 0, 'aarch64' => 1, 'i686' => 2];
61 32
62function e(?string $value): string 33function 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 */
88function 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}>} */
126function 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 */
146function 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 */
165function 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'] ?? '/')), '/');
218if ($base === '.' || $base === '/') {
219 $base = '';
220}
221
222$cachePath = getenv('ZAGAN_HASH_CACHE');
223if (!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
235foreach (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.
282foreach (array_keys($cache['files']) as $key) {
283 if (!isset($seen[$key])) {
284 unset($cache['files'][$key]);
285 $cacheDirty = true;
286 }
287}
288if ($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 &lt;file&gt;</code>. On Windows use <code>Get-FileHash &lt;file&gt;</code>.</p> 111<p>On Linux use <code>sha256sum &lt;file&gt;</code>. On Windows use <code>Get-FileHash &lt;file&gt;</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 &lt;downloaded-client&gt; 133<pre><code>chmod +x &lt;downloaded-client&gt;
382./&lt;downloaded-client&gt;</code></pre> 134./&lt;downloaded-client&gt;</code></pre>
diff --git a/scripts/installers/windows.ps1.in b/web/install.ps1
index 4b8e61d..92eef83 100644
--- a/scripts/installers/windows.ps1.in
+++ b/web/install.ps1
@@ -1,21 +1,18 @@
1# Zagan-Bet desktop installer for Windows. 1# Zagan-Bet installer for Windows.
2# 2#
3# Verifies the app against its published SHA-256, installs it for the current 3# Asks the site which build is newest, verifies its SHA-256, installs it for
4# user and adds a Start-menu shortcut. Put the downloaded app next to this 4# the current user and adds a Start-menu shortcut. Point it at a staging site
5# script, or run the script on its own and it will fetch the matching binary. 5# with -Site http://127.0.0.1:8899; add -NoPause for automation.
6# 6#
7# Run it with: 7# Run it with:
8# powershell -ExecutionPolicy Bypass -File .\zagan-bet-gui-*-install.ps1 8# powershell -ExecutionPolicy Bypass -File .\install.ps1
9# Add -NoPause to run it unattended. 9param(
10param([switch]$NoPause) 10 [string]$Site = "https://zagan.2137697.xyz",
11 [switch]$NoPause
12)
11 13
12$ErrorActionPreference = "Stop" 14$ErrorActionPreference = "Stop"
13 15
14$Version = "__VERSION__"
15$Binary = "__BINARY__"
16$Sha256 = "__SHA256__"
17$Url = "__URL__"
18
19function Finish([int]$Code) { 16function Finish([int]$Code) {
20 if (-not $NoPause) { 17 if (-not $NoPause) {
21 Read-Host "Press Enter to close" | Out-Null 18 Read-Host "Press Enter to close" | Out-Null
@@ -28,6 +25,30 @@ function Stop-Install([string]$Message) {
28 Finish 1 25 Finish 1
29} 26}
30 27
28try {
29 $latest = (Invoke-WebRequest -Uri "$Site/latest.php" -UseBasicParsing).Content
30} catch {
31 Stop-Install "could not read $Site/latest.php: $_"
32}
33
34$line = ($latest -split "`r?`n") |
35 Where-Object { $_ -match '^gui windows x86_64 ' } |
36 Select-Object -First 1
37if (-not $line) {
38 Stop-Install "no Windows build is published yet"
39}
40
41$parts = $line.Trim() -split '\s+'
42if ($parts.Count -lt 6) {
43 Stop-Install "the latest-build file is malformed"
44}
45$Version = $parts[3]
46$Binary = $parts[4]
47$Sha256 = $parts[5]
48$Url = "$Site/bin/gui/$Binary"
49
50Write-Host "Newest Zagan-Bet is $Version."
51
31$here = Split-Path -Parent $MyInvocation.MyCommand.Path 52$here = Split-Path -Parent $MyInvocation.MyCommand.Path
32$candidates = @( 53$candidates = @(
33 (Join-Path $here $Binary), 54 (Join-Path $here $Binary),
diff --git a/scripts/installers/linux.sh.in b/web/install.sh
index 5b53c02..998b262 100644
--- a/scripts/installers/linux.sh.in
+++ b/web/install.sh
@@ -1,24 +1,38 @@
1#!/usr/bin/env bash 1#!/usr/bin/env bash
2# Zagan-Bet desktop installer for Linux. 2# Zagan-Bet installer for Linux.
3# 3#
4# Verifies the app against its published SHA-256, installs it for the current 4# Asks the site which build is newest, verifies its SHA-256, installs it for
5# user and adds a launcher. Put the downloaded app next to this script, or run 5# the current user and adds a launcher. Point it at a staging site with
6# the script on its own and it will fetch the matching binary. 6# ZAGAN_SITE=http://127.0.0.1:8899.
7set -euo pipefail 7set -euo pipefail
8 8
9VERSION="__VERSION__" 9SITE=${ZAGAN_SITE:-https://zagan.2137697.xyz}
10BINARY="__BINARY__" 10PLATFORM=linux
11SHA256="__SHA256__" 11ARCH=x86_64
12URL="__URL__"
13 12
14say() { printf '%s\n' "$*"; } 13say() { printf '%s\n' "$*"; }
15die() { printf 'install: %s\n' "$*" >&2; exit 1; } 14die() { printf 'install: %s\n' "$*" >&2; exit 1; }
16 15
16command -v curl >/dev/null 2>&1 || die "curl is required"
17command -v sha256sum >/dev/null 2>&1 || die "sha256sum is required" 17command -v sha256sum >/dev/null 2>&1 || die "sha256sum is required"
18 18
19latest=$(curl -fsSL --max-time 30 "$SITE/latest.php") \
20 || die "could not read $SITE/latest.php"
21line=$(printf '%s\n' "$latest" \
22 | awk -v p="$PLATFORM" -v a="$ARCH" '$1 == "gui" && $2 == p && $3 == a { print; exit }')
23[ -n "$line" ] || die "no Linux build is published yet"
24
25version=$(printf '%s\n' "$line" | awk '{print $4}')
26binary=$(printf '%s\n' "$line" | awk '{print $5}')
27sha=$(printf '%s\n' "$line" | awk '{print $6}')
28[ -n "$version" ] && [ -n "$binary" ] && [ -n "$sha" ] \
29 || die "the latest-build file is malformed"
30
31say "Newest Zagan-Bet is $version."
32
19here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) 33here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
20source_file="" 34source_file=""
21for candidate in "$here/$BINARY" "./$BINARY" "$HOME/Downloads/$BINARY"; do 35for candidate in "$here/$binary" "./$binary" "$HOME/Downloads/$binary"; do
22 if [ -f "$candidate" ]; then 36 if [ -f "$candidate" ]; then
23 source_file=$candidate 37 source_file=$candidate
24 break 38 break
@@ -27,16 +41,17 @@ done
27 41
28cleanup="" 42cleanup=""
29if [ -z "$source_file" ]; then 43if [ -z "$source_file" ]; then
30 command -v curl >/dev/null 2>&1 || die "put $BINARY next to this installer, or install curl"
31 cleanup=$(mktemp) || die "cannot create a temporary file" 44 cleanup=$(mktemp) || die "cannot create a temporary file"
32 say "Downloading $BINARY..." 45 say "Downloading $binary..."
33 curl -fsSL --max-time 300 "$URL" -o "$cleanup" || die "download failed" 46 curl -fsSL --max-time 300 "$SITE/bin/gui/$binary" -o "$cleanup" \
47 || die "download failed"
34 source_file=$cleanup 48 source_file=$cleanup
35fi 49fi
36trap 'if [ -n "$cleanup" ]; then rm -f "$cleanup"; fi' EXIT 50trap 'if [ -n "$cleanup" ]; then rm -f "$cleanup"; fi' EXIT
37 51
38actual=$(sha256sum "$source_file" | awk '{print $1}') 52actual=$(sha256sum "$source_file" | awk '{print $1}')
39[ "$actual" = "$SHA256" ] || die "checksum mismatch: expected $SHA256, got $actual" 53[ "$actual" = "$sha" ] \
54 || die "checksum mismatch: expected $sha, got $actual"
40 55
41bin_dir="${XDG_BIN_HOME:-$HOME/.local/bin}" 56bin_dir="${XDG_BIN_HOME:-$HOME/.local/bin}"
42data_dir="${XDG_DATA_HOME:-$HOME/.local/share}" 57data_dir="${XDG_DATA_HOME:-$HOME/.local/share}"
@@ -48,7 +63,12 @@ mkdir -p "$bin_dir" "$desktop_dir" "$icon_dir"
48install -m 0755 "$source_file" "$app" 63install -m 0755 "$source_file" "$app"
49 64
50cat > "$icon_dir/zagan-bet.svg" <<'ICON' 65cat > "$icon_dir/zagan-bet.svg" <<'ICON'
51__ICON__ 66<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
67 <rect width="64" height="64" rx="14" fill="#0a0e12"/>
68 <rect x="9" y="9" width="46" height="46" rx="10" fill="#0f1a14" stroke="#1f7d52" stroke-width="2"/>
69 <path d="M22 20h20L22 44h20" fill="none" stroke="#4ec27b" stroke-width="6"
70 stroke-linecap="round" stroke-linejoin="round"/>
71</svg>
52ICON 72ICON
53 73
54cat > "$desktop_dir/zagan-bet.desktop" <<DESKTOP 74cat > "$desktop_dir/zagan-bet.desktop" <<DESKTOP
@@ -70,7 +90,7 @@ if command -v gtk-update-icon-cache >/dev/null 2>&1; then
70 gtk-update-icon-cache -q "$data_dir/icons/hicolor" >/dev/null 2>&1 || true 90 gtk-update-icon-cache -q "$data_dir/icons/hicolor" >/dev/null 2>&1 || true
71fi 91fi
72 92
73say "Installed Zagan-Bet $VERSION to $app" 93say "Installed Zagan-Bet $version to $app"
74say "A launcher was added to your applications menu." 94say "A launcher was added to your applications menu."
75case ":$PATH:" in 95case ":$PATH:" in
76 *":$bin_dir:"*) ;; 96 *":$bin_dir:"*) ;;
diff --git a/web/latest.php b/web/latest.php
new file mode 100644
index 0000000..3e7368c
--- /dev/null
+++ b/web/latest.php
@@ -0,0 +1,50 @@
1<?php
2declare(strict_types=1);
3
4/**
5 * The newest published build, for install.sh and install.ps1.
6 *
7 * Plain text, one record per line, so both a shell script and PowerShell can
8 * parse it without a JSON library:
9 *
10 * version 4.4.0
11 * gui linux x86_64 4.4.0 zagan-bet-gui-4.4.0-linux-x86_64 <sha256>
12 * gui windows x86_64 4.4.0 zagan-bet-gui-4.4.0-windows-x86_64.exe <sha256>
13 * client ...
14 * server ...
15 *
16 * Only the newest version is listed. The installers verify the SHA-256 they
17 * read here against the bytes they download.
18 */
19
20require __DIR__ . '/releases.php';
21
22$scan = scan_releases();
23$all = array_merge(
24 $scan['releases']['gui'],
25 $scan['releases']['client'],
26 $scan['releases']['server']
27);
28
29$newest = '0.0.0';
30foreach ($all as $release) {
31 if (version_compare($release['version'], $newest, '>')) {
32 $newest = $release['version'];
33 }
34}
35
36header('Content-Type: text/plain; charset=utf-8');
37header('Cache-Control: no-cache');
38
39echo 'version ' . $newest . "\n";
40foreach ($all as $release) {
41 if ($release['version'] !== $newest) {
42 continue;
43 }
44 echo $release['kind'] . ' '
45 . $release['platform'] . ' '
46 . $release['arch'] . ' '
47 . $release['version'] . ' '
48 . $release['file'] . ' '
49 . $release['sha256'] . "\n";
50}
diff --git a/web/releases.php b/web/releases.php
new file mode 100644
index 0000000..b9c7284
--- /dev/null
+++ b/web/releases.php
@@ -0,0 +1,262 @@
1<?php
2declare(strict_types=1);
3
4/**
5 * Shared release scanning for the download page and the latest-build endpoint.
6 *
7 * Expected file names under bin/<kind>/ (anything else is ignored):
8 *
9 * bin/gui/zagan-bet-gui-<version>-<platform>-<arch>[.exe]
10 * bin/client/zagan-bet-client-<version>-<platform>-<arch>[.exe]
11 * bin/server/zagan-bet-server-<version>-<platform>-<arch>[.exe]
12 *
13 * <version> is MAJOR.MINOR.PATCH, <platform> is linux|windows|macos and
14 * <arch> is x86_64|i686|aarch64.
15 *
16 * SHA-256 values are cached in a JSON file keyed on size and mtime, so a file
17 * is only re-read when it changes. The cache lives in the system temp
18 * directory by default (the web root is often not writable by the PHP user);
19 * set ZAGAN_HASH_CACHE to put it somewhere else.
20 */
21
22const KINDS = [
23 'gui' => [
24 'title' => 'Desktop app',
25 'hint' => 'Windows 10 or newer, or a Linux desktop. No terminal needed.',
26 ],
27 'client' => [
28 'title' => 'Client',
29 'hint' => 'Needs a terminal of at least 80×24 cells.',
30 ],
31 'server' => [
32 'title' => 'Server',
33 'hint' => 'Generates its own TLS identity on first run.',
34 ],
35];
36
37const PLATFORMS = [
38 'linux' => 'Linux',
39 'windows' => 'Windows',
40 'macos' => 'macOS',
41];
42
43const ARCHES = [
44 'x86_64' => '64-bit',
45 'i686' => '32-bit',
46 'aarch64' => 'ARM64',
47];
48
49// Newest version first, then 64-bit builds before 32-bit.
50const PLATFORM_ORDER = ['linux' => 0, 'windows' => 1, 'macos' => 2];
51const ARCH_ORDER = ['x86_64' => 0, 'aarch64' => 1, 'i686' => 2];
52
53/**
54 * Hash to use for a published file, reusing the cached value when the file has
55 * not changed since it was hashed.
56 *
57 * A cached hash is valid only when the size and mtime still match and the hash
58 * was taken at or after the file's mtime, so a restored or touched file is
59 * re-read.
60 *
61 * @param array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} $cache
62 */
63function cached_hash(
64 string $key,
65 string $path,
66 int $size,
67 int $mtime,
68 array &$cache,
69 bool &$dirty
70): ?string {
71 $entry = $cache['files'][$key] ?? null;
72 if (
73 is_array($entry)
74 && ($entry['size'] ?? null) === $size
75 && ($entry['mtime'] ?? null) === $mtime
76 && (int) ($entry['hashed_at'] ?? 0) >= $mtime
77 && isset($entry['sha256'])
78 && is_string($entry['sha256'])
79 && preg_match('/\A[0-9a-f]{64}\z/', $entry['sha256']) === 1
80 ) {
81 return $entry['sha256'];
82 }
83
84 $hash = hash_file('sha256', $path);
85 if ($hash === false) {
86 return null;
87 }
88
89 $cache['files'][$key] = [
90 'size' => $size,
91 'mtime' => $mtime,
92 'sha256' => $hash,
93 'hashed_at' => time(),
94 ];
95 $dirty = true;
96
97 return $hash;
98}
99
100/** @return array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} */
101function load_hash_cache(string $path): array
102{
103 $raw = @file_get_contents($path);
104 if ($raw === false || $raw === '') {
105 return ['files' => []];
106 }
107 $decoded = json_decode($raw, true);
108 if (!is_array($decoded) || !isset($decoded['files']) || !is_array($decoded['files'])) {
109 return ['files' => []];
110 }
111
112 return ['files' => $decoded['files']];
113}
114
115/**
116 * Best effort: a read-only web root, a full disk or a racing writer must never
117 * break the page, so failures are ignored and the next request recomputes.
118 *
119 * @param array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} $cache
120 */
121function save_hash_cache(string $path, array $cache): void
122{
123 $json = json_encode($cache, JSON_UNESCAPED_SLASHES);
124 if ($json === false) {
125 return;
126 }
127 $directory = dirname($path);
128 $temporary = @tempnam($directory, '.zagan-hashes-');
129 if ($temporary === false) {
130 return;
131 }
132 if (@file_put_contents($temporary, $json, LOCK_EX) === false || !@rename($temporary, $path)) {
133 @unlink($temporary);
134 }
135}
136
137/**
138 * @return array{kind:string,version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null
139 */
140function read_release(
141 string $kind,
142 string $directory,
143 string $entry,
144 array &$cache,
145 bool &$dirty
146): ?array {
147 $pattern = '/^zagan-bet-' . preg_quote($kind, '/')
148 . '-v?(\d+\.\d+\.\d+)-([a-z]+)-([a-z0-9_]+)(\.exe)?$/i';
149 if (preg_match($pattern, $entry, $matches) !== 1) {
150 return null;
151 }
152
153 $realDirectory = realpath($directory);
154 $real = realpath($directory . '/' . $entry);
155 if ($realDirectory === false || $real === false) {
156 return null;
157 }
158 // Never serve anything that resolves outside the published directory.
159 if (!str_starts_with($real, $realDirectory . DIRECTORY_SEPARATOR)) {
160 return null;
161 }
162 if (!is_file($real)) {
163 return null;
164 }
165
166 $size = filesize($real);
167 $mtime = filemtime($real);
168 if ($size === false || $mtime === false) {
169 return null;
170 }
171
172 $hash = cached_hash($kind . '/' . $entry, $real, $size, $mtime, $cache, $dirty);
173 if ($hash === null) {
174 return null;
175 }
176
177 return [
178 'kind' => $kind,
179 'version' => $matches[1],
180 'platform' => strtolower($matches[2]),
181 'arch' => strtolower($matches[3]),
182 'file' => $entry,
183 'size' => $size,
184 'mtime' => $mtime,
185 'sha256' => $hash,
186 ];
187}
188
189/**
190 * Scan every kind and return its releases, newest version first.
191 *
192 * @return array{releases:array<string,array<int,array{kind:string,version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}>>,newest_mtime:int}
193 */
194function scan_releases(): array
195{
196 $cachePath = getenv('ZAGAN_HASH_CACHE');
197 if (!is_string($cachePath) || $cachePath === '') {
198 $cachePath = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
199 . DIRECTORY_SEPARATOR . 'zagan-bet-hashes-'
200 . substr(hash('sha256', __DIR__), 0, 16) . '.json';
201 }
202 $cache = load_hash_cache($cachePath);
203 $dirty = false;
204 $seen = [];
205
206 $releases = [];
207 $newestMtime = 0;
208
209 foreach (KINDS as $kind => $meta) {
210 $found = [];
211 $directory = __DIR__ . '/bin/' . $kind;
212
213 if (is_dir($directory)) {
214 $entries = scandir($directory);
215 if ($entries !== false) {
216 foreach ($entries as $entry) {
217 if ($entry === '.' || $entry === '..') {
218 continue;
219 }
220 $release = read_release($kind, $directory, $entry, $cache, $dirty);
221 if ($release === null) {
222 continue;
223 }
224 $seen[$kind . '/' . $entry] = true;
225 $found[] = $release;
226 $newestMtime = max($newestMtime, $release['mtime']);
227 }
228 }
229 }
230
231 usort($found, static function (array $a, array $b): int {
232 $byVersion = version_compare($b['version'], $a['version']);
233 if ($byVersion !== 0) {
234 return $byVersion;
235 }
236 $byPlatform = (PLATFORM_ORDER[$a['platform']] ?? 9) <=> (PLATFORM_ORDER[$b['platform']] ?? 9);
237 if ($byPlatform !== 0) {
238 return $byPlatform;
239 }
240 $byArch = (ARCH_ORDER[$a['arch']] ?? 9) <=> (ARCH_ORDER[$b['arch']] ?? 9);
241 if ($byArch !== 0) {
242 return $byArch;
243 }
244 return strcmp($a['file'], $b['file']);
245 });
246
247 $releases[$kind] = $found;
248 }
249
250 // Drop cache entries for files that are no longer published.
251 foreach (array_keys($cache['files']) as $key) {
252 if (!isset($seen[$key])) {
253 unset($cache['files'][$key]);
254 $dirty = true;
255 }
256 }
257 if ($dirty) {
258 save_hash_cache($cachePath, $cache);
259 }
260
261 return ['releases' => $releases, 'newest_mtime' => $newestMtime];
262}