diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 22:23:00 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 22:23:00 +0100 |
| commit | 4887abc5773b33ab1e213ed5f528dfebd242e2cd (patch) | |
| tree | 8eb2316783a21817cce422c3fb58ec953206dd9a | |
| parent | v4.3.1: render Windows through wgpu and keep the installer window open (diff) | |
| download | zagan-bet-master.tar.gz zagan-bet-master.zip | |
Adds web/latest.php: a plain-text feed of the newest version with each artifact's filename and SHA-256. The per-release generated installers are replaced by stable web/install.sh and web/install.ps1; both read the feed, pick the newest build, verify it, install per-user and add a launcher. Publishing a new app version no longer regenerates or versions the installers.
publish.sh drops the installer renderer and Python dependency. deploy.sh now also verifies latest.php against the local artifacts and that the served installer scripts match the repository.
| -rw-r--r-- | AGENTS.md | 10 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/GUI-PLAN.md | 12 | ||||
| -rw-r--r-- | docs/TESTING.md | 16 | ||||
| -rw-r--r-- | gui/Cargo.toml | 2 | ||||
| -rwxr-xr-x | scripts/deploy.sh | 24 | ||||
| -rwxr-xr-x | scripts/publish.sh | 37 | ||||
| -rw-r--r-- | scripts/render-installer.py | 49 | ||||
| -rw-r--r-- | web/README.md | 38 | ||||
| -rw-r--r-- | web/index.php | 308 | ||||
| -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.php | 50 | ||||
| -rw-r--r-- | web/releases.php | 262 |
14 files changed, 485 insertions, 420 deletions
| @@ -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. |
| @@ -3,7 +3,7 @@ members = ["gui"] | |||
| 3 | 3 | ||
| 4 | [package] | 4 | [package] |
| 5 | name = "zagan-bet" | 5 | name = "zagan-bet" |
| 6 | version = "4.3.1" | 6 | version = "4.4.0" |
| 7 | edition = "2024" | 7 | edition = "2024" |
| 8 | license = "GPL-3.0-or-later" | 8 | license = "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. | |||
| 272 | 1. Set the version in `Cargo.toml` and `gui/Cargo.toml` (same MAJOR.MINOR), | 272 | 1. 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. |
| 275 | 2. `./scripts/publish.sh` (add `DEST=` and `URL_BASE=` to stage elsewhere). It | 275 | 2. `./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. |
| 278 | 3. `php -S 127.0.0.1:8899 -t <staging>` and | 278 | 3. `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 |
| 281 | 4. 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 | 282 | 4. 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. | ||
| 284 | 5. Repeat on Windows for the `.ps1` installer and the Start-menu shortcut. | 286 | 5. 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] |
| 2 | name = "zagan-bet-gui" | 2 | name = "zagan-bet-gui" |
| 3 | version = "4.3.1" | 3 | version = "4.4.0" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | license = "GPL-3.0-or-later" | 5 | license = "GPL-3.0-or-later" |
| 6 | description = "Desktop frontend for zagan-bet" | 6 | description = "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#') | |||
| 95 | grep -oE '[0-9a-f]{64} [A-Za-z0-9._-]+' "$tmp/page.html" > "$tmp/sums" || true | 95 | grep -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 | ||
| 98 | for 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" | ||
| 105 | done | ||
| 106 | |||
| 107 | curl -fsSL --max-time 30 "$origin/latest.php" -o "$tmp/latest.txt" \ | ||
| 108 | || die "could not fetch latest.php" | ||
| 109 | newest=$(awk '$1 == "version" { print $2; exit }' "$tmp/latest.txt") | ||
| 110 | [ -n "$newest" ] || die "latest.php has no version line" | ||
| 111 | |||
| 98 | failed=0 | 112 | failed=0 |
| 99 | verified=0 | 113 | verified=0 |
| 100 | for local in "${artifacts[@]}"; do | 114 | for 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)) |
| 137 | done | 161 | done |
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 | ||
| 22 | set -euo pipefail | 21 | set -euo pipefail |
| 23 | 22 | ||
| 24 | ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) | 23 | ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) |
| @@ -31,22 +30,12 @@ if [ -z "${VERSION:-}" ]; then | |||
| 31 | fi | 30 | fi |
| 32 | 31 | ||
| 33 | DEST=${DEST:-web/bin} | 32 | DEST=${DEST:-web/bin} |
| 34 | URL_BASE=${URL_BASE:-https://zagan.2137697.xyz} | ||
| 35 | 33 | ||
| 36 | if [ "${ALLOW_DIRTY:-0}" != "1" ] && [ -n "$(git status --porcelain)" ]; then | 34 | if [ "${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 |
| 39 | fi | 37 | fi |
| 40 | 38 | ||
| 41 | command -v python3 >/dev/null || { | ||
| 42 | echo "python3 is required to render the installers" >&2 | ||
| 43 | exit 1 | ||
| 44 | } | ||
| 45 | |||
| 46 | hash_of() { | ||
| 47 | sha256sum "$1" | awk '{print $1}' | ||
| 48 | } | ||
| 49 | |||
| 50 | publish() { | 39 | publish() { |
| 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 | ||
| 80 | rm web/bin/client/* | ||
| 81 | rm web/bin/gui/* | ||
| 82 | rm web/bin/server/* | ||
| 83 | |||
| 105 | publish_gui x86_64-unknown-linux-gnu linux x86_64 | 84 | publish_gui x86_64-unknown-linux-gnu linux x86_64 |
| 106 | publish_gui x86_64-pc-windows-gnu windows x86_64 | 85 | publish_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 | |||
| 4 | Usage: render-installer.py <template> <output> <version> <binary> <sha256> <url> | ||
| 5 | |||
| 6 | The Linux template embeds the application icon so the installer works offline. | ||
| 7 | """ | ||
| 8 | |||
| 9 | from __future__ import annotations | ||
| 10 | |||
| 11 | import pathlib | ||
| 12 | import sys | ||
| 13 | |||
| 14 | |||
| 15 | def 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 | |||
| 48 | if __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 | ``` |
| 10 | web/ | 10 | web/ |
| 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 | ||
| 17 | Serve `web/` as the document root. Binaries in `bin/` are gitignored. | 21 | Serve `web/` as the document root. Binaries in `bin/` are gitignored. |
| @@ -23,8 +27,6 @@ those directories is ignored. | |||
| 23 | 27 | ||
| 24 | ``` | 28 | ``` |
| 25 | bin/gui/zagan-bet-gui-<version>-<platform>-<arch>[.exe] | 29 | bin/gui/zagan-bet-gui-<version>-<platform>-<arch>[.exe] |
| 26 | bin/gui/zagan-bet-gui-<version>-<platform>-<arch>-install.sh | ||
| 27 | bin/gui/zagan-bet-gui-<version>-<platform>-<arch>-install.ps1 | ||
| 28 | bin/client/zagan-bet-client-<version>-<platform>-<arch>[.exe] | 30 | bin/client/zagan-bet-client-<version>-<platform>-<arch>[.exe] |
| 29 | bin/server/zagan-bet-server-<version>-<platform>-<arch>[.exe] | 31 | bin/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 | ||
| 35 | The installers are generated next to their app. They embed the app's SHA-256, | 37 | The installers are not versioned. They read `latest.php`, pick the newest build |
| 36 | check the file (or download it) and install it for the current user: Linux | 38 | for the machine, verify its SHA-256 and install it for the current user: Linux |
| 37 | writes `~/.local/bin/zagan-bet-gui` plus a `.desktop` launcher, Windows writes | 39 | writes `~/.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 |
| 39 | installer waits for Enter before closing (`-NoPause` for automation). The page | 41 | site only needs `publish.sh`; the installer links never change. Set |
| 40 | links the public git repository for the GPL corresponding source. | 42 | `ZAGAN_SITE` (Linux) or `-Site` (Windows) to test against a staging site. The |
| 43 | Windows installer waits for Enter before closing (`-NoPause` for automation). | ||
| 44 | The 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 | ||
| 50 | It reads the version from `Cargo.toml`, builds every artifact and installs | 54 | It reads the version from `Cargo.toml`, builds every artifact and installs |
| 51 | them under `web/bin` with the names above. The GUI is built with the | 55 | them under `web/bin` with the names above. The GUI is built with the |
| 52 | workspace's `gui-release` profile (LTO, stripped) and the installers are | 56 | workspace's `gui-release` profile (LTO, stripped). Run it from a clean tree so |
| 53 | rendered with Python 3 and the published SHA-256. Run it from a clean tree so | ||
| 54 | the release commit is the source the page links to. Override the destination | 57 | the release commit is the source the page links to. Override the destination |
| 55 | with `DEST=/srv/www/bin ./scripts/publish.sh`, and the download origin baked | 58 | with `DEST=/srv/www/bin ./scripts/publish.sh`; `ALLOW_DIRTY=1` skips the |
| 56 | into the installers with `URL_BASE=https://staging.example/ ./scripts/publish.sh`. | 59 | clean-tree check for a preview. The installers are static and need no |
| 57 | `ALLOW_DIRTY=1` skips the clean-tree check for a preview. | 60 | regeneration. |
| 58 | 61 | ||
| 59 | Builds are per target triple, so these must be installed: | 62 | Builds 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 |
| 62 | builds always pass `--no-default-features`, which drops the server's OpenSSL | 65 | builds always pass `--no-default-features`, which drops the server's OpenSSL |
| 63 | dependency; the client is rustls-only. The GUI needs Python 3 at publish time | 66 | dependency; the client is rustls-only. |
| 64 | for the installer templates in `scripts/installers/`. | ||
| 65 | 67 | ||
| 66 | Cargo itself cannot produce these names: it always writes | 68 | Cargo 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 |
| 2 | declare(strict_types=1); | 2 | declare(strict_types=1); |
| 3 | 3 | ||
| 4 | require __DIR__ . '/releases.php'; | ||
| 5 | |||
| 4 | /** | 6 | /** |
| 5 | * zagan.bet download page. | 7 | * zagan.bet download page. |
| 6 | * | 8 | * |
| 7 | * Scans ./bin/{gui,client,server} and renders every build it finds, with its | 9 | * Lists every published app, terminal client and server, with version, size |
| 8 | * version, size and SHA-256. The GPL source link points at the public git | 10 | * and SHA-256. The installers are stable links that ask latest.php for the |
| 9 | * repository. No database, no JavaScript, no dependencies. | 11 | * newest build. The GPL source link points at the public git repository. No |
| 10 | * | 12 | * database, no JavaScript, no dependencies. |
| 11 | * Expected file names (anything else is ignored): | ||
| 12 | * | ||
| 13 | * bin/gui/zagan-bet-gui-<version>-<platform>-<arch>[.exe] | ||
| 14 | * bin/gui/zagan-bet-gui-<version>-<platform>-<arch>-install.sh | ||
| 15 | * bin/gui/zagan-bet-gui-<version>-<platform>-<arch>-install.ps1 | ||
| 16 | * bin/client/zagan-bet-client-<version>-<platform>-<arch>[.exe] | ||
| 17 | * bin/server/zagan-bet-server-<version>-<platform>-<arch>[.exe] | ||
| 18 | * | ||
| 19 | * <version> is MAJOR.MINOR.PATCH, <platform> is linux|windows|macos and | ||
| 20 | * <arch> is x86_64|i686|aarch64. Example: | ||
| 21 | * | ||
| 22 | * bin/gui/zagan-bet-gui-4.3.0-linux-x86_64 | ||
| 23 | * bin/client/zagan-bet-client-4.3.0-linux-x86_64 | ||
| 24 | * | ||
| 25 | * SHA-256 values are cached in a JSON file keyed on size and mtime, so a file | ||
| 26 | * is only re-read when it changes. The cache lives in the system temp | ||
| 27 | * directory by default (the web root is often not writable by the PHP user); | ||
| 28 | * set ZAGAN_HASH_CACHE to put it somewhere else. | ||
| 29 | */ | 13 | */ |
| 30 | 14 | ||
| 31 | const KINDS = [ | 15 | $scan = scan_releases(); |
| 32 | 'gui' => [ | 16 | $releases = $scan['releases']; |
| 33 | 'title' => 'Desktop app', | 17 | $newest = $scan['newest_mtime']; |
| 34 | 'hint' => 'Windows 10 or newer, or a Linux desktop. No terminal needed.', | ||
| 35 | ], | ||
| 36 | 'client' => [ | ||
| 37 | 'title' => 'Client', | ||
| 38 | 'hint' => 'Needs a terminal of at least 80×24 cells.', | ||
| 39 | ], | ||
| 40 | 'server' => [ | ||
| 41 | 'title' => 'Server', | ||
| 42 | 'hint' => 'Generates its own TLS identity on first run.', | ||
| 43 | ], | ||
| 44 | ]; | ||
| 45 | 18 | ||
| 46 | const PLATFORMS = [ | 19 | $base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/')), '/'); |
| 47 | 'linux' => 'Linux', | 20 | if ($base === '.' || $base === '/') { |
| 48 | 'windows' => 'Windows', | 21 | $base = ''; |
| 49 | 'macos' => 'macOS', | 22 | } |
| 50 | ]; | ||
| 51 | 23 | ||
| 52 | const ARCHES = [ | 24 | foreach ($releases as $kind => &$found) { |
| 53 | 'x86_64' => '64-bit', | 25 | foreach ($found as &$release) { |
| 54 | 'i686' => '32-bit', | 26 | $release['href'] = $base . '/bin/' . $kind . '/' . rawurlencode($release['file']); |
| 55 | 'aarch64' => 'ARM64', | 27 | } |
| 56 | ]; | 28 | } |
| 29 | unset($found, $release); | ||
| 57 | 30 | ||
| 58 | // Newest version first, then 64-bit builds before 32-bit. | 31 | $all = array_merge($releases['gui'], $releases['client'], $releases['server']); |
| 59 | const PLATFORM_ORDER = ['linux' => 0, 'windows' => 1, 'macos' => 2]; | ||
| 60 | const ARCH_ORDER = ['x86_64' => 0, 'aarch64' => 1, 'i686' => 2]; | ||
| 61 | 32 | ||
| 62 | function e(?string $value): string | 33 | function e(?string $value): string |
| 63 | { | 34 | { |
| @@ -74,220 +45,6 @@ function human_size(int $bytes): string | |||
| 74 | } | 45 | } |
| 75 | return $bytes . ' B'; | 46 | return $bytes . ' B'; |
| 76 | } | 47 | } |
| 77 | |||
| 78 | /** | ||
| 79 | * Hash to use for a published file, reusing the cached value when the file has | ||
| 80 | * not changed since it was hashed. | ||
| 81 | * | ||
| 82 | * A cached hash is valid only when the size and mtime still match and the hash | ||
| 83 | * was taken at or after the file's mtime, so a restored or touched file is | ||
| 84 | * re-read. | ||
| 85 | * | ||
| 86 | * @param array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} $cache | ||
| 87 | */ | ||
| 88 | function cached_hash( | ||
| 89 | string $key, | ||
| 90 | string $path, | ||
| 91 | int $size, | ||
| 92 | int $mtime, | ||
| 93 | array &$cache, | ||
| 94 | bool &$dirty | ||
| 95 | ): ?string { | ||
| 96 | $entry = $cache['files'][$key] ?? null; | ||
| 97 | if ( | ||
| 98 | is_array($entry) | ||
| 99 | && ($entry['size'] ?? null) === $size | ||
| 100 | && ($entry['mtime'] ?? null) === $mtime | ||
| 101 | && (int) ($entry['hashed_at'] ?? 0) >= $mtime | ||
| 102 | && isset($entry['sha256']) | ||
| 103 | && is_string($entry['sha256']) | ||
| 104 | && preg_match('/\A[0-9a-f]{64}\z/', $entry['sha256']) === 1 | ||
| 105 | ) { | ||
| 106 | return $entry['sha256']; | ||
| 107 | } | ||
| 108 | |||
| 109 | $hash = hash_file('sha256', $path); | ||
| 110 | if ($hash === false) { | ||
| 111 | return null; | ||
| 112 | } | ||
| 113 | |||
| 114 | $cache['files'][$key] = [ | ||
| 115 | 'size' => $size, | ||
| 116 | 'mtime' => $mtime, | ||
| 117 | 'sha256' => $hash, | ||
| 118 | 'hashed_at' => time(), | ||
| 119 | ]; | ||
| 120 | $dirty = true; | ||
| 121 | |||
| 122 | return $hash; | ||
| 123 | } | ||
| 124 | |||
| 125 | /** @return array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} */ | ||
| 126 | function load_hash_cache(string $path): array | ||
| 127 | { | ||
| 128 | $raw = @file_get_contents($path); | ||
| 129 | if ($raw === false || $raw === '') { | ||
| 130 | return ['files' => []]; | ||
| 131 | } | ||
| 132 | $decoded = json_decode($raw, true); | ||
| 133 | if (!is_array($decoded) || !isset($decoded['files']) || !is_array($decoded['files'])) { | ||
| 134 | return ['files' => []]; | ||
| 135 | } | ||
| 136 | |||
| 137 | return ['files' => $decoded['files']]; | ||
| 138 | } | ||
| 139 | |||
| 140 | /** | ||
| 141 | * Best effort: a read-only web root, a full disk or a racing writer must never | ||
| 142 | * break the page, so failures are ignored and the next request recomputes. | ||
| 143 | * | ||
| 144 | * @param array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} $cache | ||
| 145 | */ | ||
| 146 | function save_hash_cache(string $path, array $cache): void | ||
| 147 | { | ||
| 148 | $json = json_encode($cache, JSON_UNESCAPED_SLASHES); | ||
| 149 | if ($json === false) { | ||
| 150 | return; | ||
| 151 | } | ||
| 152 | $directory = dirname($path); | ||
| 153 | $temporary = @tempnam($directory, '.zagan-hashes-'); | ||
| 154 | if ($temporary === false) { | ||
| 155 | return; | ||
| 156 | } | ||
| 157 | if (@file_put_contents($temporary, $json, LOCK_EX) === false || !@rename($temporary, $path)) { | ||
| 158 | @unlink($temporary); | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | /** | ||
| 163 | * @return array{version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null | ||
| 164 | */ | ||
| 165 | function read_release( | ||
| 166 | string $kind, | ||
| 167 | string $directory, | ||
| 168 | string $entry, | ||
| 169 | array &$cache, | ||
| 170 | bool &$dirty | ||
| 171 | ): ?array { | ||
| 172 | $pattern = '/^zagan-bet-' . preg_quote($kind, '/') | ||
| 173 | . '-v?(\d+\.\d+\.\d+)-([a-z]+)-([a-z0-9_]+)(-install\.sh|-install\.ps1|\.exe)?$/i'; | ||
| 174 | if (preg_match($pattern, $entry, $matches) !== 1) { | ||
| 175 | return null; | ||
| 176 | } | ||
| 177 | |||
| 178 | $realDirectory = realpath($directory); | ||
| 179 | $real = realpath($directory . '/' . $entry); | ||
| 180 | if ($realDirectory === false || $real === false) { | ||
| 181 | return null; | ||
| 182 | } | ||
| 183 | // Never serve anything that resolves outside the published directory. | ||
| 184 | if (!str_starts_with($real, $realDirectory . DIRECTORY_SEPARATOR)) { | ||
| 185 | return null; | ||
| 186 | } | ||
| 187 | if (!is_file($real)) { | ||
| 188 | return null; | ||
| 189 | } | ||
| 190 | |||
| 191 | $size = filesize($real); | ||
| 192 | $mtime = filemtime($real); | ||
| 193 | if ($size === false || $mtime === false) { | ||
| 194 | return null; | ||
| 195 | } | ||
| 196 | |||
| 197 | $hash = cached_hash($kind . '/' . $entry, $real, $size, $mtime, $cache, $dirty); | ||
| 198 | if ($hash === null) { | ||
| 199 | return null; | ||
| 200 | } | ||
| 201 | |||
| 202 | $suffix = strtolower($matches[4] ?? ''); | ||
| 203 | $variant = str_starts_with($suffix, '-install.') ? 'installer' : 'binary'; | ||
| 204 | |||
| 205 | return [ | ||
| 206 | 'version' => $matches[1], | ||
| 207 | 'platform' => strtolower($matches[2]), | ||
| 208 | 'arch' => strtolower($matches[3]), | ||
| 209 | 'variant' => $variant, | ||
| 210 | 'file' => $entry, | ||
| 211 | 'size' => $size, | ||
| 212 | 'mtime' => $mtime, | ||
| 213 | 'sha256' => $hash, | ||
| 214 | ]; | ||
| 215 | } | ||
| 216 | |||
| 217 | $base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/')), '/'); | ||
| 218 | if ($base === '.' || $base === '/') { | ||
| 219 | $base = ''; | ||
| 220 | } | ||
| 221 | |||
| 222 | $cachePath = getenv('ZAGAN_HASH_CACHE'); | ||
| 223 | if (!is_string($cachePath) || $cachePath === '') { | ||
| 224 | $cachePath = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) | ||
| 225 | . DIRECTORY_SEPARATOR . 'zagan-bet-hashes-' | ||
| 226 | . substr(hash('sha256', __DIR__), 0, 16) . '.json'; | ||
| 227 | } | ||
| 228 | $cache = load_hash_cache($cachePath); | ||
| 229 | $cacheDirty = false; | ||
| 230 | $seen = []; | ||
| 231 | |||
| 232 | $releases = []; | ||
| 233 | $newest = 0; | ||
| 234 | |||
| 235 | foreach (KINDS as $kind => $meta) { | ||
| 236 | $found = []; | ||
| 237 | $directory = __DIR__ . '/bin/' . $kind; | ||
| 238 | |||
| 239 | if (is_dir($directory)) { | ||
| 240 | $entries = scandir($directory); | ||
| 241 | if ($entries !== false) { | ||
| 242 | foreach ($entries as $entry) { | ||
| 243 | if ($entry === '.' || $entry === '..') { | ||
| 244 | continue; | ||
| 245 | } | ||
| 246 | $release = read_release($kind, $directory, $entry, $cache, $cacheDirty); | ||
| 247 | if ($release === null) { | ||
| 248 | continue; | ||
| 249 | } | ||
| 250 | $seen[$kind . '/' . $entry] = true; | ||
| 251 | $release['href'] = $base . '/bin/' . $kind . '/' . rawurlencode($release['file']); | ||
| 252 | $found[] = $release; | ||
| 253 | $newest = max($newest, $release['mtime']); | ||
| 254 | } | ||
| 255 | } | ||
| 256 | } | ||
| 257 | |||
| 258 | usort($found, static function (array $a, array $b): int { | ||
| 259 | $byVersion = version_compare($b['version'], $a['version']); | ||
| 260 | if ($byVersion !== 0) { | ||
| 261 | return $byVersion; | ||
| 262 | } | ||
| 263 | $byPlatform = (PLATFORM_ORDER[$a['platform']] ?? 9) <=> (PLATFORM_ORDER[$b['platform']] ?? 9); | ||
| 264 | if ($byPlatform !== 0) { | ||
| 265 | return $byPlatform; | ||
| 266 | } | ||
| 267 | $byArch = (ARCH_ORDER[$a['arch']] ?? 9) <=> (ARCH_ORDER[$b['arch']] ?? 9); | ||
| 268 | if ($byArch !== 0) { | ||
| 269 | return $byArch; | ||
| 270 | } | ||
| 271 | $byVariant = ($a['variant'] === 'installer' ? 1 : 0) <=> ($b['variant'] === 'installer' ? 1 : 0); | ||
| 272 | if ($byVariant !== 0) { | ||
| 273 | return $byVariant; | ||
| 274 | } | ||
| 275 | return strcmp($a['file'], $b['file']); | ||
| 276 | }); | ||
| 277 | |||
| 278 | $releases[$kind] = $found; | ||
| 279 | } | ||
| 280 | |||
| 281 | // Drop cache entries for files that are no longer published. | ||
| 282 | foreach (array_keys($cache['files']) as $key) { | ||
| 283 | if (!isset($seen[$key])) { | ||
| 284 | unset($cache['files'][$key]); | ||
| 285 | $cacheDirty = true; | ||
| 286 | } | ||
| 287 | } | ||
| 288 | if ($cacheDirty) { | ||
| 289 | save_hash_cache($cachePath, $cache); | ||
| 290 | } | ||
| 291 | ?> | 48 | ?> |
| 292 | <!doctype html> | 49 | <!doctype html> |
| 293 | <html lang="en"> | 50 | <html lang="en"> |
| @@ -308,24 +65,20 @@ if ($cacheDirty) { | |||
| 308 | 65 | ||
| 309 | <h2>Desktop app</h2> | 66 | <h2>Desktop app</h2> |
| 310 | <p>Windows 10 or newer, or a Linux desktop. No terminal needed.</p> | 67 | <p>Windows 10 or newer, or a Linux desktop. No terminal needed.</p> |
| 311 | <?php $guiBinaries = array_values(array_filter($releases['gui'], static fn (array $r): bool => $r['variant'] === 'binary')); ?> | 68 | <?php if ($releases['gui'] === []): ?> |
| 312 | <?php $guiInstallers = array_values(array_filter($releases['gui'], static fn (array $r): bool => $r['variant'] === 'installer')); ?> | ||
| 313 | <?php if ($guiBinaries === []): ?> | ||
| 314 | <p>Nothing published yet.</p> | 69 | <p>Nothing published yet.</p> |
| 315 | <?php else: ?> | 70 | <?php else: ?> |
| 316 | <ul> | 71 | <ul> |
| 317 | <?php foreach ($guiBinaries as $release): ?> | 72 | <?php foreach ($releases['gui'] as $release): ?> |
| 318 | <li><a href="<?= e($release['href']) ?>" download><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?> <?= e($release['arch']) ?><?php if (isset(ARCHES[$release['arch']])): ?> (<?= e(ARCHES[$release['arch']]) ?>)<?php endif; ?> v<?= e($release['version']) ?> — <?= e(human_size($release['size'])) ?></a></li> | 73 | <li><a href="<?= e($release['href']) ?>" download><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?> <?= e($release['arch']) ?><?php if (isset(ARCHES[$release['arch']])): ?> (<?= e(ARCHES[$release['arch']]) ?>)<?php endif; ?> v<?= e($release['version']) ?> — <?= e(human_size($release['size'])) ?></a></li> |
| 319 | <?php endforeach; ?> | 74 | <?php endforeach; ?> |
| 320 | </ul> | 75 | </ul> |
| 321 | <?php endif; ?> | 76 | |
| 322 | <?php if ($guiInstallers !== []): ?> | ||
| 323 | <h3>Installers</h3> | 77 | <h3>Installers</h3> |
| 324 | <p>The installer checks the download, installs Zagan-Bet for your user and adds it to your programs. Run it next to a downloaded app, or on its own to fetch one.</p> | 78 | <p>Downloads the newest build, checks its SHA-256 and installs it for your user.</p> |
| 325 | <ul> | 79 | <ul> |
| 326 | <?php foreach ($guiInstallers as $release): ?> | 80 | <li><a href="<?= e($base) ?>/install.sh" download>Linux installer (bash)</a></li> |
| 327 | <li><a href="<?= e($release['href']) ?>" download><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?> <?= e($release['arch']) ?> installer (<?= str_ends_with($release['file'], '.ps1') ? 'PowerShell' : 'bash' ?>) v<?= e($release['version']) ?> — <?= e(human_size($release['size'])) ?></a></li> | 81 | <li><a href="<?= e($base) ?>/install.ps1" download>Windows installer (PowerShell)</a></li> |
| 328 | <?php endforeach; ?> | ||
| 329 | </ul> | 82 | </ul> |
| 330 | <?php endif; ?> | 83 | <?php endif; ?> |
| 331 | 84 | ||
| @@ -353,7 +106,6 @@ if ($cacheDirty) { | |||
| 353 | </ul> | 106 | </ul> |
| 354 | <?php endif; ?> | 107 | <?php endif; ?> |
| 355 | 108 | ||
| 356 | <?php $all = array_merge($releases['gui'], $releases['client'], $releases['server']); ?> | ||
| 357 | <?php if ($all !== []): ?> | 109 | <?php if ($all !== []): ?> |
| 358 | <h2>SHA-256</h2> | 110 | <h2>SHA-256</h2> |
| 359 | <p>On Linux use <code>sha256sum <file></code>. On Windows use <code>Get-FileHash <file></code>.</p> | 111 | <p>On Linux use <code>sha256sum <file></code>. On Windows use <code>Get-FileHash <file></code>.</p> |
| @@ -372,11 +124,11 @@ echo e($sums); | |||
| 372 | 124 | ||
| 373 | <h2>Running it</h2> | 125 | <h2>Running it</h2> |
| 374 | <p>Desktop app on Linux:</p> | 126 | <p>Desktop app on Linux:</p> |
| 375 | <pre><code>chmod +x zagan-bet-gui-*-linux-x86_64-install.sh | 127 | <pre><code>chmod +x install.sh |
| 376 | ./zagan-bet-gui-*-linux-x86_64-install.sh</code></pre> | 128 | ./install.sh</code></pre> |
| 377 | <p>Desktop app on Windows (PowerShell):</p> | 129 | <p>Desktop app on Windows (PowerShell):</p> |
| 378 | <pre><code>powershell -ExecutionPolicy Bypass -File .\zagan-bet-gui-*-windows-x86_64-install.ps1</code></pre> | 130 | <pre><code>powershell -ExecutionPolicy Bypass -File .\install.ps1</code></pre> |
| 379 | <p>The installers verify the SHA-256 before installing. You can also run the downloaded app directly.</p> | 131 | <p>The installers fetch the newest build, verify its SHA-256, install it for your user and add a launcher.</p> |
| 380 | <p>Terminal client:</p> | 132 | <p>Terminal client:</p> |
| 381 | <pre><code>chmod +x <downloaded-client> | 133 | <pre><code>chmod +x <downloaded-client> |
| 382 | ./<downloaded-client></code></pre> | 134 | ./<downloaded-client></code></pre> |
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. | 9 | param( |
| 10 | param([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 | |||
| 19 | function Finish([int]$Code) { | 16 | function 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 | ||
| 28 | try { | ||
| 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 | ||
| 37 | if (-not $line) { | ||
| 38 | Stop-Install "no Windows build is published yet" | ||
| 39 | } | ||
| 40 | |||
| 41 | $parts = $line.Trim() -split '\s+' | ||
| 42 | if ($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 | |||
| 50 | Write-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. |
| 7 | set -euo pipefail | 7 | set -euo pipefail |
| 8 | 8 | ||
| 9 | VERSION="__VERSION__" | 9 | SITE=${ZAGAN_SITE:-https://zagan.2137697.xyz} |
| 10 | BINARY="__BINARY__" | 10 | PLATFORM=linux |
| 11 | SHA256="__SHA256__" | 11 | ARCH=x86_64 |
| 12 | URL="__URL__" | ||
| 13 | 12 | ||
| 14 | say() { printf '%s\n' "$*"; } | 13 | say() { printf '%s\n' "$*"; } |
| 15 | die() { printf 'install: %s\n' "$*" >&2; exit 1; } | 14 | die() { printf 'install: %s\n' "$*" >&2; exit 1; } |
| 16 | 15 | ||
| 16 | command -v curl >/dev/null 2>&1 || die "curl is required" | ||
| 17 | command -v sha256sum >/dev/null 2>&1 || die "sha256sum is required" | 17 | command -v sha256sum >/dev/null 2>&1 || die "sha256sum is required" |
| 18 | 18 | ||
| 19 | latest=$(curl -fsSL --max-time 30 "$SITE/latest.php") \ | ||
| 20 | || die "could not read $SITE/latest.php" | ||
| 21 | line=$(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 | |||
| 25 | version=$(printf '%s\n' "$line" | awk '{print $4}') | ||
| 26 | binary=$(printf '%s\n' "$line" | awk '{print $5}') | ||
| 27 | sha=$(printf '%s\n' "$line" | awk '{print $6}') | ||
| 28 | [ -n "$version" ] && [ -n "$binary" ] && [ -n "$sha" ] \ | ||
| 29 | || die "the latest-build file is malformed" | ||
| 30 | |||
| 31 | say "Newest Zagan-Bet is $version." | ||
| 32 | |||
| 19 | here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) | 33 | here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) |
| 20 | source_file="" | 34 | source_file="" |
| 21 | for candidate in "$here/$BINARY" "./$BINARY" "$HOME/Downloads/$BINARY"; do | 35 | for 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 | ||
| 28 | cleanup="" | 42 | cleanup="" |
| 29 | if [ -z "$source_file" ]; then | 43 | if [ -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 |
| 35 | fi | 49 | fi |
| 36 | trap 'if [ -n "$cleanup" ]; then rm -f "$cleanup"; fi' EXIT | 50 | trap 'if [ -n "$cleanup" ]; then rm -f "$cleanup"; fi' EXIT |
| 37 | 51 | ||
| 38 | actual=$(sha256sum "$source_file" | awk '{print $1}') | 52 | actual=$(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 | ||
| 41 | bin_dir="${XDG_BIN_HOME:-$HOME/.local/bin}" | 56 | bin_dir="${XDG_BIN_HOME:-$HOME/.local/bin}" |
| 42 | data_dir="${XDG_DATA_HOME:-$HOME/.local/share}" | 57 | data_dir="${XDG_DATA_HOME:-$HOME/.local/share}" |
| @@ -48,7 +63,12 @@ mkdir -p "$bin_dir" "$desktop_dir" "$icon_dir" | |||
| 48 | install -m 0755 "$source_file" "$app" | 63 | install -m 0755 "$source_file" "$app" |
| 49 | 64 | ||
| 50 | cat > "$icon_dir/zagan-bet.svg" <<'ICON' | 65 | cat > "$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> | ||
| 52 | ICON | 72 | ICON |
| 53 | 73 | ||
| 54 | cat > "$desktop_dir/zagan-bet.desktop" <<DESKTOP | 74 | cat > "$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 |
| 71 | fi | 91 | fi |
| 72 | 92 | ||
| 73 | say "Installed Zagan-Bet $VERSION to $app" | 93 | say "Installed Zagan-Bet $version to $app" |
| 74 | say "A launcher was added to your applications menu." | 94 | say "A launcher was added to your applications menu." |
| 75 | case ":$PATH:" in | 95 | case ":$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 | ||
| 2 | declare(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 | |||
| 20 | require __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'; | ||
| 30 | foreach ($all as $release) { | ||
| 31 | if (version_compare($release['version'], $newest, '>')) { | ||
| 32 | $newest = $release['version']; | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | header('Content-Type: text/plain; charset=utf-8'); | ||
| 37 | header('Cache-Control: no-cache'); | ||
| 38 | |||
| 39 | echo 'version ' . $newest . "\n"; | ||
| 40 | foreach ($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 | ||
| 2 | declare(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 | |||
| 22 | const 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 | |||
| 37 | const PLATFORMS = [ | ||
| 38 | 'linux' => 'Linux', | ||
| 39 | 'windows' => 'Windows', | ||
| 40 | 'macos' => 'macOS', | ||
| 41 | ]; | ||
| 42 | |||
| 43 | const 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. | ||
| 50 | const PLATFORM_ORDER = ['linux' => 0, 'windows' => 1, 'macos' => 2]; | ||
| 51 | const 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 | */ | ||
| 63 | function 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}>} */ | ||
| 101 | function 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 | */ | ||
| 121 | function 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 | */ | ||
| 140 | function 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 | */ | ||
| 194 | function 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 | } | ||
