summaryrefslogtreecommitdiffstats
path: root/web/releases.php
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/releases.php
parentv4.3.1: render Windows through wgpu and keep the installer window open (diff)
downloadzagan-bet-4887abc5773b33ab1e213ed5f528dfebd242e2cd.tar.gz
zagan-bet-4887abc5773b33ab1e213ed5f528dfebd242e2cd.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 '')
-rw-r--r--web/releases.php262
1 files changed, 262 insertions, 0 deletions
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}