summaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 22:23:00 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 22:23:00 +0100
commit4887abc5773b33ab1e213ed5f528dfebd242e2cd (patch)
tree8eb2316783a21817cce422c3fb58ec953206dd9a /web
parentv4.3.1: render Windows through wgpu and keep the installer window open (diff)
downloadzagan-bet-master.tar.gz
zagan-bet-master.zip
v4.4.0: stable installers that follow the newest published buildHEADmaster
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 'web')
-rw-r--r--web/README.md38
-rw-r--r--web/index.php308
-rw-r--r--web/install.ps1108
-rw-r--r--web/install.sh98
-rw-r--r--web/latest.php50
-rw-r--r--web/releases.php262
6 files changed, 568 insertions, 296 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/web/install.ps1 b/web/install.ps1
new file mode 100644
index 0000000..92eef83
--- /dev/null
+++ b/web/install.ps1
@@ -0,0 +1,108 @@
1# Zagan-Bet installer for Windows.
2#
3# Asks the site which build is newest, verifies its SHA-256, installs it for
4# the current user and adds a Start-menu shortcut. Point it at a staging site
5# with -Site http://127.0.0.1:8899; add -NoPause for automation.
6#
7# Run it with:
8# powershell -ExecutionPolicy Bypass -File .\install.ps1
9param(
10 [string]$Site = "https://zagan.2137697.xyz",
11 [switch]$NoPause
12)
13
14$ErrorActionPreference = "Stop"
15
16function Finish([int]$Code) {
17 if (-not $NoPause) {
18 Read-Host "Press Enter to close" | Out-Null
19 }
20 exit $Code
21}
22
23function Stop-Install([string]$Message) {
24 Write-Host "install: $Message" -ForegroundColor Red
25 Finish 1
26}
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
52$here = Split-Path -Parent $MyInvocation.MyCommand.Path
53$candidates = @(
54 (Join-Path $here $Binary),
55 (Join-Path (Get-Location) $Binary),
56 (Join-Path $env:USERPROFILE "Downloads\$Binary")
57)
58$sourceFile = $null
59foreach ($candidate in $candidates) {
60 if (Test-Path -LiteralPath $candidate) {
61 $sourceFile = $candidate
62 break
63 }
64}
65
66$temporary = $null
67if (-not $sourceFile) {
68 $temporary = Join-Path ([System.IO.Path]::GetTempPath()) $Binary
69 Write-Host "Downloading $Binary..."
70 try {
71 Invoke-WebRequest -Uri $Url -OutFile $temporary -UseBasicParsing
72 } catch {
73 Stop-Install "download failed: $_"
74 }
75 $sourceFile = $temporary
76}
77
78try {
79 $actual = (Get-FileHash -LiteralPath $sourceFile -Algorithm SHA256).Hash.ToLowerInvariant()
80 if ($actual -ne $Sha256.ToLowerInvariant()) {
81 Stop-Install "checksum mismatch: expected $Sha256, got $actual"
82 }
83
84 $installDir = Join-Path $env:LOCALAPPDATA "Programs\Zagan-Bet"
85 New-Item -ItemType Directory -Force -Path $installDir | Out-Null
86 $target = Join-Path $installDir "zagan-bet-gui.exe"
87 Copy-Item -LiteralPath $sourceFile -Destination $target -Force
88
89 $startMenu = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs"
90 New-Item -ItemType Directory -Force -Path $startMenu | Out-Null
91 $shortcutPath = Join-Path $startMenu "Zagan-Bet.lnk"
92 $shell = New-Object -ComObject WScript.Shell
93 $shortcut = $shell.CreateShortcut($shortcutPath)
94 $shortcut.TargetPath = $target
95 $shortcut.WorkingDirectory = $installDir
96 $shortcut.IconLocation = "$target,0"
97 $shortcut.Description = "Slots, roulette, blackjack and crash"
98 $shortcut.Save()
99
100 Write-Host "Installed Zagan-Bet $Version to $target"
101 Write-Host "A Start-menu shortcut was created."
102} finally {
103 if ($temporary -and (Test-Path -LiteralPath $temporary)) {
104 Remove-Item -LiteralPath $temporary -Force
105 }
106}
107
108Finish 0
diff --git a/web/install.sh b/web/install.sh
new file mode 100644
index 0000000..998b262
--- /dev/null
+++ b/web/install.sh
@@ -0,0 +1,98 @@
1#!/usr/bin/env bash
2# Zagan-Bet installer for Linux.
3#
4# Asks the site which build is newest, verifies its SHA-256, installs it for
5# the current user and adds a launcher. Point it at a staging site with
6# ZAGAN_SITE=http://127.0.0.1:8899.
7set -euo pipefail
8
9SITE=${ZAGAN_SITE:-https://zagan.2137697.xyz}
10PLATFORM=linux
11ARCH=x86_64
12
13say() { printf '%s\n' "$*"; }
14die() { printf 'install: %s\n' "$*" >&2; exit 1; }
15
16command -v curl >/dev/null 2>&1 || die "curl is required"
17command -v sha256sum >/dev/null 2>&1 || die "sha256sum is required"
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
33here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
34source_file=""
35for candidate in "$here/$binary" "./$binary" "$HOME/Downloads/$binary"; do
36 if [ -f "$candidate" ]; then
37 source_file=$candidate
38 break
39 fi
40done
41
42cleanup=""
43if [ -z "$source_file" ]; then
44 cleanup=$(mktemp) || die "cannot create a temporary file"
45 say "Downloading $binary..."
46 curl -fsSL --max-time 300 "$SITE/bin/gui/$binary" -o "$cleanup" \
47 || die "download failed"
48 source_file=$cleanup
49fi
50trap 'if [ -n "$cleanup" ]; then rm -f "$cleanup"; fi' EXIT
51
52actual=$(sha256sum "$source_file" | awk '{print $1}')
53[ "$actual" = "$sha" ] \
54 || die "checksum mismatch: expected $sha, got $actual"
55
56bin_dir="${XDG_BIN_HOME:-$HOME/.local/bin}"
57data_dir="${XDG_DATA_HOME:-$HOME/.local/share}"
58app="$bin_dir/zagan-bet-gui"
59desktop_dir="$data_dir/applications"
60icon_dir="$data_dir/icons/hicolor/scalable/apps"
61
62mkdir -p "$bin_dir" "$desktop_dir" "$icon_dir"
63install -m 0755 "$source_file" "$app"
64
65cat > "$icon_dir/zagan-bet.svg" <<'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>
72ICON
73
74cat > "$desktop_dir/zagan-bet.desktop" <<DESKTOP
75[Desktop Entry]
76Type=Application
77Name=Zagan-Bet
78Comment=Slots, roulette, blackjack and crash
79Exec=$app
80Icon=zagan-bet
81Terminal=false
82Categories=Game;
83DESKTOP
84chmod 0644 "$desktop_dir/zagan-bet.desktop" "$icon_dir/zagan-bet.svg"
85
86if command -v update-desktop-database >/dev/null 2>&1; then
87 update-desktop-database "$desktop_dir" >/dev/null 2>&1 || true
88fi
89if command -v gtk-update-icon-cache >/dev/null 2>&1; then
90 gtk-update-icon-cache -q "$data_dir/icons/hicolor" >/dev/null 2>&1 || true
91fi
92
93say "Installed Zagan-Bet $version to $app"
94say "A launcher was added to your applications menu."
95case ":$PATH:" in
96 *":$bin_dir:"*) ;;
97 *) say "Add $bin_dir to PATH to start it as 'zagan-bet-gui'." ;;
98esac
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}