Author SHA1 Message Date
gitea-actionsandClaude Opus 5 85b6618952 binárka ke stažení a jasné místo pro databázi
Stáhni a spusť: jednosouborová binárka pro Linux, uvnitř Python, Flask,
šablony i styly. Staví se ./build.sh v kontejneru na AlmaLinux 8, protože
binárka slinkovaná proti glibc z Fedory 44 by nešla spustit nikde se
starším systémem.

Databáze tím dostává dvě různá místa. Ze zdrojáků zůstává dice.db
v pracovním adresáři, ať se dá mít víc sad vedle sebe. Z binárky jde do
~/.local/share/kostky/ — binárka se rozbaluje do dočasného adresáře
a pouští se odkudkoli, takže relativní cesta by databázi rozsypala po
disku. DICE_DB přebije obojí.

Server teď cestu k databázi vypisuje při startu, ať se na ni nemusí ptát.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:12:25 +02:00
38 changed files with 113 additions and 2251 deletions

No files matched your search

-20
View File
@@ -1,20 +0,0 @@
# Do image patří jen to, co appka za běhu potřebuje.
.git/
.github/
.gitea/
.venv/
.build/
.pytest_cache/
__pycache__/
dist/
tests/
deploy/
*.db
*.spec
build.sh
dice.py
verze.txt
README.md
requirements*.in
requirements-dev.txt
requirements-build.txt
-127
View File
@@ -1,127 +0,0 @@
# Postaví binárku a pověsí ji na release.
#
# Runner běží v host módu, takže tu není kontejner ani hotové akce
# z marketplace — nic se nespoléhá na to, co má hostitel nainstalováno.
# O starou glibc se stará build.sh přes samostatný CPython od uv.
name: binárka
on:
push:
tags: ["v*"]
pull_request:
workflow_dispatch:
jobs:
linux:
runs-on: homelab
steps:
- name: objdump
run: |
# Jediné, co musí být na hostiteli — PyInstaller jím hledá, na
# kterých knihovnách binárka visí. Zbytek si obstará uv.
command -v objdump >/dev/null && exit 0
sudo=""; [ "$(id -u)" -ne 0 ] && sudo=sudo
if command -v apt-get >/dev/null; then
$sudo apt-get update -qq
$sudo apt-get install -y -qq binutils
elif command -v dnf >/dev/null; then
$sudo dnf install -y -q binutils
else
echo "Nainstaluj binutils, PyInstaller bez objdumpu nestaví." >&2
exit 1
fi
- name: Zdrojáky
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git init -q .
git remote remove origin 2>/dev/null || true
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git config http.extraheader "Authorization: token ${TOKEN}"
git fetch -q --depth 1 origin "${GITHUB_REF}"
git config --unset http.extraheader
git checkout -qf FETCH_HEAD
git clean -qfd
- name: uv
run: |
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
uv --version
- name: Testy
run: uv run --with-requirements requirements-dev.txt --no-project pytest -q
- name: Binárka
run: |
# Runner klonuje jeden commit bez značek, takže si git describe
# nemá o co opřít — na tagu mu verzi řekneme rovnou.
if [ "$GITHUB_REF_TYPE" = tag ]; then
export VERSION="$GITHUB_REF_NAME"
fi
./build.sh
- name: Zkouška
run: |
# Vlastní adresář pro každý běh. Runner si /tmp mezi běhy drží
# a binárka umí sáhnout po databázi ve starém umístění, takže
# zbytek po dřívější zkoušce by tuhle poslal vedle.
XDG=$(mktemp -d)
XDG_DATA_HOME="$XDG" DICE_PORT=8321 DICE_HOST=127.0.0.1 \
./dist/dice-counter > /tmp/server.log 2>&1 &
server=$!
trap 'kill $server 2>/dev/null || true; rm -rf "$XDG"' EXIT
for _ in $(seq 30); do
curl -sf -o /dev/null http://127.0.0.1:8321/ && break
sleep 1
done
curl -sf -o /dev/null http://127.0.0.1:8321/
curl -sf -o /dev/null http://127.0.0.1:8321/static/style.css
curl -sf -o /dev/null http://127.0.0.1:8321/board
test -f "$XDG/dice-counter/dice.db"
./dist/dice-counter --version
cat /tmp/server.log
- name: Na release
if: startsWith(github.ref, 'refs/tags/')
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET: dice-counter-linux-x86_64
run: |
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
auth="Authorization: token ${TOKEN}"
# Release u tagu být může, ale nemusí — dá se založit i ručně.
id=$(curl -s -H "$auth" "$api/releases/tags/${GITHUB_REF_NAME}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))' || true)
if [ -z "$id" ]; then
id=$(curl -sf -X POST -H "$auth" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" \
"$api/releases" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
fi
# Součet se počítá nad jménem, pod kterým se soubor stahuje, aby
# šel ověřit rovnou: sha256sum -c dice-counter-linux-x86_64.sha256
cp dist/dice-counter "$ASSET"
sha256sum "$ASSET" | tee "$ASSET.sha256"
for file in "$ASSET" "$ASSET.sha256"; do
# Aby se dal build zopakovat, stará příloha stejného jména jde pryč.
curl -s -H "$auth" "$api/releases/$id/assets" \
| python3 -c 'import json,sys; [print(a["id"]) for a in json.load(sys.stdin) if a["name"] == sys.argv[1]]' "$file" \
| while read -r old; do
curl -sf -X DELETE -H "$auth" "$api/releases/$id/assets/$old"
done
curl -sf -o /dev/null -X POST -H "$auth" -F "attachment=@$file" \
"$api/releases/$id/assets?name=$file"
echo "$file pověšen na ${GITHUB_REF_NAME}"
done
-85
View File
@@ -1,85 +0,0 @@
# Postaví kontejnerový image a zkusí ho spustit. Nikam ho neposílá.
#
# Dockerfile by se jinak rozbil až ve chvíli, kdy se tagne release — tohle ho
# ohlídá nad každým pull requestem.
#
# Publikuje se jinde: .github/workflows/image.yml. Zdejší runner běží v LXC,
# kde nejde zaregistrovat binfmt, takže arm64 by se tu postavit nedalo.
# Registrace je vlastnost jádra hostitele a to je produkční stroj, do kterého
# se kvůli počitadlu kostek vrtat nebude.
#
# Staví se nativní amd64 obyčejným `docker build`, žádný buildx ani emulace.
name: image
on:
pull_request:
workflow_dispatch:
env:
ZKOUSKA_PORT: "18000"
jobs:
image:
runs-on: homelab
steps:
- name: Zdrojáky
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git init -q .
git remote remove origin 2>/dev/null || true
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git config http.extraheader "Authorization: token ${TOKEN}"
git fetch -q --depth 1 origin "${GITHUB_REF}"
git config --unset http.extraheader
git checkout -qf FETCH_HEAD
git clean -qfd
- name: Předpoklady
run: |
command -v docker >/dev/null \
|| { echo "Docker na runneru není." >&2; exit 1; }
docker version --format '{{.Server.Version}}'
- name: Stavba
run: |
docker build -f deploy/Dockerfile \
--build-arg VERSION="zkouska-${GITHUB_SHA:0:7}" \
-t dice-counter:zkouska .
- name: Zkouška
run: |
trap 'docker rm -f dice-counter-zkouska >/dev/null 2>&1 || true' EXIT
# Port musí být stejný uvnitř i venku: appka poslouchá na DICE_PORT,
# takže mapovat ho na jiný by znamenalo mířit do prázdna.
# Adresa se vnucuje, ať zkouška nezávisí na síti runneru a dá se
# ověřit, co přesně tabule nabídne.
docker run -d --name dice-counter-zkouska --init \
-p "${ZKOUSKA_PORT}:${ZKOUSKA_PORT}" -e DICE_HOST=127.0.0.1 \
-e DICE_PORT="${ZKOUSKA_PORT}" dice-counter:zkouska
for _ in $(seq 30); do
curl -sf -o /dev/null "http://127.0.0.1:${ZKOUSKA_PORT}/" && break
sleep 1
done
curl -sf -o /dev/null "http://127.0.0.1:${ZKOUSKA_PORT}/" || {
echo "Kontejner se nerozeběhl." >&2
docker logs dice-counter-zkouska >&2
exit 1; }
curl -sf -o /dev/null "http://127.0.0.1:${ZKOUSKA_PORT}/static/style.css"
curl -sf "http://127.0.0.1:${ZKOUSKA_PORT}/board" | grep -q "127.0.0.1:${ZKOUSKA_PORT}"
curl -sf -o /dev/null "http://127.0.0.1:${ZKOUSKA_PORT}/export"
# Verze se musí do image opravdu dostat, ne se hlásit jako neznámá.
docker run --rm dice-counter:zkouska python web.py --version
! docker run --rm dice-counter:zkouska python web.py --version | grep -q "neznámá"
docker logs dice-counter-zkouska
echo "Image jede."
- name: Úklid
if: always()
run: docker rmi dice-counter:zkouska >/dev/null 2>&1 || true
-69
View File
@@ -1,69 +0,0 @@
# Testy nad každým pull requestem a nad main.
#
# Runner běží v host módu, takže tu není kontejner ani hotové akce
# z marketplace. Pythony obstará uv, na hostiteli nemusí být žádný.
name: testy
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: testy-${{ github.ref }}
cancel-in-progress: true
jobs:
pytest:
runs-on: homelab
strategy:
fail-fast: false
# Runner sdílí pracovní adresář mezi variantami, takže po jedné.
max-parallel: 1
matrix:
python: ["3.11", "3.12", "3.13", "3.14"]
steps:
- name: Zdrojáky
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git init -q .
git remote remove origin 2>/dev/null || true
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git config http.extraheader "Authorization: token ${TOKEN}"
git fetch -q --depth 1 origin "${GITHUB_REF}"
git config --unset http.extraheader
git checkout -qf FETCH_HEAD
git clean -qfd
- name: uv
run: |
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
uv python install "${{ matrix.python }}"
- name: Testy
run: |
uv run --managed-python --python "${{ matrix.python }}" \
--with-requirements requirements-dev.txt --no-project \
pytest -q
- name: Terminálová verze
run: |
# Pytest sahá na core.py, ne na prompty. Tohle projde celou partii
# tak, jak ji odehraje člověk: dvě nuly, třetí vynuluje, výhra.
out=$(printf 'A\nB\n\n1000\n\n200\n10\n0\n10\n0\n10\n0\n\n1000\n' \
| TERM=dumb uv run --managed-python --python "${{ matrix.python }}" dice.py)
for expected in "200xx" "Po třech nulových hodech" "Vítězem se stává B"; do
printf '%s' "$out" | grep -qF "$expected" || {
echo "chybí: $expected"
printf '%s\n' "$out"
exit 1
}
done
echo "terminálová verze v pořádku"
-142
View File
@@ -1,142 +0,0 @@
# Binárky pro Windows, macOS a Linux na ARM.
#
# PyInstaller neumí křížovou kompilaci: binárku pro každý systém musí postavit
# ten systém. Kupovat kvůli tomu Mac nebo krmit Windows VM by bylo přehnané,
# tak si tyhle stroje půjčujeme od GitHub Actions.
#
# GitHub je tu jen půjčená dílna, ne druhý domov projektu: repozitář se sem
# z Gitey zrcadlí a hotové soubory se posílají zpátky na tamní release přes
# Gitea API. Jedna stránka s releasy, ta původní.
#
# Linux na x86-64 se tu schválně nestaví — o ten se stará vlastní runner
# (.gitea/workflows/binarka.yml). Když GitHub vypadne, release má pořád tu
# platformu, na které to reálně poběží. ARM doma není na čem postavit, takže
# ten jediný linuxový sem patří.
name: binárky
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
env:
GITEA: https://gitea.spacilovi.eu
GITEA_REPO: david-spacil/dice-counter
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
asset: dice-counter-windows-x86_64.exe
built: dist/dice-counter.exe
- os: macos-15-intel # Intel; macos-13 GitHub zrušil
asset: dice-counter-macos-x86_64
built: dist/dice-counter
- os: macos-latest # Apple Silicon
asset: dice-counter-macos-arm64
built: dist/dice-counter
- os: ubuntu-24.04-arm # pro Raspberry Pi a spol.
asset: dice-counter-linux-arm64
built: dist/dice-counter
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
# Přesné verze, ne plovoucí `v7`. Ta u setup-uv nevede tam, kam by
# člověk čekal — hlavní značky se přestaly vydávat po v7, takže `v10`
# neexistuje, i když vydání v10.0.1 ano.
- uses: actions/checkout@v7.0.1
- uses: astral-sh/setup-uv@v10.0.1
- name: Testy
run: uv run --with-requirements requirements-dev.txt --no-project pytest -q
- name: Binárka
run: |
# Na tagu je verze jistá. Jinde je repozitář naklonovaný mělce
# a build.sh si vystačí s tím, co z gitu vyčte.
if [ "$GITHUB_REF_TYPE" = tag ]; then
export VERSION="$GITHUB_REF_NAME"
fi
./build.sh
- name: Zkouška
run: |
# Binárka se musí umět rozbalit, najít šablony a začít poslouchat.
# Adresa se vnucuje, ať zkouška nezávisí na síti běhounu.
export DICE_HOST=127.0.0.1 DICE_PORT=8123 DICE_DB=zkouska/dice.db
"${{ matrix.built }}" &
for _ in $(seq 30); do
curl -sf -o /dev/null "http://127.0.0.1:8123/" && break
sleep 1
done
curl -sf -o /dev/null "http://127.0.0.1:8123/" || {
echo "Server se nerozeběhl." >&2; exit 1; }
curl -sf -o /dev/null "http://127.0.0.1:8123/static/style.css"
curl -sf "http://127.0.0.1:8123/board" | grep -q "127.0.0.1:8123"
test -f zkouska/dice.db
# Verze se musí do binárky opravdu dostat, ne se hlásit jako neznámá.
"${{ matrix.built }}" --version
! "${{ matrix.built }}" --version | grep -q "neznámá"
echo "Binárka jede."
- name: Na release
if: startsWith(github.ref, 'refs/tags/')
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
ASSET: ${{ matrix.asset }}
run: |
api="$GITEA/api/v1/repos/$GITEA_REPO"
auth="Authorization: token $TOKEN"
json() { uv run --no-project python -c "$@"; }
# Release u tagu být může, ale nemusí — dá se založit i ručně a
# sahají na něj čtyři buildy naráz, tak se po neúspěchu ptáme znovu.
for _ in 1 2 3; do
id=$(curl -s -H "$auth" "$api/releases/tags/${GITHUB_REF_NAME}" \
| json 'import json,sys; print(json.load(sys.stdin).get("id",""))')
[ -n "$id" ] && break
curl -s -o /dev/null -X POST -H "$auth" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" \
"$api/releases"
done
[ -n "$id" ] || { echo "Release ${GITHUB_REF_NAME} se nepodařilo najít." >&2; exit 1; }
# macOS zná jen shasum, Git Bash na Windows jen sha256sum. Formát
# výsledku je stejný, takže se dá ověřit kterýmkoli z nich.
if command -v sha256sum >/dev/null; then
soucet() { sha256sum "$@"; }
else
soucet() { shasum -a 256 "$@"; }
fi
# Součet se počítá nad jménem, pod kterým se soubor stahuje, aby
# šel ověřit rovnou: shasum -a 256 -c dice-counter-macos-arm64.sha256
cp "${{ matrix.built }}" "$ASSET"
soucet "$ASSET" | tee "$ASSET.sha256"
for file in "$ASSET" "$ASSET.sha256"; do
# Aby se dal build zopakovat, stará příloha stejného jména jde pryč.
curl -s -H "$auth" "$api/releases/$id/assets" \
| json 'import json,sys; [print(a["id"]) for a in json.load(sys.stdin) if a["name"] == sys.argv[1]]' "$file" \
| while read -r old; do
curl -sf -X DELETE -H "$auth" "$api/releases/$id/assets/$old"
done
curl -sf -o /dev/null -X POST -H "$auth" -F "attachment=@$file" \
"$api/releases/$id/assets?name=$file"
echo "$file pověšen na ${GITHUB_REF_NAME}"
done
-79
View File
@@ -1,79 +0,0 @@
# Publikuje kontejnerový image do registru balíčků Gitey, pro amd64 i arm64.
#
# Proč zrovna odsud: domácí runner běží v LXC, kde nejde zaregistrovat binfmt
# — je to vlastnost jádra hostitele a ten je produkční. Běhouni GitHubu jsou
# plnohodnotné virtuály, kde emulace funguje bez řečí.
#
# Doma se image přesto staví a zkouší nad každým pull requestem
# (.gitea/workflows/image.yml), jen se nikam neposílá. Stejná dělba jako
# u binárek: doma se hlídá, tady se vydává.
name: image
on:
push:
tags: ["v*"]
workflow_dispatch:
env:
REGISTRY: gitea.spacilovi.eu
IMAGE: david-spacil/dice-counter
PLATFORMS: linux/amd64,linux/arm64
jobs:
publikace:
runs-on: ubuntu-latest
steps:
# Přesná verze, ne plovoucí značka — viz binarky.yml.
- uses: actions/checkout@v7.0.1
- name: QEMU pro arm64
run: |
# Bez hotové akce z marketplace, ať není co udržovat. Běhoun je
# virtuál, takže privilegovaný kontejner smí zapisovat do binfmt
# a registrace platí pro celé jádro.
docker run --privileged --rm tonistiigi/binfmt --install arm64
- name: Builder
run: |
# Výchozí ovladač Dockeru multiplatformní stavbu neumí.
docker buildx create --name dice-counter --driver docker-container --use
docker buildx inspect --bootstrap
# Bez tohohle by stavba spadla až na konci a hláška by nic neřekla.
PLATFORMY=$(docker buildx inspect)
echo "$PLATFORMY" | grep -q "linux/arm64" || {
echo "Buildkit nevidí linux/arm64, emulace se nezaregistrovala." >&2
exit 1; }
- name: Publikace
env:
TOKEN: ${{ secrets.PACKAGE_TOKEN }}
# Uživatel v Gitee, ne na GitHubu. Shodou okolností je to totéž.
VLASTNIK: david-spacil
run: |
# Spouští se to i ručně, a to klidně nad větví. Bez tagu není z čeho
# udělat verzi a `:latest` by ukazoval na rozdělanou práci.
[ "$GITHUB_REF_TYPE" = tag ] || {
echo "Publikovat jde jen z tagu, tohle je ${GITHUB_REF_TYPE} ${GITHUB_REF_NAME}." >&2
exit 1; }
# Automatický token na balíčky nestačí ani u Gitey samotné, natož
# odsud — musí to být osobní token s oprávněním write:package.
[ -n "$TOKEN" ] || { echo "Chybí secret PACKAGE_TOKEN." >&2; exit 1; }
echo "$TOKEN" | docker login "$REGISTRY" -u "$VLASTNIK" --password-stdin
trap 'docker logout "$REGISTRY" >/dev/null 2>&1 || true' EXIT
# Značka bez počátečního "v": v repozitáři je v1.2.0, u image se
# čeká 1.2.0. Verze vypálená do image ale zůstává s "v", ať --version
# hlásí totéž co binárky.
VERZE="${GITHUB_REF_NAME#v}"
docker buildx build --platform "$PLATFORMS" --push \
-f deploy/Dockerfile \
--build-arg VERSION="${GITHUB_REF_NAME}" \
-t "$REGISTRY/$IMAGE:$VERZE" \
-t "$REGISTRY/$IMAGE:latest" .
echo "Publikováno: $REGISTRY/$IMAGE:$VERZE a :latest"
docker buildx imagetools inspect "$REGISTRY/$IMAGE:$VERZE"
-2
View File
@@ -4,5 +4,3 @@ __pycache__/
.pytest_cache/
dist/
build/
.build/
verze.txt
+18 -261
View File
@@ -1,4 +1,4 @@
# Dice Counter
# Počitadlo v kostkách
Doprovod k fyzickým kostkám: **telefon zapisuje skóre, notebook ukazuje
zápisník** a hry se pamatují mezi večery.
@@ -8,46 +8,17 @@ ten samý Adam jako z minulého měsíce. Žádné účty, žádná hesla.
## Spuštění
Stáhni binárku pro svůj systém z
[releases](https://gitea.spacilovi.eu/david-spacil/dice-counter/releases):
| Systém | Soubor |
|---|---|
| Linux (x86-64) | `dice-counter-linux-x86_64` |
| Linux (ARM64) | `dice-counter-linux-arm64` |
| macOS (Apple Silicon) | `dice-counter-macos-arm64` |
| macOS (Intel) | `dice-counter-macos-x86_64` |
| Windows (x86-64) | `dice-counter-windows-x86_64.exe` |
Nic se neinstaluje — Python, Flask i šablony jsou uvnitř.
Na Linuxu a macOS:
Stáhni binárku z [releases](https://gitea.spacilovi.eu/david-spacil/dice-counter/releases)
a spusť ji:
```bash
chmod +x dice-counter-*
./dice-counter-linux-x86_64
chmod +x kostky
./kostky
```
Na Windows stačí na `.exe` poklepat.
Linuxová binárka je postavená proti glibc 2.17, takže jede prakticky všude.
### Než to poprvé pustíš
Binárky nejsou podepsané — podpisové certifikáty stojí tisíce ročně a na
počitadlo kostek by to byl nesmysl. Systémy si toho všimnou:
- **macOS** stažený soubor označí za karanténní a odmítne ho spustit. Buď
značku sundej (`xattr -dr com.apple.quarantine dice-counter-macos-arm64`), nebo
soubor stáhni rovnou z terminálu přes `curl -LO` — tudy se karanténa
nenastavuje.
- **Windows** ukáže modré okno SmartScreenu. *Další informace* →
*Přesto spustit*. Občas si postěžuje i antivirus; u zabalených Python
programů je to běžný falešný poplach. Při prvním spuštění se ještě zeptá
brána firewall — bez povolení do místní sítě se telefon nepřipojí.
Kdo tomu nechce věřit, ověří si stažený soubor podle přiloženého součtu
(níž) nebo si ho postaví sám — `./build.sh`, zdrojáky jsou tady celé.
Nic se neinstaluje — Python, Flask i šablony jsou uvnitř. Zatím jen pro Linux
na x86-64; postavené proti glibc 2.28, takže jede na všem od RHEL 8,
Debianu 10 a Ubuntu 20.04 výš.
Ze zdrojáků to je stejně krátké:
@@ -60,20 +31,15 @@ Závislosti si skript nese v hlavičce (PEP 723) a `uv` je obstará sám.
Server vypíše všechny adresy, na kterých je dostupný:
```
dice-counter v1.2.2
Počitadlo je dostupné na:
→ http://10.186.234.182:8000 místní síť
http://100.91.0.24:8000 přes Tailscale
http://172.17.0.1:8000 virtuální síť
```
Kterou verzi máš, řekne i `dice-counter --version`; visí taky v patičce každé
stránky. Když něco nefunguje, je to první věc, na kterou se zeptám.
Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule
nabízí i zbylé adresy — když jedna nefunguje, klikni na jinou a QR kód se
přepne na ni. Zastavuje se `Ctrl+C`.
zobrazuje i zbylé adresy — když jedna nefunguje, zkus další. Zastavuje se
`Ctrl+C`.
| Proměnná | K čemu je | Výchozí |
|---|---|---|
@@ -88,9 +54,7 @@ server vypíše při startu.
| Spuštěno | Databáze |
|---|---|
| binárkou na Linuxu | `~/.local/share/dice-counter/dice.db` |
| binárkou na macOS | `~/Library/Application Support/dice-counter/dice.db` |
| binárkou na Windows | `%LOCALAPPDATA%\dice-counter\dice.db` |
| binárkou | `~/.local/share/kostky/dice.db` |
| ze zdrojáků | `dice.db` v pracovním adresáři |
Binárka se při každém spuštění rozbaluje do dočasného adresáře a pouští se
@@ -99,134 +63,11 @@ napevno domovský adresář. Ze zdrojáků zůstává relativní, ať se dá mí
sad vedle sebe.
Zálohovat i stěhovat jde prostým zkopírováním souboru; `DICE_DB` si ho
najde kdekoli. Kdo ho hledat nechce — a z binárky na Windows je zalezlý —
si ho stáhne ze síně slávy odkazem **Stáhnout databázi** (`/export`). Kopie
se dělá přes `VACUUM INTO`, takže je použitelná i uprostřed rozehrané hry.
Schéma má verzi uloženou v `PRAGMA user_version` a `storage.migrate()` si
databázi při každém připojení dorovná. Starší soubory z verzí, kdy se verze
schématu ještě nepsala, se poznají podle nuly a orazítkují se. Databázi
založenou novější verzí počitadla appka odmítne otevřít, místo aby ji tiše
rozbila.
Obsluhuje to `waitress` — čistě pythonní WSGI server, který se zabalí do
binárky stejně snadno jako zbytek. Vestavěný server Flasku by pod úvodní
hlášku vysypal varování, že takhle se to nemá, a měl by pravdu.
najde kdekoli.
Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi
může zapisovat.
## Nasazení na server
Kdo nechce každý večer spouštět binárku na notebooku, může to nechat běžet
pořád. Obojí níž počítá **jen s domácí sítí** — appka nemá přihlašování
a ani jedno nasazení ji nijak nezabezpečuje. Do internetu to nepatří.
### Proxmox (LXC)
Jeden příkaz v terminálu uzlu Proxmoxu, jako root. Založí kontejner,
nastaví ho, stáhne poslední vydanou binárku, ověří kontrolní součet a zapne
službu:
```bash
bash -c "$(curl -fsSL https://gitea.spacilovi.eu/david-spacil/dice-counter/raw/branch/main/deploy/pve-dice-counter.sh)"
```
Na konci vypíše adresu, na které počitadlo poslouchá. Výchozí nastavení je
1 jádro, 512 MB, 4 GB disku a Debian 13; úložiště si najde samo. Přebíjí se
proměnnými před příkazem:
```bash
MEMORY=1024 STORAGE=local-zfs PORT=8080 bash -c "$(curl -fsSL ...)"
```
| Proměnná | Výchozí |
|---|---|
| `CTID` | první volné číslo |
| `CT_HOSTNAME` | `dice-counter` |
| `CORES`, `MEMORY`, `DISK` | `1`, `512`, `4` |
| `STORAGE` | první aktivní úložiště pro kontejnery |
| `TEMPLATE_STORAGE`, `BRIDGE` | `local`, `vmbr0` |
| `PORT` | `8000` |
| `UNPRIVILEGED`, `OSVERSION` | `1`, `13` |
| `NESTING` | `1` — systemd v Debianu 13 ho potřebuje |
| `AUTOLOGIN` | `1` — konzole ve webu Proxmoxu bez hesla |
| `VERSION` | poslední vydání |
Aktualizace na novější vydání se dá spustit dvěma způsoby. Buď zevnitř
kontejneru, kde na to stačí jedno slovo:
```bash
update
```
Nebo z uzlu Proxmoxu tím samým příkazem s číslem kontejneru:
```bash
CTID=123 MODE=update bash -c "$(curl -fsSL ...)"
```
Obojí vymění binárku za poslední vydání a službu restartuje; databáze zůstává.
Příkaz `update` si přitom obnoví i sám sebe — instalátor bere z větve `main`,
takže jede na nejnovější verzi skriptu, ne na té, se kterou se instalovalo.
Do konzole kontejneru se dostaneš přímo z webu Proxmoxu, **žádné jméno ani
heslo se nezadává** — kontejner root heslo nemá a konzole se přihlašuje sama.
Nová práva to nikomu nedává: kdo se dostane do webu Proxmoxu, má root na uzlu
tak jako tak. Komu se to nezdá, vypne to přes `AUTOLOGIN=0` a do kontejneru
pak leze přes `pct enter <ctid>`.
Podoba je odkoukaná od [community-scripts.org](https://community-scripts.org),
ale nic z jejich frameworku se nestahuje — jejich `build.func` si instalační
skript hledá natvrdo ve vlastním repozitáři, takže mimo něj nefunguje.
Instalátor, který běží uvnitř kontejneru, je ve skriptu vložený jako text —
přes rouru z curlu na disku hostitele žádný druhý soubor není. O Proxmoxu nic
neví, takže se dá použít i v kontejneru, který sis založil sám:
```bash
bash pve-dice-counter.sh instalator > install.sh
```
Je idempotentní; druhé spuštění jen vymění binárku za nejnovější.
### Docker
Stačí jeden soubor, repozitář k tomu potřeba není:
```bash
curl -fsSLO https://gitea.spacilovi.eu/david-spacil/dice-counter/raw/branch/main/deploy/docker-compose.yaml
docker compose up -d
```
Image se stáhne hotový z registru Gitey, pro amd64 i arm64 — Docker si vybere
svoji variantu sám. Databáze leží v pojmenovaném svazku, appka běží pod
nerootovým uživatelem.
Aktualizace na novější vydání:
```bash
docker compose pull && docker compose up -d
```
Kdo si ho radši postaví ze zdrojáků, přebije ten stažený stejnou značkou:
```bash
docker build -f deploy/Dockerfile -t gitea.spacilovi.eu/david-spacil/dice-counter:latest .
```
**Pozor na síť.** Compose schválně používá `network_mode: host`. V bridge
režimu kontejner uvnitř vidí adresu jako `172.17.0.2`, tabule ji poctivě
nabídne a vygeneruje na ni QR kód — jenže z telefonu je nedosažitelná.
Kdo bridge potřebuje, musí nastavit `DICE_HOST` na adresu hostitele v LAN.
| | LXC | Docker |
|---|---|---|
| Odkud | vydaná binárka z releasu | image z registru |
| Databáze | `/var/lib/dice-counter/dice.db` | svazek `dice-counter-data` |
| Aktualizace | `update` uvnitř kontejneru | `docker compose pull` |
| Adresa a QR | funguje samo | potřeba síť hostitele |
## Když se telefon nepřipojí
Adresy se hledají při každém načtení tabule, takže přechod z domácí WiFi na
@@ -260,12 +101,7 @@ při remíze na prvním místě se hraje dál.
| `stats.py` | síň slávy a kariérní statistiky |
| `web.py` | Flask aplikace |
| `dice.py` | totéž v terminálu, bez ukládání |
| `console.py` | aby čeština prošla i windowsovou konzolí |
| `version.py` | která verze to je — z gitu, nebo z binárky |
| `build.sh`, `dice-counter.spec` | stavba binárky |
| `.gitea/workflows/` | testy, linuxová binárka a zkouška image doma |
| `.github/workflows/` | binárky pro Windows a macOS |
| `deploy/` | nasazení na server: Proxmox LXC a Docker |
| `build.sh`, `kostky.spec` | stavba binárky |
Terminálová verze zůstává funkční jako záloha:
@@ -286,98 +122,23 @@ python -m venv .venv
Na Fedoře stačí i systémové balíčky, pak se nemusí řešit vůbec nic:
```bash
sudo dnf install python3-flask python3-qrcode python3-tabulate python3-waitress
sudo dnf install python3-flask python3-qrcode python3-tabulate
python3 web.py
```
## Závislosti
Verze jsou zamčené, aby binárka postavená dnes a za půl roku obsahovala
totéž. Volné seznamy jsou v `.in`, zamčené v `.txt`:
| Soubor | K čemu |
|---|---|
| `requirements.in` → `.txt` | běh aplikace |
| `requirements-dev.in` → `.txt` | + pytest, pro testy |
| `requirements-build.in` → `.txt` | + PyInstaller, pro stavbu binárky |
Aktualizace je vědomý krok, ne vedlejší efekt toho, že něco vyšlo na PyPI:
```bash
for f in requirements requirements-dev requirements-build; do
uv pip compile "$f.in" -o "$f.txt"
done
```
Verze se píšou ještě jednou v hlavičce PEP 723 na začátku `web.py` a
`dice.py`, aby fungovalo `uv run web.py`. Že se ty dva zápisy shodují, hlídá
`tests/test_zavislosti.py` — po každém přegenerování je potřeba hlavičky
srovnat.
## Vlastní binárka
```bash
./build.sh
```
Verzi si `build.sh` vezme z `git describe`, nebo se dá vnutit přes
`VERSION=v1.2.3 ./build.sh`. Zapíše ji do `verze.txt`, `dice-counter.spec` ji
přibalí a binárka ji pak umí ohlásit i na cizím počítači, kde žádný git není.
Verzi Pythonu, proti kterému se staví, přebíjí `PYTHON_VERSION`.
Staví se proti samostatnému CPythonu od `uv`, ne proti systémovému. Ten je
slinkovaný s glibc 2.17; postavené proti Pythonu z Fedory 44 by to chtělo
glibc 2.43 a nešlo by spustit skoro nikde — glibc drží zpětnou kompatibilitu,
ne dopřednou. Nic z hostitele se do binárky nedostane, takže kontejner k tomu
potřeba není. Výsledek je v `dist/`.
Stejný `build.sh` staví i na macOS a na Windows (v Git Bash) — jen tam bez
té starosti o glibc. Křížem to nejde: PyInstaller balí do výsledku interpret
a knihovny toho systému, na kterém běží, takže **binárku pro každý systém
musí postavit ten systém.**
Staví se v kontejneru (podman nebo docker) na AlmaLinux 8, ne na hostiteli.
Binárka slinkovaná proti glibc z Fedory 44 by nešla spustit nikde se starším
systémem — glibc drží zpětnou kompatibilitu, ne dopřednou. Výsledek je
v `dist/kostky`.
Terminálová verze součástí binárky není; ta se pouští ze zdrojáků.
### Jak vznikají releasy
Otagovaný commit spustí stavbu na obou stranách:
| Kde | Co staví | Workflow |
|---|---|---|
| vlastní runner u Gitey | Linux x86-64 | `.gitea/workflows/binarka.yml` |
| GitHub Actions | Windows, macOS ×2, Linux ARM64 | `.github/workflows/binarky.yml` |
| GitHub Actions | image amd64 + arm64 | `.github/workflows/image.yml` |
```bash
git tag v1.0.0 && git push origin v1.0.0
```
GitHub je tu jen půjčená dílna. Repozitář se tam z Gitey zrcadlí
([mirror](https://github.com/david-spacil/dice-counter)), postavené soubory
se posílají zpátky na zdejší release přes Gitea API a projekt má pořád jednu
stránku s releasy — tuhle. Linux na x86-64 se na GitHubu schválně nestaví;
doma to jde proti staré glibc, a když GitHub vypadne, release má aspoň tu
platformu, na které to reálně poběží. ARM64 je výjimka — doma na něm není
na čem stavět, a hodí se pro Raspberry Pi nebo jinou malou pořád zapnutou
krabičku.
Každá stavba nejdřív projede testy a zkusí hotovou binárku nastartovat, než
ji kamkoli pověsí. Vedle každé visí i `.sha256`, takže se stažený soubor dá
ověřit:
```bash
sha256sum -c dice-counter-linux-x86_64.sha256 # na macOS: shasum -a 256 -c
```
Nad pull requesty se Linux staví taky, jen se nikam nevěší. Totéž platí pro
kontejnerový image — doma se nad každým PR postaví a zkusí nastartovat
(`.gitea/workflows/image.yml`), publikuje se ale až z tagu na GitHubu.
Image se schválně nevydává doma: tamní runner běží v LXC, kde nejde
zaregistrovat emulaci pro arm64 — je to vlastnost jádra hostitele a ten je
produkční stroj. Běhouni GitHubu jsou plnohodnotné virtuály, kde to funguje
bez zásahu do čehokoli.
## Testy
```bash
@@ -385,7 +146,3 @@ uv run --with-requirements requirements.txt --no-project pytest
```
Nebo z připraveného prostředí prostě `pytest`.
Nad každým pull requestem a nad `main` běží `.gitea/workflows/testy.yml` —
118 testů na Pythonu 3.11, 3.12, 3.13 i 3.14, a k tomu skriptovaná partie
v terminálové verzi, na kterou pytest nesahá.
+22 -36
View File
@@ -1,44 +1,30 @@
#!/usr/bin/env bash
# Postaví jednosouborovou binárku do dist/ — pro systém, na kterém běží.
# Postaví jednosouborovou binárku pro Linux do dist/kostky.
#
# PyInstaller neumí křížovou kompilaci: binárku pro každý systém musí postavit
# ten systém. Linux se staví doma na vlastním runneru, Windows a macOS na
# půjčených strojích GitHub Actions (.github/workflows/binarky.yml).
#
# Staví se proti samostatnému CPythonu od uv, ne proti systémovému. Na Linuxu
# je slinkovaný s glibc 2.17, takže binárka jede i na letitých systémech.
# Postavená proti Pythonu z Fedory 44 by chtěla glibc 2.43 a nešla by spustit
# skoro nikde — glibc drží zpětnou kompatibilitu, ne dopřednou. Nic z hostitele
# se do ní nedostane, takže kontejner k tomu není potřeba.
# Staví se v kontejneru s glibc 2.28 (AlmaLinux 8), ne na hostiteli. Binárka
# slinkovaná proti glibc z Fedory 44 by nešla spustit nikde se starším
# systémem — glibc drží zpětnou kompatibilitu, ne dopřednou. Postavené na
# staré glibc poběží i na nové.
set -euo pipefail
PYTHON_VERSION=${PYTHON_VERSION:-3.11}
BUILD=${BUILD:-.build}
IMAGE=${IMAGE:-docker.io/library/almalinux:8}
PYTHON=${PYTHON:-python3.11}
ENGINE=${ENGINE:-$(command -v podman || command -v docker)}
# Verze, kterou binárka ohlásí přes --version. V CI ji na tagu vnutíme
# proměnnou, protože tam je repozitář naklonovaný na jeden commit a git
# describe nemá o co se opřít.
VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo neznámá)}
printf '%s\n' "$VERSION" > verze.txt
echo "Verze: $VERSION"
# Rootless podman mapuje roota v kontejneru na tebe, docker ne — tam by
# dist/ zůstalo cizí.
FIXUP=":"
[[ "$ENGINE" == *docker ]] && FIXUP="chown -R $(id -u):$(id -g) dist"
uv python install "$PYTHON_VERSION"
# --clear, protože runner si pracovní adresář mezi běhy drží a uv nad
# existujícím prostředím jinak skončí chybou. Stavíme načisto.
uv venv --quiet --clear --managed-python --python "$PYTHON_VERSION" "$BUILD"
# Windows dává spustitelné soubory venvu do Scripts/, zbytek světa do bin/.
python="$BUILD/bin/python"
pyinstaller="$BUILD/bin/pyinstaller"
if [ -d "$BUILD/Scripts" ]; then
python="$BUILD/Scripts/python.exe"
pyinstaller="$BUILD/Scripts/pyinstaller.exe"
fi
uv pip install --quiet --python "$python" -r requirements-build.txt
"$pyinstaller" --clean --noconfirm \
--distpath dist --workpath "$BUILD/work" dice-counter.spec
"$ENGINE" run --rm -v "$PWD:/src:z" -w /src "$IMAGE" bash -euc "
dnf install -y -q python3.11 binutils
$PYTHON -m venv /tmp/build
/tmp/build/bin/pip install --quiet --upgrade pip
/tmp/build/bin/pip install --quiet flask qrcode pyinstaller
/tmp/build/bin/pyinstaller --clean --noconfirm \
--distpath dist --workpath /tmp/work kostky.spec
$FIXUP
"
echo
ls -lh dist/
ls -lh dist/kostky
-27
View File
@@ -1,27 +0,0 @@
"""Aby výstup prošel i tam, kde ho nikdo nečeká na konzoli."""
import sys
def utf8() -> None:
"""Přepne standardní výstup na UTF-8 a na řádkové bufferování.
Windows sahá po historické kódové stránce (cp1252, cp852) všude, kde
výstup není konzole — přesměrovaný do souboru, puštěný ze skriptu, ve
frontě CI. První háček nad takovým proudem shodí celý program, takže
binárka umřela dřív, než stihla vypsat adresu, na které poslouchá.
Na konzoli i na ostatních systémech je UTF-8 dávno výchozí a tohle je
tam prázdná operace. `errors="replace"` je pojistka pro zbylé případy:
rozsypaný háček je pořád lepší než spadlý server.
Řádkové bufferování řeší druhou půlku téhož problému. Mimo konzoli
Python drží výstup v bloku a pouští ho, až se naplní nebo až program
skončí — jenže server neskončí, ten běží. Kdo si ho pustí na pozadí
s výpisem do souboru, našel by tam prázdno; přesně to dělá zkouška
binárky v CI. Pár řádek hlášky za to bufferovat nemá cenu.
"""
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace",
line_buffering=True)
-56
View File
@@ -1,56 +0,0 @@
# Počitadlo kostek jako kontejner.
#
# Staví se ze zdrojáků, ne z vydané binárky: image jde pak sestavit pro
# amd64 i arm64 a nezávisí na tom, jestli pro danou architekturu release
# proběhl. Závislosti jsou zamčené v requirements.txt, takže je sestavení
# opakovatelné.
#
# docker build -f deploy/Dockerfile -t gitea.spacilovi.eu/david-spacil/dice-counter:latest .
#
# Sestavuje se z kořene repozitáře, ne z deploy/.
FROM python:3.11-slim
# Verze se jinak nemá kde vzít — v image není git ani zabalená binárka.
ARG VERSION=neznámá
# Podle image.source si Gitea připne balíček k repozitáři. Bez toho labelu
# image sice existuje, ale visí jen pod uživatelem a záložka Packages
# u repozitáře zůstane prázdná.
LABEL org.opencontainers.image.source="https://gitea.spacilovi.eu/david-spacil/dice-counter" \
org.opencontainers.image.url="https://gitea.spacilovi.eu/david-spacil/dice-counter" \
org.opencontainers.image.title="dice-counter" \
org.opencontainers.image.description="Počitadlo kostek: telefon zapisuje skóre, notebook ukazuje zápisník" \
org.opencontainers.image.version="$VERSION"
RUN useradd --system --uid 1000 --home-dir /app --shell /usr/sbin/nologin dice-counter
WORKDIR /app
# Závislosti zvlášť a dřív než kód, ať se vrstva s pipem přestavuje jen tehdy,
# když se opravdu změnily.
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY console.py core.py net.py stats.py storage.py version.py web.py ./
COPY templates/ templates/
COPY static/ static/
RUN echo "$VERSION" > verze.txt
# Adresář pro databázi musí vzniknout už tady a patřit správnému uživateli —
# svazek, který Docker založí sám, by jinak patřil rootovi a appka běžící pod
# dice-counter by do něj nezapsala.
RUN install -d -o dice-counter -g dice-counter /data
VOLUME ["/data"]
ENV DICE_DB=/data/dice.db \
DICE_PORT=8000
EXPOSE 8000
USER dice-counter
# Bez curlu, ten ve slim image není. Stačí, že stránka odpoví — sahá i do
# databáze, takže se tím kontroluje i ta.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.environ.get('DICE_PORT','8000') + '/')"
CMD ["python", "web.py"]
-46
View File
@@ -1,46 +0,0 @@
# Počitadlo kostek v Dockeru.
#
# curl -fsSLO https://gitea.spacilovi.eu/david-spacil/dice-counter/raw/branch/main/deploy/docker-compose.yaml
# docker compose up -d
#
# Nic dalšího není potřeba — image se stáhne hotový, pro amd64 i arm64.
# Kdo si ho chce postavit ze zdrojáků, přebije ten stažený stejnou značkou:
#
# docker build -f deploy/Dockerfile -t gitea.spacilovi.eu/david-spacil/dice-counter:latest .
#
# Počítá se s domácí sítí. Appka nemá přihlašování, tohle ji nijak
# nezabezpečuje a do internetu nepatří.
services:
dice-counter:
image: gitea.spacilovi.eu/david-spacil/dice-counter:latest
container_name: dice-counter
restart: unless-stopped
# Python jako PID 1 nesklízí osiřelé procesy a hůř reaguje na signály.
init: true
# Síť hostitele schválně. V bridge režimu kontejner uvnitř vidí adresu
# jako 172.17.0.2, tabule ji poctivě nabídne a vygeneruje na ni QR kód —
# jenže z telefonu je nedosažitelná. Se sítí hostitele appka najde tu
# adresu, na kterou se telefon opravdu dovolá.
#
# Kdo bridge potřebuje, ať zakomentuje řádek níž, odkomentuje ports
# a nastaví DICE_HOST na adresu hostitele v LAN:
#
# ports:
# - "8000:8000"
# environment:
# DICE_HOST: 192.168.1.10
network_mode: host
environment:
DICE_PORT: 8000
DICE_DB: /data/dice.db
volumes:
- dice-counter-data:/data
volumes:
# Zálohu si stáhneš rovnou z appky přes /export, takže se sem nemusí lézt.
dice-counter-data:
-391
View File
@@ -1,391 +0,0 @@
#!/usr/bin/env bash
# Založí na Proxmoxu LXC kontejner s počitadlem kostek.
#
# Pouští se v terminálu uzlu Proxmoxu jako root, jedním příkazem:
#
# bash -c "$(curl -fsSL https://gitea.spacilovi.eu/david-spacil/dice-counter/raw/branch/main/deploy/pve-dice-counter.sh)"
#
# Aktualizace stávajícího kontejneru na novější vydání:
#
# CTID=123 MODE=update bash -c "$(curl -fsSL .../pve-dice-counter.sh)"
#
# Ze staženého souboru jde i obojí postaru: `bash pve-dice-counter.sh` a
# `bash pve-dice-counter.sh update 123`.
#
# Podoba je odkoukaná od community-scripts.org, ale nic z jejich frameworku
# se tu nestahuje — jejich build.func si instalační skript hledá natvrdo ve
# vlastním repozitáři, takže mimo něj nefunguje.
#
# Počítá se s domácí sítí. Appka nemá přihlašování a tenhle skript ji nijak
# nezabezpečuje; do internetu ji nepouštěj.
set -euo pipefail
APP="dice-counter"
GITEA="${GITEA:-https://gitea.spacilovi.eu}"
REPO="${REPO:-david-spacil/dice-counter}"
# Výchozí hodnoty. Všechny se dají přebít proměnnou prostředí, třeba:
# MEMORY=1024 STORAGE=local-zfs bash -c "$(curl -fsSL ...)"
CT_HOSTNAME="${CT_HOSTNAME:-dice-counter}"
CORES="${CORES:-1}"
MEMORY="${MEMORY:-512}"
DISK="${DISK:-4}"
STORAGE="${STORAGE:-}"
TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}"
BRIDGE="${BRIDGE:-vmbr0}"
PORT="${PORT:-8000}"
UNPRIVILEGED="${UNPRIVILEGED:-1}"
OSVERSION="${OSVERSION:-13}"
NESTING="${NESTING:-1}"
AUTOLOGIN="${AUTOLOGIN:-1}" # konzole ve webu Proxmoxu bez hesla
VERSION="${VERSION:-}" # prázdné = poslední vydání
# --- výpisy ------------------------------------------------------------------
# Bez tohohle Ctrl+C zabije jen rozdělaný podproces a skript jede vesele dál
# — třeba rovnou zakládat kontejner, na který se čekat nemá.
trap 'echo; echo -e "\e[1;31m ✗\e[0m Přerušeno." >&2; exit 130' INT TERM
msg() { echo -e "\e[1;34m →\e[0m $*"; }
ok() { echo -e "\e[1;32m ✓\e[0m $*"; }
die() { echo -e "\e[1;31m ✗\e[0m $*" >&2; exit 1; }
header() {
echo
echo -e "\e[1;36m $APP\e[0m — počitadlo kostek do LXC"
echo -e " \e[2m$GITEA/$REPO\e[0m"
echo
}
# --- instalátor, který poběží uvnitř kontejneru ------------------------------
#
# Je tu jako text schválně. Skript se pouští i rourou z curlu, kdy na disku
# hostitele žádný soubor není a nebylo by co poslat dovnitř. Uvozovky kolem
# 'INSTALL' jsou podstatné: nic z toho se tady nesmí rozvinout, všechny
# proměnné patří až tomu, co poběží v kontejneru.
#
# Vypsat se dá i samostatně: `bash pve-dice-counter.sh instalator > install.sh`,
# což se hodí, když chceš appku dostat do kontejneru, který sis založil sám.
installer() {
cat <<'INSTALL'
#!/usr/bin/env bash
# Instalace počitadla uvnitř kontejneru. O Proxmoxu nic neví, takže se dá
# pustit v jakémkoli Debianu. Je idempotentní — druhé spuštění jen vymění
# binárku za nejnovější.
set -euo pipefail
GITEA="${GITEA:-https://gitea.spacilovi.eu}"
REPO="${REPO:-david-spacil/dice-counter}"
PORT="${PORT:-8000}"
VERSION="${VERSION:-}"
USER_NAME=dice-counter
HOME_DIR=/opt/dice-counter
DATA_DIR=/var/lib/dice-counter
SERVICE=dice-counter
msg() { echo -e "\e[1;34m →\e[0m $*"; }
ok() { echo -e "\e[1;32m ✓\e[0m $*"; }
die() { echo -e "\e[1;31m ✗\e[0m $*" >&2; exit 1; }
case "$(dpkg --print-architecture)" in
amd64) ASSET=dice-counter-linux-x86_64 ;;
arm64) ASSET=dice-counter-linux-arm64 ;;
*) die "Pro architekturu $(dpkg --print-architecture) binárka není." ;;
esac
msg "Doinstalovávám, co je potřeba ke stažení"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl ca-certificates >/dev/null
ok "Základ je hotový"
if [ -z "$VERSION" ]; then
msg "Zjišťuji poslední vydanou verzi"
# Jen tag; adresa přílohy se z něj složí, takže tu nepotřebujeme jq.
VERSION=$(curl -fsSL "$GITEA/api/v1/repos/$REPO/releases/latest" \
| grep -o '"tag_name":[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)
[ -n "$VERSION" ] || die "Nepodařilo se zjistit poslední verzi z $GITEA."
fi
ok "Instaluje se $VERSION ($ASSET)"
BASE="$GITEA/$REPO/releases/download/$VERSION"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
msg "Stahuji binárku"
curl -fsSL -o "$TMP/$ASSET" "$BASE/$ASSET" \
|| die "$ASSET pro $VERSION na releasu není. Vydání pro tuhle architekturu možná ještě neproběhlo."
curl -fsSL -o "$TMP/$ASSET.sha256" "$BASE/$ASSET.sha256" \
|| die "Chybí kontrolní součet — radši nic neinstaluju."
msg "Ověřuji kontrolní součet"
( cd "$TMP" && sha256sum -c "$ASSET.sha256" >/dev/null ) \
|| die "Kontrolní součet nesedí. Stažený soubor zahazuju."
ok "Součet sedí"
id -u "$USER_NAME" >/dev/null 2>&1 \
|| useradd --system --home-dir "$HOME_DIR" --shell /usr/sbin/nologin "$USER_NAME"
install -d -o root -g root -m 755 "$HOME_DIR"
# Zastavit se dá jen to, co běží — při první instalaci služba ještě není.
if systemctl is-active --quiet "$SERVICE"; then
systemctl stop "$SERVICE"
fi
install -o root -g root -m 755 "$TMP/$ASSET" "$HOME_DIR/dice-counter"
echo "$VERSION" > "$HOME_DIR/verze"
ok "Binárka je na místě"
msg "Zapisuji službu"
cat > "/etc/systemd/system/$SERVICE.service" <<EOF
[Unit]
Description=Počitadlo kostek
Documentation=$GITEA/$REPO
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$USER_NAME
Environment=DICE_DB=$DATA_DIR/dice.db
Environment=DICE_PORT=$PORT
ExecStart=$HOME_DIR/dice-counter
Restart=on-failure
RestartSec=5
# Databáze je jediné, co appka potřebuje mít zapisovatelné. StateDirectory ji
# založí a předá správnému uživateli, takže se tu nemusí nic chownovat.
StateDirectory=dice-counter
ReadWritePaths=$DATA_DIR
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
# Binárka se při každém startu rozbaluje do /tmp; tady do vlastního, ne do
# sdíleného.
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --quiet --now "$SERVICE"
ok "Služba běží"
# --- konzole v rozhraní Proxmoxu --------------------------------------------
if [ "${AUTOLOGIN:-1}" = "1" ]; then
msg "Nastavuji konzoli"
# Kontejner nemá root heslo, takže by se do konzole ve webu Proxmoxu
# nedalo dostat. Autologin to řeší stejně, jako to dělají community-scripts.
# Nová práva to nikomu nedává — kdo má web Proxmoxu, má root na uzlu tak
# jako tak.
mkdir -p /etc/systemd/system/container-getty@1.service.d
cat > /etc/systemd/system/container-getty@1.service.d/override.conf <<'GETTY'
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin root --noclear --keep-baud tty%I 115200,38400,9600 $TERM
GETTY
systemctl daemon-reload
systemctl restart container-getty@1.service 2>/dev/null || true
ok "Konzole se přihlásí sama"
fi
# --- příkaz update ----------------------------------------------------------
# Instalátor si necháme, ať je z čeho aktualizovat. Když se skript pouští
# rourou, soubor neexistuje a zůstane ten z minula.
#
# `-ef` je tu nutnost, ne opatrnost: příkaz `update` si nový instalátor uloží
# rovnou sem a odsud ho pustí, takže `$0` a cíl jsou tentýž soubor. `install`
# na to řekne "are the same file", skončí chybou a se `set -e` by celý update
# spadl dřív, než by stihl cokoli udělat.
if [ -f "$0" ] && ! [ "$0" -ef "$HOME_DIR/install.sh" ]; then
install -m 755 "$0" "$HOME_DIR/install.sh"
fi
cat > /usr/bin/update <<UPDATE
#!/usr/bin/env bash
# Aktualizace počitadla na poslední vydání. Stačí napsat: update
set -euo pipefail
# Nejdřív zkusíme obnovit i samotný instalátor, kdyby se mezitím zlepšil.
# Bez sítě nebo při změněné adrese se použije ten uložený.
NOVY=\$(mktemp)
if curl -fsSL "$GITEA/$REPO/raw/branch/main/deploy/pve-dice-counter.sh" -o "\$NOVY" 2>/dev/null \\
&& bash "\$NOVY" instalator > "\$NOVY.in" 2>/dev/null \\
&& bash -n "\$NOVY.in" 2>/dev/null; then
install -m 755 "\$NOVY.in" "$HOME_DIR/install.sh"
fi
rm -f "\$NOVY" "\$NOVY.in"
exec env PORT="$PORT" GITEA="$GITEA" REPO="$REPO" bash "$HOME_DIR/install.sh"
UPDATE
chmod 755 /usr/bin/update
ok "Aktualizovat půjde příkazem: update"
msg "Čekám, až začne odpovídat"
for _ in $(seq 30); do
curl -fsS -o /dev/null "http://127.0.0.1:$PORT/" 2>/dev/null && break
sleep 1
done
curl -fsS -o /dev/null "http://127.0.0.1:$PORT/" \
|| die "Služba nastartovala, ale neodpovídá. Mrkni na journalctl -u $SERVICE."
# Verzi bereme ze souboru, ne z binárky. Starší vydání --version neumí
# a místo výpisu by nastartovala server, který by tenhle skript zavěsil.
ok "Počitadlo $(cat "$HOME_DIR/verze") odpovídá na portu $PORT"
INSTALL
}
# --- společné části ----------------------------------------------------------
install_into() {
local ctid="$1" tmp
tmp=$(mktemp)
installer > "$tmp"
msg "Posílám instalátor do kontejneru $ctid"
pct push "$ctid" "$tmp" /root/dice-counter-install.sh --perms 0755
rm -f "$tmp"
pct exec "$ctid" -- env PORT="$PORT" GITEA="$GITEA" REPO="$REPO" \
VERSION="$VERSION" AUTOLOGIN="$AUTOLOGIN" bash /root/dice-counter-install.sh
}
address_of() {
pct exec "$1" -- hostname -I 2>/dev/null | awk '{print $1}'
}
hotovo() {
local ctid="$1" ip jmeno
ip=$(address_of "$ctid")
jmeno=$(pct config "$ctid" | awk -F': ' '/^hostname:/ {print $2}')
echo
ok "Hotovo. Kontejner $ctid ($jmeno) běží."
echo
echo -e " Zápisník na notebook: \e[1;32mhttp://${ip}:${PORT}/board\e[0m"
echo -e " Zadávání z telefonu: \e[1;32mhttp://${ip}:${PORT}/\e[0m"
echo
echo -e " \e[2mDatabáze: /var/lib/dice-counter/dice.db (uvnitř kontejneru)"
echo -e " Aktualizace: v konzoli kontejneru napiš update"
echo -e " Log: pct exec $ctid -- journalctl -u dice-counter -f"
echo -e " Konzole: pct enter $ctid"
echo -e " Zrušit: pct stop $ctid && pct destroy $ctid\e[0m"
echo
}
# --- co se má vlastně dělat --------------------------------------------------
#
# Rourou z curlu se argumenty předávají mizerně, tak jde všechno i proměnnou:
# MODE=update CTID=123. Ze souboru funguje i `bash pve-dice-counter.sh update 123`.
MODE="${MODE:-${1:-create}}"
[ "$MODE" = "update" ] && CTID="${CTID:-${2:-}}"
if [ "$MODE" = "instalator" ]; then
installer
exit 0
fi
command -v pct >/dev/null || die "Tohle patří na uzel Proxmoxu — pct tu není."
[ "$(id -u)" -eq 0 ] || die "Spusť to jako root."
# --- aktualizace -------------------------------------------------------------
if [ "$MODE" = "update" ]; then
[ -n "${CTID:-}" ] || die "Řekni který: CTID=123 MODE=update ..."
pct status "$CTID" >/dev/null 2>&1 || die "Kontejner $CTID neexistuje."
header
if [ "$(pct status "$CTID")" != "status: running" ]; then
msg "Startuji kontejner"
pct start "$CTID"
sleep 5
fi
install_into "$CTID"
hotovo "$CTID"
exit 0
fi
# --- nový kontejner ----------------------------------------------------------
header
# Úložiště se nehádá. local-lvm na ZFS instalacích neexistuje a pct create by
# spadlo až po půlce práce, tak se radši zeptáme systému, co tu opravdu je.
if [ -z "$STORAGE" ]; then
STORAGE=$(pvesm status -content rootdir 2>/dev/null \
| awk 'NR>1 && $3=="active" {print $1; exit}')
[ -n "$STORAGE" ] || die "Nenašel jsem úložiště pro kontejnery. Zadej STORAGE=<jméno>; co máš, ukáže pvesm status."
msg "Úložiště: $STORAGE (nalezeno automaticky)"
else
pvesm status -content rootdir 2>/dev/null | awk 'NR>1 {print $1}' | grep -qx "$STORAGE" \
|| die "Úložiště $STORAGE pro kontejnery nesedí. Co máš, ukáže: pvesm status -content rootdir"
msg "Úložiště: $STORAGE"
fi
CTID="${CTID:-$(pvesh get /cluster/nextid)}"
msg "Kontejner dostane číslo $CTID"
# Architektura uzlu, ne první šablona v seznamu. pveam nabízí amd64 i arm64
# vedle sebe a bez tohohle filtru by výběr padl na tu abecedně poslední.
ARCH=$(dpkg --print-architecture)
SABLONA="debian-${OSVERSION}-standard[^[:space:]]*_${ARCH}\.tar\.[a-z]*"
msg "Hledám šablonu Debianu $OSVERSION pro $ARCH"
# Nejdřív co už na uzlu leží — stahovat 120 MB znovu je zbytečné.
TEMPLATE=$(pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep -o "$SABLONA" | sort -V | tail -1)
if [ -n "$TEMPLATE" ]; then
ok "Šablona $TEMPLATE (už je na uzlu)"
else
pveam update >/dev/null 2>&1 || true
TEMPLATE=$(pveam available --section system 2>/dev/null | grep -o "$SABLONA" | sort -V | tail -1)
[ -n "$TEMPLATE" ] || die "Šablona pro Debian $OSVERSION a $ARCH není k dispozici."
msg "Stahuji $TEMPLATE (asi 120 MB, chvíli to trvá)"
pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" >/dev/null \
|| die "Stažení šablony selhalo."
ok "Šablona $TEMPLATE"
fi
# Nesting: Debian 13 veze systemd 257 a ten v neprivilegovaném kontejneru bez
# něj nedostane, co potřebuje — Proxmox na to při startu sám upozorňuje.
# Stejnou výchozí hodnotu mají i community-scripts.
msg "Zakládám kontejner"
pct create "$CTID" "$TEMPLATE_STORAGE:vztmpl/$TEMPLATE" \
--hostname "$CT_HOSTNAME" \
--cores "$CORES" \
--memory "$MEMORY" \
--swap 256 \
--rootfs "$STORAGE:$DISK" \
--net0 "name=eth0,bridge=$BRIDGE,ip=dhcp" \
--unprivileged "$UNPRIVILEGED" \
--features "nesting=$NESTING" \
--ostype debian \
--onboot 1 \
--description "$APP — $GITEA/$REPO" >/dev/null
ok "Kontejner $CTID založen"
msg "Startuji"
pct start "$CTID"
msg "Čekám na síť"
for _ in $(seq 60); do
if [ -n "$(address_of "$CTID")" ] \
&& pct exec "$CTID" -- getent hosts deb.debian.org >/dev/null 2>&1; then
break
fi
sleep 2
done
[ -n "$(address_of "$CTID")" ] || die "Kontejner nedostal adresu. Sedí bridge $BRIDGE?"
ok "Adresa $(address_of "$CTID")"
install_into "$CTID"
hotovo "$CTID"
-38
View File
@@ -1,38 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
"""Jedna binárka se vším: Python, Flask, šablony i styly.
Staví se přes `./build.sh`, ne ručně — ta se stará o to, aby výsledek jel
i na starších systémech, než je ten tvůj.
"""
import os
import sys
# Verzi zapisuje build.sh těsně před stavbou. Když se pyinstaller pustí
# ručně bez ní, binárka se postaví taky — jen se pak hlásí jako neznámá.
datas = [("templates", "templates"), ("static", "static")]
if os.path.exists("verze.txt"):
datas.append(("verze.txt", "."))
analysis = Analysis(
["web.py"],
pathex=["."],
datas=datas,
noarchive=False,
)
pyz = PYZ(analysis.pure)
exe = EXE(
pyz,
analysis.scripts,
analysis.binaries,
analysis.datas,
name="dice-counter",
console=True,
# Ořezání symbolů šetří pár megabajtů, ale jen na Linuxu. Na macOS by
# rozbilo podpis, který si PyInstaller sám přidává a bez kterého se
# binárka na Apple Silicon vůbec nespustí; na Windows nedělá nic.
strip=sys.platform == "linux",
upx=False,
)
+1 -10
View File
@@ -1,6 +1,6 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["tabulate==0.10.0"]
# dependencies = ["tabulate"]
# ///
"""Počitadlo skóre v terminálu.
@@ -8,13 +8,10 @@ Pravidla žijí v `core.Game`, tenhle modul je jen vstup a výstup. Hraje se
v paměti, nic se neukládá — na historii je webová aplikace (`web.py`).
"""
import sys
from os import name, system
from tabulate import tabulate
import console
import version
from core import FINAL_SCORE, Game
table_header = ["Hráč", "Skóre"]
@@ -131,10 +128,4 @@ def main() -> None:
if __name__ == "__main__":
console.utf8()
if {"--version", "-V"} & set(sys.argv[1:]):
print(f"dice-counter {version.current()}")
sys.exit(0)
main()
+26
View File
@@ -0,0 +1,26 @@
# -*- mode: python ; coding: utf-8 -*-
"""Jedna binárka se vším: Python, Flask, šablony i styly.
Staví se přes `./build.sh`, ne ručně — ta se stará o to, aby výsledek jel
i na starších systémech, než je ten tvůj.
"""
analysis = Analysis(
["web.py"],
pathex=["."],
datas=[("templates", "templates"), ("static", "static")],
noarchive=False,
)
pyz = PYZ(analysis.pure)
exe = EXE(
pyz,
analysis.scripts,
analysis.binaries,
analysis.datas,
name="kostky",
console=True,
strip=True,
upx=False,
)
+2 -41
View File
@@ -11,9 +11,7 @@ adresy a obecný typ rozhraní, ne to, co má kdo zrovna nainstalované.
import ipaddress
import os
import re
import socket
import subprocess
from dataclasses import dataclass
# Rozsah, ze kterého adresy přiděluje Tailscale (RFC 6598, CGNAT).
@@ -125,45 +123,8 @@ def _from_interfaces() -> list[tuple[str, str]]:
return found
def parse_ifconfig(text: str) -> list[tuple[str, str]]:
"""Rozhraní a jejich IPv4 adresy z výstupu `ifconfig`.
Oddělená čistá funkce, ať se dá otestovat i bez macOS pod rukama.
"""
found = []
name = ""
for line in text.splitlines():
head = re.match(r"(\S+):", line)
if head:
name = head.group(1)
continue
addr = re.search(r"\binet (\d+\.\d+\.\d+\.\d+)", line)
if addr:
found.append((name, addr.group(1)))
return found
def _from_ifconfig() -> list[tuple[str, str]]:
"""Adresy přes `ifconfig`. Záloha pro macOS, kde ioctl výše nesedí.
Spouští se jen tam, kde předchozí cesta nic nenašla, takže na Linuxu
se neplatí nic. Trvá jednotky milisekund, cachovat to nemá cenu.
"""
try:
done = subprocess.run(["ifconfig", "-a"], capture_output=True,
text=True, timeout=5, check=True)
except (OSError, subprocess.SubprocessError):
return []
return parse_ifconfig(done.stdout)
def _from_hostname() -> list[tuple[str, str]]:
"""Poslední záloha. Na Windows vrátí adresy všech rozhraní, jinde
obvykle jen jednu — jména rozhraní se takhle nedozvíme."""
"""Záloha pro systémy, kde předchozí cesta nefunguje."""
try:
infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET)
except OSError:
@@ -183,7 +144,7 @@ def addresses() -> list[Address]:
primary = default_route_address()
found = _from_interfaces() or _from_ifconfig() or _from_hostname()
found = _from_interfaces() or _from_hostname()
if primary and primary not in [ip for _name, ip in found]:
found.append(("", primary))
-4
View File
@@ -1,4 +0,0 @@
# Co je potřeba ke stavbě binárky. Zamyká se do requirements-build.txt:
# uv pip compile requirements-build.in -o requirements-build.txt
-r requirements.in
pyinstaller
-37
View File
@@ -1,37 +0,0 @@
# This file was autogenerated by uv via the following command:
# uv pip compile requirements-build.in -o requirements-build.txt --python-version 3.11
altgraph==0.17.5
# via pyinstaller
blinker==1.9.0
# via flask
click==8.4.2
# via flask
flask==3.1.3
# via -r requirements.in
itsdangerous==2.2.0
# via flask
jinja2==3.1.6
# via flask
markupsafe==3.0.3
# via
# flask
# jinja2
# werkzeug
packaging==26.3
# via
# pyinstaller
# pyinstaller-hooks-contrib
pyinstaller==6.22.2
# via -r requirements-build.in
pyinstaller-hooks-contrib==2026.6
# via pyinstaller
qrcode==8.2
# via -r requirements.in
setuptools==84.0.0
# via pyinstaller
tabulate==0.10.0
# via -r requirements.in
waitress==3.0.2
# via -r requirements.in
werkzeug==3.1.8
# via flask
-4
View File
@@ -1,4 +0,0 @@
# Co je potřeba k vývoji a testům. Zamyká se do requirements-dev.txt:
# uv pip compile requirements-dev.in -o requirements-dev.txt
-r requirements.in
pytest
-35
View File
@@ -1,35 +0,0 @@
# This file was autogenerated by uv via the following command:
# uv pip compile requirements-dev.in -o requirements-dev.txt --python-version 3.11
blinker==1.9.0
# via flask
click==8.4.2
# via flask
flask==3.1.3
# via -r requirements.in
iniconfig==2.3.0
# via pytest
itsdangerous==2.2.0
# via flask
jinja2==3.1.6
# via flask
markupsafe==3.0.3
# via
# flask
# jinja2
# werkzeug
packaging==26.3
# via pytest
pluggy==1.6.0
# via pytest
pygments==2.21.0
# via pytest
pytest==9.1.1
# via -r requirements-dev.in
qrcode==8.2
# via -r requirements.in
tabulate==0.10.0
# via -r requirements.in
waitress==3.0.2
# via -r requirements.in
werkzeug==3.1.8
# via flask
-6
View File
@@ -1,6 +0,0 @@
# Běhové závislosti. Verze se zamykají do requirements.txt přes:
# uv pip compile requirements.in -o requirements.txt
flask
qrcode
tabulate
waitress
+4 -25
View File
@@ -1,25 +1,4 @@
# This file was autogenerated by uv via the following command:
# uv pip compile requirements.in -o requirements.txt --python-version 3.11
blinker==1.9.0
# via flask
click==8.4.2
# via flask
flask==3.1.3
# via -r requirements.in
itsdangerous==2.2.0
# via flask
jinja2==3.1.6
# via flask
markupsafe==3.0.3
# via
# flask
# jinja2
# werkzeug
qrcode==8.2
# via -r requirements.in
tabulate==0.10.0
# via -r requirements.in
waitress==3.0.2
# via -r requirements.in
werkzeug==3.1.8
# via flask
flask
qrcode
tabulate
pytest
+1 -29
View File
@@ -373,7 +373,7 @@ button {
line-height: 1.4;
}
.join .address .what {
.join .address span {
display: block;
font-family: var(--font-ui);
font-size: 0.7rem;
@@ -382,8 +382,6 @@ button {
color: var(--ink-soft);
}
.join .address .url { display: block; }
.qr { width: 5.5rem; height: 5.5rem; fill: var(--ink); }
.idle {
@@ -482,24 +480,6 @@ button {
.alt li { white-space: nowrap; }
/* Adresa je tlačítko, ale nemá vypadat jako tlačítko — jen dát najevo,
že se na ni dá kliknout. */
.alt button {
font: inherit;
color: inherit;
background: none;
border: 0;
border-bottom: 1px dotted var(--rule);
padding: 0;
cursor: pointer;
}
.alt button:hover,
.alt button:focus-visible {
color: var(--ink);
border-bottom-color: currentColor;
}
.alt em {
font-family: var(--font-ui);
font-style: normal;
@@ -513,11 +493,3 @@ button {
font-size: 0.85em;
color: var(--ink);
}
/* Verze v patičce. Má být k nalezení, když ji někdo hledá, a jinak neplést. */
.verze {
margin: 2rem 0 0.75rem;
text-align: center;
font-size: 0.75rem;
opacity: 0.35;
}
+4 -53
View File
@@ -16,30 +16,20 @@ from pathlib import Path
from core import FINAL_SCORE, Game, Turn
def data_home() -> Path:
"""Adresář, kam podle zvyklostí systému patří data aplikací."""
if sys.platform == "win32":
local = os.environ.get("LOCALAPPDATA")
return Path(local) if local else Path.home() / "AppData" / "Local"
if sys.platform == "darwin":
return Path.home() / "Library" / "Application Support"
return Path(os.environ.get("XDG_DATA_HOME")
or Path.home() / ".local" / "share")
def default_db() -> Path:
"""Kam se ukládá databáze.
Ze zdrojáků do pracovního adresáře — dá se tak mít víc rozehraných
sad vedle sebe a je vidět, s čím se pracuje. Z binárky do datového
adresáře podle systému: binárka se spouští odkudkoli a rozbaluje se do
adresáře podle XDG: binárka se spouští odkudkoli a rozbaluje se do
dočasného adresáře, takže relativní cesta by databázi rozsypala po disku.
"""
chosen = os.environ.get("DICE_DB")
if chosen:
return Path(chosen)
if getattr(sys, "frozen", False):
return data_home() / "dice-counter" / "dice.db"
share = os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
return Path(share) / "kostky" / "dice.db"
return Path("dice.db")
@@ -86,45 +76,6 @@ CREATE TABLE IF NOT EXISTS turns (
CREATE INDEX IF NOT EXISTS turns_game ON turns(game_id, id);
"""
# Každý krok posouvá schéma o jedno dál; krok na indexu 0 vede z verze 1 na 2.
# Přidávat jen na konec a nikdy nesahat na ten, který už někomu proběhl —
# databáze leží lidem na discích a zpátky se nedají stáhnout.
MIGRATIONS: list[str] = []
SCHEMA_VERSION = 1 + len(MIGRATIONS)
def migrate(conn: sqlite3.Connection) -> int:
"""Dorovná databázi na aktuální schéma a vrátí její verzi.
Verze se drží v `PRAGMA user_version`, což je celé číslo, které si SQLite
veze v hlavičce souboru a jinak si ho nevšímá.
Nula znamená dvojí: buď je soubor prázdný, nebo pochází z doby, kdy se
verze ještě nezapisovala. Obojí se vyřeší stejně — `SCHEMA` je samé
`IF NOT EXISTS`, takže na hotové databázi neudělá nic a jen se orazítkuje.
"""
version = conn.execute("PRAGMA user_version").fetchone()[0]
if version > SCHEMA_VERSION:
raise RuntimeError(
f"Databáze je ve verzi {version}, tahle aplikace umí "
f"{SCHEMA_VERSION}. Nejspíš ji založila novější verze počitadla; "
f"aktualizuj, ať o data nepřijdeš.")
if version == 0:
conn.executescript(SCHEMA)
version = 1
for step in MIGRATIONS[version - 1:]:
conn.executescript(step)
version += 1
# PRAGMA neumí zástupné otazníky. Číslo sem teče z kódu, ne od uživatele.
conn.execute(f"PRAGMA user_version = {version}")
conn.commit()
return version
def connect(path: Path | str | None = None) -> sqlite3.Connection:
target = Path(path or DB_PATH)
@@ -132,7 +83,7 @@ def connect(path: Path | str | None = None) -> sqlite3.Connection:
conn = sqlite3.connect(target)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
migrate(conn)
conn.executescript(SCHEMA)
return conn
+9 -45
View File
@@ -1,58 +1,22 @@
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může
člověk — QR kódy jsou vykreslené všechny, přepíná se jen ten viditelný. #}
{% if choices %}
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může člověk. #}
{% if primary %}
<div class="join">
<div class="qr-stack">
{% for choice in choices %}
<div class="qr-slot" data-ip="{{ choice.ip }}"{% if not loop.first %} hidden{% endif %}>
{{ choice.url|qr }}
</div>
{% endfor %}
</div>
{{ target|qr }}
<div class="address">
<span class="what">Zapisuje se z telefonu</span>
<span class="url">{{ choices[0].url }}</span>
{% if choices|length > 1 %}
<span>Zapisuje se z telefonu</span>
{{ target }}
{% if others %}
<ul class="alt">
{% for choice in choices %}
<li data-ip="{{ choice.ip }}"{% if loop.first %} hidden{% endif %}>
<button type="button" data-ip="{{ choice.ip }}" data-url="{{ choice.url }}"
aria-label="Ukázat QR kód pro {{ choice.url }}">
{{ choice.ip }}:{{ port }} <em>{{ choice.label }}</em>
</button>
</li>
{% for address in others %}
<li>{{ address.ip }}:{{ port }} <em>{{ address.label }}</em></li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
<script>
// Klik na jinou adresu jen prohodí, který QR kód a který řádek je vidět.
// Vybraná adresa ze seznamu zmizí a ta předchozí se v něm objeví, takže
// se dá kroužit dokola.
{
const join = document.currentScript.previousElementSibling;
const shown = join.querySelector(".address .url");
for (const button of join.querySelectorAll(".alt button")) {
button.addEventListener("click", () => {
for (const slot of join.querySelectorAll(".qr-slot")) {
slot.hidden = slot.dataset.ip !== button.dataset.ip;
}
for (const item of join.querySelectorAll(".alt li")) {
item.hidden = item.dataset.ip === button.dataset.ip;
}
shown.textContent = button.dataset.url;
});
}
}
</script>
{% else %}
<div class="join">
<p class="address"><span class="what">Adresu se nepodařilo zjistit</span>
<p class="address"><span>Adresu se nepodařilo zjistit</span>
Spusť server s <code>DICE_HOST=&lt;adresa&gt;</code>.</p>
</div>
{% endif %}
+1 -1
View File
@@ -1,3 +1,3 @@
{# Fragment pro tabuli: obě části, které se během hry mění. #}
<div id="head" data-address="{{ address_key }}">{% include "_head.html" %}</div>
<div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
<div id="sheet">{% include "_sheet.html" %}</div>
-1
View File
@@ -8,6 +8,5 @@
</head>
<body>
{% block body %}{% endblock %}
<footer class="verze">dice-counter {{ verze }}</footer>
</body>
</html>
+1 -1
View File
@@ -4,7 +4,7 @@
{% block body %}
<div class="board">
<header class="masthead">
<div id="head" data-address="{{ address_key }}">{% include "_head.html" %}</div>
<div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
{% include "_join.html" %}
</header>
+1 -2
View File
@@ -8,8 +8,7 @@
<p class="eyebrow">Počitadlo</p>
<h1>Síň slávy</h1>
</div>
<p class="meta"><a href="{{ url_for('index') }}">Nová hra</a>
· <a href="{{ url_for('export') }}">Stáhnout databázi</a></p>
<p class="meta"><a href="{{ url_for('index') }}">Nová hra</a></p>
</header>
{% if records.best_turn %}
-65
View File
@@ -1,65 +0,0 @@
import io
import console
class Proud:
"""Náhrada za výstup, která si pamatuje, na co ji přepnuli."""
def __init__(self):
self.encoding = "cp1252"
self.errors = "strict"
self.line_buffering = False
def reconfigure(self, encoding, errors, line_buffering=False):
self.encoding = encoding
self.errors = errors
self.line_buffering = line_buffering
def test_vystup_se_prepne_na_utf8(monkeypatch):
out, err = Proud(), Proud()
monkeypatch.setattr(console.sys, "stdout", out)
monkeypatch.setattr(console.sys, "stderr", err)
console.utf8()
assert (out.encoding, out.errors) == ("utf-8", "replace")
assert (err.encoding, err.errors) == ("utf-8", "replace")
assert out.line_buffering and err.line_buffering
def test_cestina_projde_i_pres_cp1252(monkeypatch):
"""Přesně to, na čem umřela první windowsová binárka."""
surovy = io.BytesIO()
proud = io.TextIOWrapper(surovy, encoding="cp1252")
monkeypatch.setattr(console.sys, "stdout", proud)
monkeypatch.setattr(console.sys, "stderr", proud)
console.utf8()
print("Počitadlo je dostupné na:", file=console.sys.stdout)
console.sys.stdout.flush()
assert "Počitadlo je dostupné" in surovy.getvalue().decode("utf-8")
def test_proud_bez_reconfigure_se_prezije(monkeypatch):
"""Zabalený výstup reconfigure mít nemusí; padat kvůli tomu nesmíme."""
monkeypatch.setattr(console.sys, "stdout", object())
monkeypatch.setattr(console.sys, "stderr", object())
console.utf8()
def test_hlaska_je_v_souboru_hned(tmp_path, monkeypatch):
"""Server běží dál, takže na vyprázdnění bufferu při konci se čekat nedá."""
cesta = tmp_path / "server.log"
with cesta.open("w", encoding="utf-8") as soubor:
monkeypatch.setattr(console.sys, "stdout", soubor)
monkeypatch.setattr(console.sys, "stderr", soubor)
console.utf8()
print("Počitadlo je dostupné na:", file=console.sys.stdout)
# Bez zavření souboru — přesně jako u běžícího serveru.
assert "Počitadlo je dostupné" in cesta.read_text(encoding="utf-8")
-47
View File
@@ -90,50 +90,3 @@ def test_v_systemu_se_neco_najde():
assert all(net.classify(a.ip, a.interface) for a in found)
assert len({a.ip for a in found}) == len(found)
IFCONFIG_MACOS = """\
lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
\toptions=1203<RXCSUM,TXCSUM,TXSTATUS,SW_TIMESTAMP>
\tinet 127.0.0.1 netmask 0xff000000
\tinet6 ::1 prefixlen 128
gif0: flags=8010<POINTOPOINT,MULTICAST> mtu 1280
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
\tether 3c:22:fb:aa:bb:cc
\tinet6 fe80::14b0:6b1a:9e2f:1a4c%en0 prefixlen 64 secured scopeid 0xb
\tinet 192.168.0.42 netmask 0xffffff00 broadcast 192.168.0.255
\tmedia: autoselect
\tstatus: active
utun4: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 1280
\tinet 100.99.98.97 --> 100.99.98.97 netmask 0xff000000
"""
def test_ifconfig_vytahne_rozhrani_a_adresy():
"""macOS na ioctl z Linuxu neslyší, tak se čte výstup ifconfigu."""
assert net.parse_ifconfig(IFCONFIG_MACOS) == [
("lo0", "127.0.0.1"),
("en0", "192.168.0.42"),
("utun4", "100.99.98.97"),
]
def test_ifconfig_si_neplete_inet6_s_inet():
"""`inet6` začíná stejně jako `inet`; IPv6 adresy tudy projít nesmí."""
assert net.parse_ifconfig("en0: flags=1\n\tinet6 fe80::1 prefixlen 64\n") == []
def test_adresy_z_ifconfigu_projdou_celym_retezem(monkeypatch):
"""Bez linuxového ioctl se sáhne po ifconfigu, teprve pak po hostname."""
monkeypatch.delenv("DICE_HOST", raising=False)
monkeypatch.setattr(net, "_from_interfaces", lambda: [])
monkeypatch.setattr(net, "_from_ifconfig",
lambda: net.parse_ifconfig(IFCONFIG_MACOS))
monkeypatch.setattr(net, "_from_hostname", lambda: pytest.fail("moc brzy"))
monkeypatch.setattr(net, "default_route_address", lambda: "192.168.0.42")
found = net.addresses()
assert [a.ip for a in found] == ["192.168.0.42", "100.99.98.97"]
assert found[0].primary and found[0].interface == "en0"
assert found[1].kind == "tailnet"
+1 -85
View File
@@ -1,4 +1,3 @@
import sqlite3
from pathlib import Path
import pytest
@@ -149,26 +148,9 @@ def test_umisteni_databaze_z_binarky(monkeypatch, tmp_path):
"""Binárka se rozbaluje do dočasného adresáře, databáze tam nesmí."""
monkeypatch.delenv("DICE_DB", raising=False)
monkeypatch.setattr(storage.sys, "frozen", True, raising=False)
monkeypatch.setattr(storage.sys, "platform", "linux")
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
assert storage.default_db() == tmp_path / "dice-counter" / "dice.db"
def test_data_home_podle_systemu(monkeypatch, tmp_path):
"""Každý systém má svůj adresář na data; XDG je jen ten linuxový."""
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "AppData" / "Local"))
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
monkeypatch.setattr(storage.sys, "platform", "win32")
assert storage.data_home() == tmp_path / "AppData" / "Local"
monkeypatch.setattr(storage.sys, "platform", "darwin")
assert storage.data_home() == tmp_path / "Library" / "Application Support"
monkeypatch.setattr(storage.sys, "platform", "linux")
monkeypatch.delenv("XDG_DATA_HOME", raising=False)
assert storage.data_home() == tmp_path / ".local" / "share"
assert storage.default_db() == tmp_path / "kostky" / "dice.db"
def test_dice_db_prebije_oboje(monkeypatch, tmp_path):
@@ -183,69 +165,3 @@ def test_connect_zalozi_chybejici_adresar(tmp_path):
conn.close()
assert (tmp_path / "novy" / "adresar" / "dice.db").exists()
# --- verze schématu ---------------------------------------------------------
def verze(conn) -> int:
return conn.execute("PRAGMA user_version").fetchone()[0]
def test_nova_databaze_je_orazitkovana(tmp_path):
conn = storage.connect(tmp_path / "nova.db")
try:
assert verze(conn) == storage.SCHEMA_VERSION
finally:
conn.close()
def test_databaze_z_doby_pred_verzovanim_prezije(tmp_path):
"""Přesně to, co lidem leží na disku z verzí 1.0 a 1.1.
Tabulky má, razítko ne. Nesmí se ani přijít o data, ani spadnout.
"""
path = tmp_path / "stara.db"
stara = sqlite3.connect(path)
stara.executescript(storage.SCHEMA) # bez PRAGMA user_version
stara.execute("INSERT INTO players (name, key, created_at) "
"VALUES ('Adam', 'adam', '2026-01-01T00:00:00')")
stara.commit()
stara.close()
conn = storage.connect(path)
try:
assert verze(conn) == storage.SCHEMA_VERSION
assert [p["name"] for p in storage.known_players(conn)] == ["Adam"]
finally:
conn.close()
def test_migrace_dojede_jen_ty_chybejici(tmp_path, monkeypatch):
"""Podruhé už krok proběhnout nesmí — jinak by ALTER TABLE spadl."""
monkeypatch.setattr(storage, "MIGRATIONS",
["ALTER TABLE players ADD COLUMN barva TEXT;"])
monkeypatch.setattr(storage, "SCHEMA_VERSION", 2)
path = tmp_path / "migrovana.db"
conn = storage.connect(path)
conn.close()
conn = storage.connect(path) # druhé spuštění téže verze
try:
assert verze(conn) == 2
sloupce = [r[1] for r in conn.execute("PRAGMA table_info(players)")]
assert "barva" in sloupce
finally:
conn.close()
def test_databaze_z_novejsi_verze_se_odmitne(tmp_path):
"""Radši srozumitelná hláška než tiše rozbitá data."""
path = tmp_path / "budouci.db"
conn = storage.connect(path)
conn.execute(f"PRAGMA user_version = {storage.SCHEMA_VERSION + 1}")
conn.commit()
conn.close()
with pytest.raises(RuntimeError, match="novější verze"):
storage.connect(path)
-48
View File
@@ -1,48 +0,0 @@
"""Verze se musí poznat i z binárky, kde žádný git není."""
import pytest
import version
@pytest.fixture(autouse=True)
def bez_pameti():
"""`current()` si výsledek pamatuje; mezi testy je potřeba čistý stůl."""
version.current.cache_clear()
yield
version.current.cache_clear()
def test_z_binarky_se_cte_pribaleny_soubor(monkeypatch, tmp_path):
"""V binárce leží verze.txt v dočasném adresáři vedle šablon."""
(tmp_path / version.NAME).write_text("v9.9.9\n", encoding="utf-8")
monkeypatch.setattr(version.sys, "frozen", True, raising=False)
monkeypatch.setattr(version.sys, "_MEIPASS", str(tmp_path), raising=False)
assert version.current() == "v9.9.9"
def test_binarka_se_gitu_nepta(monkeypatch, tmp_path):
"""Na cizím počítači žádný repozitář není a ptát se nemá koho."""
monkeypatch.setattr(version.sys, "frozen", True, raising=False)
monkeypatch.setattr(version.sys, "_MEIPASS", str(tmp_path), raising=False)
monkeypatch.setattr(version, "described",
lambda: pytest.fail("binárka se ptala gitu"))
assert version.current() == version.UNKNOWN
def test_ze_zdrojaku_ma_prednost_git(monkeypatch):
monkeypatch.delattr(version.sys, "frozen", raising=False)
monkeypatch.setattr(version, "described", lambda: "v1.2.3-4-gabcdef")
monkeypatch.setattr(version, "bundled", lambda: "v1.0.0")
assert version.current() == "v1.2.3-4-gabcdef"
def test_bez_gitu_a_bez_souboru_zbyde_neznama(monkeypatch):
monkeypatch.delattr(version.sys, "frozen", raising=False)
monkeypatch.setattr(version, "described", lambda: "")
monkeypatch.setattr(version, "bundled", lambda: "")
assert version.current() == version.UNKNOWN
-123
View File
@@ -1,8 +1,5 @@
import re
import pytest
import net
import storage
import web
@@ -239,123 +236,3 @@ def test_kratka_hra_nic_neuseka(client):
])
def test_sklonovani_kol(count, expected):
assert web.kola(count) == expected
def offer(monkeypatch, *ips):
"""Server nabídne přesně tyhle adresy, v tomhle pořadí."""
found = [net.Address(ip=ip, interface="test", kind="lan", primary=not i)
for i, ip in enumerate(ips)]
monkeypatch.setattr(web.net, "addresses", lambda: found)
def test_tabule_nabidne_qr_ke_kazde_adrese(client, monkeypatch):
"""Kódy jsou vykreslené předem, přepíná se jen ten viditelný."""
offer(monkeypatch, "192.168.0.10", "100.91.0.24", "172.17.0.1")
game_id = start_game(client, "Adam", "Eva")
page = text(client.get(f"/board/{game_id}"))
assert page.count('<svg class="qr"') == 3
for ip in ("192.168.0.10", "100.91.0.24", "172.17.0.1"):
assert f'data-url="http://{ip}:8000/game/{game_id}"' in page
def test_vybrana_adresa_ze_seznamu_zmizi(client, monkeypatch):
"""Velkým písmem i v seznamu zároveň by byla dvakrát."""
offer(monkeypatch, "192.168.0.10", "100.91.0.24")
page = text(client.get("/board"))
assert '<li data-ip="192.168.0.10" hidden>' in page
assert '<li data-ip="100.91.0.24">' in page
def test_jedina_adresa_nema_co_prepinat(client, monkeypatch):
offer(monkeypatch, "192.168.0.10")
page = text(client.get("/board"))
assert '<ul class="alt">' not in page
assert page.count('<svg class="qr"') == 1
def test_otisk_hlida_vsechny_adresy(client, monkeypatch):
"""Tabule se načte znovu i když se změní jen ta druhá v pořadí —
nabídnuté QR kódy už by neplatily."""
game_id = start_game(client, "Adam", "Eva")
offer(monkeypatch, "192.168.0.10", "100.91.0.24")
before = text(client.get(f"/board/{game_id}"))
offer(monkeypatch, "192.168.0.10", "100.91.0.99")
after = text(client.get(f"/board/{game_id}"))
assert 'data-address="192.168.0.10,100.91.0.24"' in before
assert 'data-address="192.168.0.10,100.91.0.99"' in after
@pytest.mark.parametrize("row, expected", [
([], []),
([False, False], []),
([True], [(0, 1)]),
([True, True], [(0, 2)]),
([False, True, True], [(1, 2)]),
([True, True, False, True], [(0, 2), (3, 1)]),
])
def test_souvisle_useky(row, expected):
assert web.runs(row) == expected
def test_qr_obdelniky_kryji_stejne_moduly():
"""Slučování čtverečků do širších obdélníků nesmí kód změnit."""
import qrcode
data = "http://192.168.0.10:8000/"
code = qrcode.QRCode(box_size=1, border=2)
code.add_data(data)
code.make(fit=True)
matrix = code.get_matrix()
svg = str(web.qr_svg(data))
painted = set()
for x, y, width in re.findall(r'<rect x="(\d+)" y="(\d+)" width="(\d+)"', svg):
painted.update((int(x) + step, int(y)) for step in range(int(width)))
expected = {(x, y) for y, row in enumerate(matrix)
for x, dark in enumerate(row) if dark}
assert painted == expected
assert svg.count("<rect") < len(expected)
# --- záloha -----------------------------------------------------------------
def test_export_vrati_pouzitelnou_databazi(client, tmp_path):
"""Ze staženého souboru musí jít data přečíst zpátky, ne jen že se stáhne."""
game_id = start_game(client, "Adam", "Eva")
score(client, game_id, 150)
response = client.get("/export")
assert response.status_code == 200
assert "attachment" in response.headers["Content-Disposition"]
assert response.headers["Content-Disposition"].endswith(".db")
stazeny = tmp_path / "stazeny.db"
stazeny.write_bytes(response.data)
conn = storage.connect(stazeny)
try:
assert [p["name"] for p in storage.seating(conn, game_id)] == ["Adam", "Eva"]
assert storage.load_game(conn, game_id).totals()["Adam"] == 150
finally:
conn.close()
def test_export_zabere_i_rozehranou_hru(client):
"""Kopie se dělá za běhu, uprostřed nedohrané hry."""
game_id = start_game(client, "Adam")
score(client, game_id, 100)
assert client.get("/export").status_code == 200
assert text(client.get("/stats")).count("Stáhnout databázi") == 1
-60
View File
@@ -1,60 +0,0 @@
"""Závislosti jsou zamčené a všechna místa se shodnou.
Verze se píšou na dvou místech: v `requirements.txt` pro stavbu binárky
a v hlavičce PEP 723 pro `uv run web.py`. Ta dvě místa se dřív nebo později
rozejdou — leda že by to někdo hlídal. Proto tenhle soubor.
"""
import re
from pathlib import Path
import pytest
ROOT = Path(__file__).parent.parent
# Skript a to, co si podle hlavičky nese s sebou.
SCRIPTS = {"web.py": {"flask", "qrcode", "waitress"},
"dice.py": {"tabulate"}}
def pins(name: str) -> dict[str, str]:
"""Zamčené verze z compilovaného requirements souboru."""
text = (ROOT / name).read_text(encoding="utf-8")
return {m.group(1).lower(): m.group(2)
for m in re.finditer(r"^([A-Za-z0-9_.\-]+)==(\S+)", text, re.M)}
def header(script: str) -> dict[str, str]:
"""Závislosti z hlavičky PEP 723 na začátku skriptu."""
text = (ROOT / script).read_text(encoding="utf-8")
line = re.search(r"^# dependencies = \[(.*)\]$", text, re.M)
assert line, f"{script} nemá hlavičku PEP 723"
return {m.group(1).lower(): m.group(2)
for m in re.finditer(r'"([A-Za-z0-9_.\-]+)==(\S+?)"', line.group(1))}
@pytest.mark.parametrize("name", ["requirements.txt", "requirements-dev.txt",
"requirements-build.txt"])
def test_zavislosti_jsou_zamcene(name):
"""Bez zamčených verzí se binárka z března liší od binárky z dubna."""
lines = [line.strip() for line in (ROOT / name).read_text().splitlines()]
volne = [line for line in lines
if line and not line.startswith("#") and "==" not in line]
assert not volne, f"{name} má nezamčené závislosti: {volne}"
@pytest.mark.parametrize("script", sorted(SCRIPTS))
def test_hlavicka_sedi_s_requirements(script):
"""`uv run web.py` musí dostat totéž, co se zabalí do binárky."""
zamcene = pins("requirements.txt")
v_hlavicce = header(script)
assert set(v_hlavicce) == SCRIPTS[script]
for balicek, verze in v_hlavicce.items():
assert balicek in zamcene, f"{balicek} chybí v requirements.txt"
assert verze == zamcene[balicek], (
f"{script}: {balicek}=={verze}, ale zamčeno je "
f"{zamcene[balicek]} — přepiš hlavičku")
-49
View File
@@ -1,49 +0,0 @@
"""Která verze počitadla to vlastně je.
Venku jsou čtyři binárky pro tři systémy a k tomu zdrojáky. Když někdo
napíše, že mu něco nefunguje, je první otázka vždycky stejná — a bez
`--version` na ni nemá jak odpovědět.
"""
import subprocess
import sys
from functools import lru_cache
from pathlib import Path
NAME = "verze.txt"
UNKNOWN = "neznámá"
def bundled() -> str:
"""Verze vypálená do binárky při stavbě.
`build.sh` ji zapíše do souboru, `dice-counter.spec` ji přibalí. V binárce
leží vedle šablon v dočasném adresáři, na který ukazuje `sys._MEIPASS`.
"""
root = Path(getattr(sys, "_MEIPASS", Path(__file__).parent))
try:
return (root / NAME).read_text(encoding="utf-8").strip()
except OSError:
return ""
def described() -> str:
"""Verze podle gitu. Ze zdrojáků je to přesnější než cokoli zapsaného."""
try:
done = subprocess.run(
["git", "describe", "--tags", "--always", "--dirty"],
cwd=Path(__file__).parent, capture_output=True, text=True,
timeout=5, check=True)
except (OSError, subprocess.SubprocessError):
return ""
return done.stdout.strip()
@lru_cache(maxsize=1)
def current() -> str:
"""Verze pro výpis. Ptát se gitu má smysl jen mimo binárku."""
if getattr(sys, "frozen", False):
return bundled() or UNKNOWN
return described() or bundled() or UNKNOWN
+22 -101
View File
@@ -1,6 +1,6 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["flask==3.1.3", "qrcode==8.2", "waitress==3.0.2"]
# dependencies = ["flask", "qrcode"]
# ///
"""Webové počitadlo pro domácí síť.
@@ -8,26 +8,19 @@ Telefon zapisuje skóre (`/game/<id>`), notebook ukazuje zápisník (`/board`).
Server-rendered HTML, formuláře přes POST/redirect/GET, žádný build step.
"""
import io
import os
import shutil
import socket
import sys
import tempfile
from datetime import date
from pathlib import Path
import qrcode
from flask import (Flask, abort, g, redirect, render_template, request,
send_file, url_for)
from markupsafe import Markup, escape
from waitress import serve
url_for)
from markupsafe import Markup
import console
import net
import stats
import storage
import version
from core import FINAL_SCORE, Game, GameOver
def bundled(folder: str) -> str:
@@ -68,50 +61,31 @@ def close_db(_exception):
# --- adresa a QR kód --------------------------------------------------------
def address_key() -> str:
"""Otisk všech adres. Tabule ho hlídá: jakmile se změní, načte se znovu,
protože nabídnuté adresy i QR kódy už neplatí."""
return ",".join(address.ip for address in net.addresses())
def primary_ip() -> str:
"""Adresa, na kterou tabule ukazuje. Prázdno, když se nedá zjistit."""
found = net.addresses()
return found[0].ip if found else ""
def links(path: str = "/") -> dict:
"""Adresy, na kterých je počitadlo dostupné, i s cíli pro QR kódy.
"""Adresy, na kterých je počitadlo dostupné, i s cílem pro QR kód.
Hledá se při každém načtení — notebook se během večera může přesunout
z domácí WiFi na hotspot a zpátky. První v pořadí je ta nejnadějnější,
zbytek si člověk může na tabuli přepnout.
z domácí WiFi na hotspot a zpátky.
"""
found = net.addresses()
primary = found[0] if found else None
return {
"choices": [
{"ip": address.ip, "label": address.label,
"url": address.url(PORT) + path}
for address in net.addresses()
],
"primary": primary,
"others": found[1:],
"port": PORT,
"target": primary.url(PORT) + path if primary else "",
}
def runs(row: list[bool]) -> list[tuple[int, int]]:
"""Souvislé tmavé úseky řádku jako (začátek, délka).
Jeden široký obdélník místo řady čtverečků. Na tabuli visí QR kód pro
každou adresu zvlášť, takže se to na velikosti stránky pozná.
"""
found = []
start = None
for x, dark in enumerate([*row, False]):
if dark and start is None:
start = x
elif not dark and start is not None:
found.append((start, x - start))
start = None
return found
def qr_svg(data: str) -> Markup:
"""QR jako SVG složené z obdélníků — bez Pillow a bez práce s obrázky."""
"""QR jako SVG složené ze čtverečků — bez Pillow a bez práce s obrázky."""
code = qrcode.QRCode(box_size=1, border=2)
code.add_data(data)
code.make(fit=True)
@@ -119,24 +93,18 @@ def qr_svg(data: str) -> Markup:
size = len(matrix)
rects = [
f'<rect x="{x}" y="{y}" width="{width}" height="1"/>'
f'<rect x="{x}" y="{y}" width="1" height="1"/>'
for y, row in enumerate(matrix)
for x, width in runs(row)
for x, dark in enumerate(row) if dark
]
return Markup(
f'<svg class="qr" viewBox="0 0 {size} {size}" role="img" '
f'aria-label="QR kód s adresou {escape(data)}" '
f'aria-label="QR kód s adresou pro telefon" '
f'shape-rendering="crispEdges">{"".join(rects)}</svg>'
)
@app.context_processor
def verze():
"""Verze do patičky. Když někdo hlásí chybu, tohle je první otázka."""
return {"verze": version.current()}
@app.template_filter("datum")
def datum(value: str) -> str:
"""Z ISO textu udělá 12. 8. 2026."""
@@ -216,7 +184,7 @@ def view(game: Game, game_id: int, row) -> dict:
"standings": game.standings(),
"winner": winner,
"current": "" if winner else game.current_player,
"address_key": address_key(),
"primary_ip": primary_ip(),
}
@@ -323,36 +291,6 @@ def board_panel(game_id: int):
return render_template("_panel.html", **view(game, game_id, row))
@app.route("/export")
def export():
"""Databáze ke stažení.
Dokud se hrálo ze zdrojáků, ležel `dice.db` v pracovním adresáři a záloha
byla otázka zkopírování. Z binárky se schovává do systémového datového
adresáře, který na Windows běžný člověk nenajde — tak ať se dá stáhnout
přes prohlížeč.
`VACUUM INTO` udělá konzistentní kopii i uprostřed zápisu, na rozdíl od
prostého přečtení souboru. Výsledek si přečteme do paměti a kopii hned
zahodíme: databáze počitadla se počítá v kilobajtech a takhle nezůstane
ležet v dočasném adresáři, když si ji někdo nestáhne celou.
"""
conn = db()
conn.commit() # VACUUM nesmí běžet v transakci
docasny = tempfile.mkdtemp()
try:
kopie = Path(docasny) / "dice.db"
conn.execute("VACUUM INTO ?", (str(kopie),))
data = kopie.read_bytes()
finally:
shutil.rmtree(docasny, ignore_errors=True)
return send_file(io.BytesIO(data), mimetype="application/vnd.sqlite3",
as_attachment=True,
download_name=f"dice-counter-{date.today().isoformat()}.db")
@app.route("/stats")
def hall():
conn = db()
@@ -362,10 +300,7 @@ def hall():
games=storage.recent_games(conn))
def announce() -> None:
"""Vypíše, kde všude je počitadlo k mání, a čím je zrovna spuštěné."""
print(f"dice-counter {version.current()}\n")
if __name__ == "__main__":
found = net.addresses()
if found:
@@ -379,18 +314,4 @@ def announce() -> None:
print(f"\nDatabáze: {storage.DB_PATH.resolve()}")
if __name__ == "__main__":
console.utf8()
if {"--version", "-V"} & set(sys.argv[1:]):
print(f"dice-counter {version.current()}")
sys.exit(0)
announce()
# Ne vestavěný server Flasku: ten pod úvodní hlášku vysype červené
# varování, že takhle se to nemá, a má pravdu. Waitress je čistě
# pythonní, takže se zabalí do binárky bez řečí, a osm vláken pobere
# telefony u stolu i tabuli, která se doptává každé dvě vteřiny.
serve(app, host="0.0.0.0", port=PORT, threads=8)
app.run(host="0.0.0.0", port=PORT, threaded=True)