summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md10
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GUI-PLAN.md12
-rw-r--r--docs/TESTING.md16
-rw-r--r--gui/Cargo.toml2
-rwxr-xr-xscripts/deploy.sh24
-rwxr-xr-xscripts/publish.sh37
-rw-r--r--scripts/render-installer.py49
-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
14 files changed, 485 insertions, 420 deletions
diff --git a/AGENTS.md b/AGENTS.md
index cd5c71b..ab794f8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -85,10 +85,12 @@
85- The embedded fonts have no suit or arrow glyphs. Draw card suits as shapes 85- The embedded fonts have no suit or arrow glyphs. Draw card suits as shapes
86 and use plain words for navigation; a glyph that is not in Open Sans or Hack 86 and use plain words for navigation; a glyph that is not in Open Sans or Hack
87 renders as tofu. 87 renders as tofu.
88- `scripts/publish.sh` builds the GUI with `gui-release` and renders the 88- `scripts/publish.sh` builds the GUI with `gui-release`; the installers are
89 installers with the published SHA-256. Installers verify the hash and install 89 stable site files (`web/install.sh`, `web/install.ps1`) that read
90 per-user; never install system-wide or skip the check. The Windows installer 90 `web/latest.php` for the newest build and its SHA-256. Never regenerate or
91 waits for Enter before closing; `-NoPause` is only for automation. 91 version the installers per release, install system-wide, or skip the hash
92 check. The Windows installer waits for Enter before closing; `-NoPause` is
93 only for automation.
92- Windows renders through wgpu on Direct3D 12, including the WARP software 94- Windows renders through wgpu on Direct3D 12, including the WARP software
93 adapter, so a machine with only OpenGL 1.1 (VM, Remote Desktop) still runs. 95 adapter, so a machine with only OpenGL 1.1 (VM, Remote Desktop) still runs.
94 Linux keeps the smaller glow renderer. Do not assume OpenGL 2.0 on Windows. 96 Linux keeps the smaller glow renderer. Do not assume OpenGL 2.0 on Windows.
diff --git a/Cargo.toml b/Cargo.toml
index 8897f9d..16ed8c6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ members = ["gui"]
3 3
4[package] 4[package]
5name = "zagan-bet" 5name = "zagan-bet"
6version = "4.3.1" 6version = "4.4.0"
7edition = "2024" 7edition = "2024"
8license = "GPL-3.0-or-later" 8license = "GPL-3.0-or-later"
9 9
diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md
index a322057..c0370a0 100644
--- a/docs/GUI-PLAN.md
+++ b/docs/GUI-PLAN.md
@@ -169,13 +169,13 @@ frame rate on integrated graphics and the Windows interactive pass.
169 169
170- `publish.sh` builds the GUI with the `gui-release` profile for Linux and 170- `publish.sh` builds the GUI with the `gui-release` profile for Linux and
171 Windows and names the app `zagan-bet-gui-<version>-<platform>-<arch>[.exe]`. 171 Windows and names the app `zagan-bet-gui-<version>-<platform>-<arch>[.exe]`.
172- It renders a bash installer and a PowerShell installer next to each app. 172- The installers are stable, unversioned site files: `install.sh` and
173 Both embed the app's SHA-256, verify the file (or download it), install 173 `install.ps1` read `latest.php`, pick the newest build for the machine,
174 per-user and add a launcher: `.desktop` plus an SVG icon on Linux, a 174 verify its SHA-256, install per-user and add a launcher (`.desktop` plus an
175 Start-menu shortcut on Windows. Templates live in `scripts/installers/`; 175 SVG icon on Linux, a Start-menu shortcut on Windows). Publishing a new
176 `render-installer.py` fills them in. 176 version does not regenerate them.
177- The download page scans `bin/gui`, `bin/client` and `bin/server`, lists 177- The download page scans `bin/gui`, `bin/client` and `bin/server`, lists
178 apps, installers, clients and servers, and includes every file in the 178 apps, stable installers, clients and servers, and includes every file in the
179 `sha256sum` block. A Source section links the public git repository 179 `sha256sum` block. A Source section links the public git repository
180 (<https://git.2137697.xyz/zagan-bet.git>) for the GPL corresponding source. 180 (<https://git.2137697.xyz/zagan-bet.git>) for the GPL corresponding source.
181 `deploy.sh` verifies every artifact kind over the public URL. 181 `deploy.sh` verifies every artifact kind over the public URL.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index c32ac22..09ceb82 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -272,14 +272,16 @@ terminal client; a saved login from one signs in the other.
2721. Set the version in `Cargo.toml` and `gui/Cargo.toml` (same MAJOR.MINOR), 2721. Set the version in `Cargo.toml` and `gui/Cargo.toml` (same MAJOR.MINOR),
273 run `cargo test --workspace`, and commit. `publish.sh` refuses a dirty tree 273 run `cargo test --workspace`, and commit. `publish.sh` refuses a dirty tree
274 because the site links the committed git history as the GPL source. 274 because the site links the committed git history as the GPL source.
2752. `./scripts/publish.sh` (add `DEST=` and `URL_BASE=` to stage elsewhere). It 2752. `./scripts/publish.sh` (add `DEST=` to stage elsewhere). It builds the GUI
276 builds the GUI with `gui-release`, the terminal clients and servers, and 276 with `gui-release` and the terminal clients and servers. The installers are
277 renders the bash/PowerShell installers with each app's SHA-256. 277 stable site files and are not regenerated.
2783. `php -S 127.0.0.1:8899 -t <staging>` and 2783. `php -S 127.0.0.1:8899 -t <staging>` and
279 `./scripts/deploy.sh <staging> http://127.0.0.1:8899/` to upload and verify 279 `./scripts/deploy.sh <staging> http://127.0.0.1:8899/` to upload and verify
280 every app, installer, client and server against the page's checksums. 280 every app, client and server against the page's checksums, the stable
2814. On a clean user account: download an app with its installer, run the 281 installers, and the newest-version feed at `latest.php`.
282 installer, and launch the new menu entry. The installer must refuse a 2824. On a clean user account: run the stable installer
283 corrupted download. 283 (`ZAGAN_SITE=http://127.0.0.1:8899 ./web/install.sh`) and launch the new
284 menu entry. It must install the newest version even when an older app sits
285 next to it, and refuse a corrupted download.
2845. Repeat on Windows for the `.ps1` installer and the Start-menu shortcut. 2865. Repeat on Windows for the `.ps1` installer and the Start-menu shortcut.
285 287
diff --git a/gui/Cargo.toml b/gui/Cargo.toml
index 24bf560..e216309 100644
--- a/gui/Cargo.toml
+++ b/gui/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet-gui" 2name = "zagan-bet-gui"
3version = "4.3.1" 3version = "4.4.0"
4edition = "2024" 4edition = "2024"
5license = "GPL-3.0-or-later" 5license = "GPL-3.0-or-later"
6description = "Desktop frontend for zagan-bet" 6description = "Desktop frontend for zagan-bet"
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index b18d462..63bd31c 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -95,6 +95,20 @@ origin=$(printf '%s' "$URL" | sed -E 's#^(https?://[^/]+).*#\1#')
95grep -oE '[0-9a-f]{64} [A-Za-z0-9._-]+' "$tmp/page.html" > "$tmp/sums" || true 95grep -oE '[0-9a-f]{64} [A-Za-z0-9._-]+' "$tmp/page.html" > "$tmp/sums" || true
96[ -s "$tmp/sums" ] || die "no SHA-256 lines on the page (is index.php being served?)" 96[ -s "$tmp/sums" ] || die "no SHA-256 lines on the page (is index.php being served?)"
97 97
98for script in install.sh install.ps1; do
99 grep -qE "href=\"[^\"]*/$script\"" "$tmp/page.html" \
100 || die "$script is not linked from the page"
101 curl -fsSL --max-time 30 "$origin/$script" -o "$tmp/$script" \
102 || die "could not fetch $script"
103 cmp -s "web/$script" "$tmp/$script" \
104 || die "$script served from the site differs from the local file"
105done
106
107curl -fsSL --max-time 30 "$origin/latest.php" -o "$tmp/latest.txt" \
108 || die "could not fetch latest.php"
109newest=$(awk '$1 == "version" { print $2; exit }' "$tmp/latest.txt")
110[ -n "$newest" ] || die "latest.php has no version line"
111
98failed=0 112failed=0
99verified=0 113verified=0
100for local in "${artifacts[@]}"; do 114for local in "${artifacts[@]}"; do
@@ -132,6 +146,16 @@ for local in "${artifacts[@]}"; do
132 continue 146 continue
133 fi 147 fi
134 148
149 version=$(printf '%s' "$file" | sed -E 's/^zagan-bet-[a-z]+-([0-9]+\.[0-9]+\.[0-9]+)-.*/\1/')
150 if [ "$version" = "$newest" ]; then
151 latest_hash=$(awk -v kind="$kind" -v name="$file" '$1 == kind && $5 == name { print $6 }' "$tmp/latest.txt")
152 if [ "$latest_hash" != "$local_hash" ]; then
153 echo " $file: latest.php shows ${latest_hash:-nothing}, local is $local_hash"
154 failed=1
155 continue
156 fi
157 fi
158
135 echo " $file: ok" 159 echo " $file: ok"
136 verified=$((verified + 1)) 160 verified=$((verified + 1))
137done 161done
diff --git a/scripts/publish.sh b/scripts/publish.sh
index 3a04631..fbcd588 100755
--- a/scripts/publish.sh
+++ b/scripts/publish.sh
@@ -5,20 +5,19 @@
5# zagan-bet-client-<version>-<platform>-<arch>[.exe] 5# zagan-bet-client-<version>-<platform>-<arch>[.exe]
6# zagan-bet-server-<version>-<platform>-<arch>[.exe] 6# zagan-bet-server-<version>-<platform>-<arch>[.exe]
7# zagan-bet-gui-<version>-<platform>-<arch>[.exe] 7# zagan-bet-gui-<version>-<platform>-<arch>[.exe]
8# zagan-bet-gui-<version>-<platform>-<arch>-install.sh
9# zagan-bet-gui-<version>-<platform>-<arch>-install.ps1
10# 8#
11# Cargo always writes target/<triple>/<profile>/<bin>, so the rename happens 9# Cargo always writes target/<triple>/<profile>/<bin>, so the rename happens
12# here after each build. The version comes from Cargo.toml. 10# here after each build. The version comes from Cargo.toml.
13# 11#
14# The GUI uses the workspace's `gui-release` profile (small, stripped, LTO). 12# The GUI uses the workspace's `gui-release` profile (small, stripped, LTO).
15# The public git repository is the GPL source for these binaries, so publish 13# The installers are stable site files (web/install.sh and web/install.ps1):
16# from a clean tree (or set ALLOW_DIRTY=1 for a preview). 14# they ask web/latest.php for the newest build, so they are not regenerated
15# here. The public git repository is the GPL source for these binaries, so
16# publish from a clean tree (or set ALLOW_DIRTY=1 for a preview).
17# 17#
18# Usage: 18# Usage:
19# ./scripts/publish.sh 19# ./scripts/publish.sh
20# DEST=/srv/www/bin ./scripts/publish.sh 20# DEST=/srv/www/bin ./scripts/publish.sh
21# URL_BASE=https://staging.example/ ./scripts/publish.sh
22set -euo pipefail 21set -euo pipefail
23 22
24ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) 23ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
@@ -31,22 +30,12 @@ if [ -z "${VERSION:-}" ]; then
31fi 30fi
32 31
33DEST=${DEST:-web/bin} 32DEST=${DEST:-web/bin}
34URL_BASE=${URL_BASE:-https://zagan.2137697.xyz}
35 33
36if [ "${ALLOW_DIRTY:-0}" != "1" ] && [ -n "$(git status --porcelain)" ]; then 34if [ "${ALLOW_DIRTY:-0}" != "1" ] && [ -n "$(git status --porcelain)" ]; then
37 echo "the working tree is dirty; commit the release first (or ALLOW_DIRTY=1)" >&2 35 echo "the working tree is dirty; commit the release first (or ALLOW_DIRTY=1)" >&2
38 exit 1 36 exit 1
39fi 37fi
40 38
41command -v python3 >/dev/null || {
42 echo "python3 is required to render the installers" >&2
43 exit 1
44}
45
46hash_of() {
47 sha256sum "$1" | awk '{print $1}'
48}
49
50publish() { 39publish() {
51 local triple=$1 kind=$2 platform=$3 arch=$4 40 local triple=$1 kind=$2 platform=$3 arch=$4
52 local ext="" 41 local ext=""
@@ -85,23 +74,13 @@ publish_gui() {
85 74
86 mkdir -p "$DEST/gui" 75 mkdir -p "$DEST/gui"
87 install -m 0755 "$source" "$DEST/gui/$artifact" 76 install -m 0755 "$source" "$DEST/gui/$artifact"
88 local sha
89 sha=$(hash_of "$DEST/gui/$artifact")
90 echo " $DEST/gui/$artifact ($(du -h "$DEST/gui/$artifact" | cut -f1))" 77 echo " $DEST/gui/$artifact ($(du -h "$DEST/gui/$artifact" | cut -f1))"
91
92 if [ "$platform" = linux ]; then
93 python3 scripts/render-installer.py \
94 scripts/installers/linux.sh.in \
95 "$DEST/gui/zagan-bet-gui-$VERSION-linux-$arch-install.sh" \
96 "$VERSION" "$artifact" "$sha" "$URL_BASE/bin/gui/$artifact"
97 elif [ "$platform" = windows ]; then
98 python3 scripts/render-installer.py \
99 scripts/installers/windows.ps1.in \
100 "$DEST/gui/zagan-bet-gui-$VERSION-windows-$arch-install.ps1" \
101 "$VERSION" "$artifact" "$sha" "$URL_BASE/bin/gui/$artifact"
102 fi
103} 78}
104 79
80rm web/bin/client/*
81rm web/bin/gui/*
82rm web/bin/server/*
83
105publish_gui x86_64-unknown-linux-gnu linux x86_64 84publish_gui x86_64-unknown-linux-gnu linux x86_64
106publish_gui x86_64-pc-windows-gnu windows x86_64 85publish_gui x86_64-pc-windows-gnu windows x86_64
107 86
diff --git a/scripts/render-installer.py b/scripts/render-installer.py
deleted file mode 100644
index 1a00551..0000000
--- a/scripts/render-installer.py
+++ /dev/null
@@ -1,49 +0,0 @@
1#!/usr/bin/env python3
2"""Fill an installer template with one published artifact's details.
3
4Usage: render-installer.py <template> <output> <version> <binary> <sha256> <url>
5
6The Linux template embeds the application icon so the installer works offline.
7"""
8
9from __future__ import annotations
10
11import pathlib
12import sys
13
14
15def main(argv: list[str]) -> int:
16 if len(argv) != 7:
17 print(
18 "usage: render-installer.py <template> <output> <version> "
19 "<binary> <sha256> <url>",
20 file=sys.stderr,
21 )
22 return 2
23
24 template, output, version, binary, sha256, url = argv[1:]
25 root = pathlib.Path(__file__).resolve().parent.parent
26 icon = (root / "gui" / "assets" / "zagan-bet.svg").read_text()
27
28 text = pathlib.Path(template).read_text()
29 replacements = {
30 "__VERSION__": version,
31 "__BINARY__": binary,
32 "__SHA256__": sha256,
33 "__URL__": url,
34 "__ICON__": icon.rstrip("\n"),
35 }
36 for placeholder, value in replacements.items():
37 text = text.replace(placeholder, value)
38
39 destination = pathlib.Path(output)
40 destination.write_text(text)
41 if destination.suffix == ".sh":
42 destination.chmod(0o755)
43
44 print(f" {destination} ({destination.stat().st_size} bytes)")
45 return 0
46
47
48if __name__ == "__main__":
49 raise SystemExit(main(sys.argv))
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}