Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf3c167e01 |
No files matched your search
@@ -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
|
|
||||||
@@ -53,16 +53,10 @@ jobs:
|
|||||||
uv --version
|
uv --version
|
||||||
|
|
||||||
- name: Testy
|
- name: Testy
|
||||||
run: uv run --with-requirements requirements-dev.txt --no-project pytest -q
|
run: uv run --with-requirements requirements.txt --no-project pytest -q
|
||||||
|
|
||||||
- name: Binárka
|
- name: Binárka
|
||||||
run: |
|
run: ./build.sh
|
||||||
# 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
|
- name: Zkouška
|
||||||
run: |
|
run: |
|
||||||
@@ -80,7 +74,6 @@ jobs:
|
|||||||
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/static/style.css
|
||||||
curl -sf -o /dev/null http://127.0.0.1:8321/board
|
curl -sf -o /dev/null http://127.0.0.1:8321/board
|
||||||
test -f /tmp/zkouska/kostky/dice.db
|
test -f /tmp/zkouska/kostky/dice.db
|
||||||
./dist/kostky --version
|
|
||||||
cat /tmp/server.log
|
cat /tmp/server.log
|
||||||
|
|
||||||
- name: Na release
|
- name: Na release
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
- name: Testy
|
- name: Testy
|
||||||
run: |
|
run: |
|
||||||
uv run --managed-python --python "${{ matrix.python }}" \
|
uv run --managed-python --python "${{ matrix.python }}" \
|
||||||
--with-requirements requirements-dev.txt --no-project \
|
--with-requirements requirements.txt --no-project \
|
||||||
pytest -q
|
pytest -q
|
||||||
|
|
||||||
- name: Terminálová verze
|
- name: Terminálová verze
|
||||||
|
|||||||
@@ -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: kostky-windows-x86_64.exe
|
|
||||||
built: dist/kostky.exe
|
|
||||||
- os: macos-15-intel # Intel; macos-13 GitHub zrušil
|
|
||||||
asset: kostky-macos-x86_64
|
|
||||||
built: dist/kostky
|
|
||||||
- os: macos-latest # Apple Silicon
|
|
||||||
asset: kostky-macos-arm64
|
|
||||||
built: dist/kostky
|
|
||||||
- os: ubuntu-24.04-arm # pro Raspberry Pi a spol.
|
|
||||||
asset: kostky-linux-arm64
|
|
||||||
built: dist/kostky
|
|
||||||
|
|
||||||
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 kostky-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
|
|
||||||
@@ -5,4 +5,3 @@ __pycache__/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
.build/
|
.build/
|
||||||
verze.txt
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Dice Counter
|
# Počitadlo v kostkách
|
||||||
|
|
||||||
Doprovod k fyzickým kostkám: **telefon zapisuje skóre, notebook ukazuje
|
Doprovod k fyzickým kostkám: **telefon zapisuje skóre, notebook ukazuje
|
||||||
zápisník** a hry se pamatují mezi večery.
|
zápisník** a hry se pamatují mezi večery.
|
||||||
@@ -8,46 +8,16 @@ ten samý Adam jako z minulého měsíce. Žádné účty, žádná hesla.
|
|||||||
|
|
||||||
## Spuštění
|
## Spuštění
|
||||||
|
|
||||||
Stáhni binárku pro svůj systém z
|
Stáhni binárku z [releases](https://gitea.spacilovi.eu/david-spacil/dice-counter/releases)
|
||||||
[releases](https://gitea.spacilovi.eu/david-spacil/dice-counter/releases):
|
a spusť ji:
|
||||||
|
|
||||||
| Systém | Soubor |
|
|
||||||
|---|---|
|
|
||||||
| Linux (x86-64) | `kostky-linux-x86_64` |
|
|
||||||
| Linux (ARM64) | `kostky-linux-arm64` |
|
|
||||||
| macOS (Apple Silicon) | `kostky-macos-arm64` |
|
|
||||||
| macOS (Intel) | `kostky-macos-x86_64` |
|
|
||||||
| Windows (x86-64) | `kostky-windows-x86_64.exe` |
|
|
||||||
|
|
||||||
Nic se neinstaluje — Python, Flask i šablony jsou uvnitř.
|
|
||||||
|
|
||||||
Na Linuxu a macOS:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x kostky-*
|
chmod +x kostky
|
||||||
./kostky-linux-x86_64
|
./kostky
|
||||||
```
|
```
|
||||||
|
|
||||||
Na Windows stačí na `.exe` poklepat.
|
Nic se neinstaluje — Python, Flask i šablony jsou uvnitř. Zatím jen pro Linux
|
||||||
|
na x86-64; postavené proti glibc 2.17, takže jede prakticky všude.
|
||||||
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 kostky-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é.
|
|
||||||
|
|
||||||
Ze zdrojáků to je stejně krátké:
|
Ze zdrojáků to je stejně krátké:
|
||||||
|
|
||||||
@@ -60,20 +30,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ý:
|
Server vypíše všechny adresy, na kterých je dostupný:
|
||||||
|
|
||||||
```
|
```
|
||||||
Kostky v1.1.0
|
|
||||||
|
|
||||||
Počitadlo je dostupné na:
|
Počitadlo je dostupné na:
|
||||||
→ http://10.186.234.182:8000 místní síť
|
→ http://10.186.234.182:8000 místní síť
|
||||||
http://100.91.0.24:8000 přes Tailscale
|
http://100.91.0.24:8000 přes Tailscale
|
||||||
http://172.17.0.1:8000 virtuální síť
|
http://172.17.0.1:8000 virtuální síť
|
||||||
```
|
```
|
||||||
|
|
||||||
Kterou verzi máš, řekne i `kostky --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
|
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
|
zobrazuje i zbylé adresy — když jedna nefunguje, zkus další. Zastavuje se
|
||||||
přepne na ni. Zastavuje se `Ctrl+C`.
|
`Ctrl+C`.
|
||||||
|
|
||||||
| Proměnná | K čemu je | Výchozí |
|
| Proměnná | K čemu je | Výchozí |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -88,9 +53,7 @@ server vypíše při startu.
|
|||||||
|
|
||||||
| Spuštěno | Databáze |
|
| Spuštěno | Databáze |
|
||||||
|---|---|
|
|---|---|
|
||||||
| binárkou na Linuxu | `~/.local/share/kostky/dice.db` |
|
| binárkou | `~/.local/share/kostky/dice.db` |
|
||||||
| binárkou na macOS | `~/Library/Application Support/kostky/dice.db` |
|
|
||||||
| binárkou na Windows | `%LOCALAPPDATA%\kostky\dice.db` |
|
|
||||||
| ze zdrojáků | `dice.db` v pracovním adresáři |
|
| 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
|
Binárka se při každém spuštění rozbaluje do dočasného adresáře a pouští se
|
||||||
@@ -99,101 +62,11 @@ napevno domovský adresář. Ze zdrojáků zůstává relativní, ať se dá mí
|
|||||||
sad vedle sebe.
|
sad vedle sebe.
|
||||||
|
|
||||||
Zálohovat i stěhovat jde prostým zkopírováním souboru; `DICE_DB` si ho
|
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ý —
|
najde kdekoli.
|
||||||
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.
|
|
||||||
|
|
||||||
Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi
|
Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi
|
||||||
může zapisovat.
|
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-kostky.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` | `kostky` |
|
|
||||||
| `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 |
|
|
||||||
| `VERSION` | poslední vydání |
|
|
||||||
|
|
||||||
Aktualizace na novější vydání je ten samý příkaz s číslem kontejneru:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CTID=123 MODE=update bash -c "$(curl -fsSL ...)"
|
|
||||||
```
|
|
||||||
|
|
||||||
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-kostky.sh instalator > install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Je idempotentní; druhé spuštění jen vymění binárku za nejnovější.
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd deploy && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Staví se ze zdrojáků, takže image jde sestavit pro amd64 i arm64 a nezávisí
|
|
||||||
na tom, jestli release pro danou architekturu proběhl. Databáze leží
|
|
||||||
v pojmenovaném svazku, appka běží pod nerootovým uživatelem.
|
|
||||||
|
|
||||||
**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 | zdrojáky |
|
|
||||||
| Databáze | `/var/lib/kostky/dice.db` | svazek `kostky-data` |
|
|
||||||
| Aktualizace | `pve-kostky.sh update <ctid>` | `docker compose build --pull` |
|
|
||||||
| Adresa a QR | funguje samo | potřeba síť hostitele |
|
|
||||||
|
|
||||||
## Když se telefon nepřipojí
|
## 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
|
Adresy se hledají při každém načtení tabule, takže přechod z domácí WiFi na
|
||||||
@@ -227,12 +100,7 @@ při remíze na prvním místě se hraje dál.
|
|||||||
| `stats.py` | síň slávy a kariérní statistiky |
|
| `stats.py` | síň slávy a kariérní statistiky |
|
||||||
| `web.py` | Flask aplikace |
|
| `web.py` | Flask aplikace |
|
||||||
| `dice.py` | totéž v terminálu, bez ukládání |
|
| `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`, `kostky.spec` | stavba binárky |
|
| `build.sh`, `kostky.spec` | stavba binárky |
|
||||||
| `.gitea/workflows/` | testy a linuxová binárka doma |
|
|
||||||
| `.github/workflows/` | binárky pro Windows a macOS |
|
|
||||||
| `deploy/` | nasazení na server: Proxmox LXC a Docker |
|
|
||||||
|
|
||||||
Terminálová verze zůstává funkční jako záloha:
|
Terminálová verze zůstává funkční jako záloha:
|
||||||
|
|
||||||
@@ -253,89 +121,40 @@ python -m venv .venv
|
|||||||
Na Fedoře stačí i systémové balíčky, pak se nemusí řešit vůbec nic:
|
Na Fedoře stačí i systémové balíčky, pak se nemusí řešit vůbec nic:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo dnf install python3-flask python3-qrcode python3-tabulate python3-waitress
|
sudo dnf install python3-flask python3-qrcode python3-tabulate
|
||||||
python3 web.py
|
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
|
## Vlastní binárka
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./build.sh
|
./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`, `kostky.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
|
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
|
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,
|
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
|
ne dopřednou. Nic z hostitele se do binárky nedostane, takže kontejner k tomu
|
||||||
potřeba není. Výsledek je v `dist/`.
|
potřeba není. Výsledek je v `dist/kostky`.
|
||||||
|
|
||||||
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.**
|
|
||||||
|
|
||||||
Terminálová verze součástí binárky není; ta se pouští ze zdrojáků.
|
Terminálová verze součástí binárky není; ta se pouští ze zdrojáků.
|
||||||
|
|
||||||
### Jak vznikají releasy
|
Releasy si tohle dělají samy. Otagovaný commit spustí
|
||||||
|
`.gitea/workflows/binarka.yml`, ta binárku postaví, projede testy, zkusí
|
||||||
Otagovaný commit spustí stavbu na obou stranách:
|
ji nastartovat a pověsí ji na release jako `kostky-linux-x86_64`:
|
||||||
|
|
||||||
| 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` |
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag v1.0.0 && git push origin v1.0.0
|
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í
|
Vedle binárky se věší i `kostky-linux-x86_64.sha256`, takže se stažený
|
||||||
([mirror](https://github.com/david-spacil/dice-counter)), postavené soubory
|
soubor dá ověřit:
|
||||||
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
|
```bash
|
||||||
sha256sum -c kostky-linux-x86_64.sha256 # na macOS: shasum -a 256 -c
|
sha256sum -c kostky-linux-x86_64.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
Nad pull requesty se Linux staví taky, jen se nikam nevěší.
|
Stejná stavba běží i nad každým pull requestem, jen se nikam nevěší.
|
||||||
|
|
||||||
## Testy
|
## Testy
|
||||||
|
|
||||||
@@ -346,5 +165,5 @@ uv run --with-requirements requirements.txt --no-project pytest
|
|||||||
Nebo z připraveného prostředí prostě `pytest`.
|
Nebo z připraveného prostředí prostě `pytest`.
|
||||||
|
|
||||||
Nad každým pull requestem a nad `main` běží `.gitea/workflows/testy.yml` —
|
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
|
84 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á.
|
v terminálové verzi, na kterou pytest nesahá.
|
||||||
@@ -1,44 +1,22 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
# Staví se proti samostatnému CPythonu od uv, ne proti systémovému. Ten je
|
||||||
# ten systém. Linux se staví doma na vlastním runneru, Windows a macOS na
|
# slinkovaný s glibc 2.17, takže binárka jede i na letitých systémech.
|
||||||
# půjčených strojích GitHub Actions (.github/workflows/binarky.yml).
|
# 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.
|
||||||
# Staví se proti samostatnému CPythonu od uv, ne proti systémovému. Na Linuxu
|
# Nic z hostitele se do ní nedostane, takže kontejner k tomu není potřeba.
|
||||||
# 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.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PYTHON_VERSION=${PYTHON_VERSION:-3.11}
|
VERSION=${VERSION:-3.11}
|
||||||
BUILD=${BUILD:-.build}
|
BUILD=${BUILD:-.build}
|
||||||
|
|
||||||
# Verze, kterou binárka ohlásí přes --version. V CI ji na tagu vnutíme
|
uv python install "$VERSION"
|
||||||
# proměnnou, protože tam je repozitář naklonovaný na jeden commit a git
|
uv venv --quiet --managed-python --python "$VERSION" "$BUILD"
|
||||||
# describe nemá o co se opřít.
|
uv pip install --quiet --python "$BUILD/bin/python" -r requirements.txt pyinstaller
|
||||||
VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo neznámá)}
|
|
||||||
printf '%s\n' "$VERSION" > verze.txt
|
|
||||||
echo "Verze: $VERSION"
|
|
||||||
|
|
||||||
uv python install "$PYTHON_VERSION"
|
"$BUILD/bin/pyinstaller" --clean --noconfirm \
|
||||||
# --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" kostky.spec
|
--distpath dist --workpath "$BUILD/work" kostky.spec
|
||||||
|
|
||||||
echo
|
echo
|
||||||
ls -lh dist/
|
ls -lh dist/kostky
|
||||||
-27
@@ -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)
|
|
||||||
@@ -1,47 +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 kostky .
|
|
||||||
#
|
|
||||||
# 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á
|
|
||||||
|
|
||||||
RUN useradd --system --uid 1000 --home-dir /app --shell /usr/sbin/nologin kostky
|
|
||||||
|
|
||||||
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
|
|
||||||
# kostky by do něj nezapsala.
|
|
||||||
RUN install -d -o kostky -g kostky /data
|
|
||||||
VOLUME ["/data"]
|
|
||||||
|
|
||||||
ENV DICE_DB=/data/dice.db \
|
|
||||||
DICE_PORT=8000
|
|
||||||
|
|
||||||
EXPOSE 8000
|
|
||||||
USER kostky
|
|
||||||
|
|
||||||
# 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"]
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Počitadlo kostek v Dockeru.
|
|
||||||
#
|
|
||||||
# cd deploy && docker compose up -d
|
|
||||||
#
|
|
||||||
# Počítá se s domácí sítí. Appka nemá přihlašování, tohle ji nijak
|
|
||||||
# nezabezpečuje a do internetu nepatří.
|
|
||||||
|
|
||||||
services:
|
|
||||||
kostky:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: deploy/Dockerfile
|
|
||||||
args:
|
|
||||||
VERSION: ${VERSION:-vlastní sestavení}
|
|
||||||
image: kostky:latest
|
|
||||||
container_name: kostky
|
|
||||||
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:
|
|
||||||
- kostky-data:/data
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
# Zálohu si stáhneš rovnou z appky přes /export, takže se sem nemusí lézt.
|
|
||||||
kostky-data:
|
|
||||||
@@ -1,336 +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-kostky.sh)"
|
|
||||||
#
|
|
||||||
# Aktualizace stávajícího kontejneru na novější vydání:
|
|
||||||
#
|
|
||||||
# CTID=123 MODE=update bash -c "$(curl -fsSL .../pve-kostky.sh)"
|
|
||||||
#
|
|
||||||
# Ze staženého souboru jde i obojí postaru: `bash pve-kostky.sh` a
|
|
||||||
# `bash pve-kostky.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="Kostky"
|
|
||||||
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:-kostky}"
|
|
||||||
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}"
|
|
||||||
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-kostky.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=kostky
|
|
||||||
HOME_DIR=/opt/kostky
|
|
||||||
DATA_DIR=/var/lib/kostky
|
|
||||||
|
|
||||||
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=kostky-linux-x86_64 ;;
|
|
||||||
arm64) ASSET=kostky-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 kostky; then
|
|
||||||
systemctl stop kostky
|
|
||||||
fi
|
|
||||||
|
|
||||||
install -o root -g root -m 755 "$TMP/$ASSET" "$HOME_DIR/kostky"
|
|
||||||
echo "$VERSION" > "$HOME_DIR/verze"
|
|
||||||
ok "Binárka je na místě"
|
|
||||||
|
|
||||||
msg "Zapisuji službu"
|
|
||||||
cat > /etc/systemd/system/kostky.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/kostky
|
|
||||||
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=kostky
|
|
||||||
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 kostky
|
|
||||||
ok "Služba běží"
|
|
||||||
|
|
||||||
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 kostky."
|
|
||||||
|
|
||||||
# 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/kostky-install.sh --perms 0755
|
|
||||||
rm -f "$tmp"
|
|
||||||
|
|
||||||
pct exec "$ctid" -- env PORT="$PORT" GITEA="$GITEA" REPO="$REPO" \
|
|
||||||
VERSION="$VERSION" bash /root/kostky-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/kostky/dice.db (uvnitř kontejneru)"
|
|
||||||
echo -e " Log: pct exec $ctid -- journalctl -u kostky -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-kostky.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"
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# /// script
|
# /// script
|
||||||
# requires-python = ">=3.11"
|
# requires-python = ">=3.11"
|
||||||
# dependencies = ["tabulate==0.10.0"]
|
# dependencies = ["tabulate"]
|
||||||
# ///
|
# ///
|
||||||
"""Počitadlo skóre v terminálu.
|
"""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`).
|
v paměti, nic se neukládá — na historii je webová aplikace (`web.py`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
from os import name, system
|
from os import name, system
|
||||||
|
|
||||||
from tabulate import tabulate
|
from tabulate import tabulate
|
||||||
|
|
||||||
import console
|
|
||||||
import version
|
|
||||||
from core import FINAL_SCORE, Game
|
from core import FINAL_SCORE, Game
|
||||||
|
|
||||||
table_header = ["Hráč", "Skóre"]
|
table_header = ["Hráč", "Skóre"]
|
||||||
@@ -131,10 +128,4 @@ def main() -> None:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
console.utf8()
|
|
||||||
|
|
||||||
if {"--version", "-V"} & set(sys.argv[1:]):
|
|
||||||
print(f"kostky {version.current()}")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
main()
|
main()
|
||||||
+2
-14
@@ -5,19 +5,10 @@ Staví se přes `./build.sh`, ne ručně — ta se stará o to, aby výsledek je
|
|||||||
i na starších systémech, než je ten tvůj.
|
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(
|
analysis = Analysis(
|
||||||
["web.py"],
|
["web.py"],
|
||||||
pathex=["."],
|
pathex=["."],
|
||||||
datas=datas,
|
datas=[("templates", "templates"), ("static", "static")],
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,9 +21,6 @@ exe = EXE(
|
|||||||
analysis.datas,
|
analysis.datas,
|
||||||
name="kostky",
|
name="kostky",
|
||||||
console=True,
|
console=True,
|
||||||
# Ořezání symbolů šetří pár megabajtů, ale jen na Linuxu. Na macOS by
|
strip=True,
|
||||||
# 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,
|
upx=False,
|
||||||
)
|
)
|
||||||
@@ -11,9 +11,7 @@ adresy a obecný typ rozhraní, ne to, co má kdo zrovna nainstalované.
|
|||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
# Rozsah, ze kterého adresy přiděluje Tailscale (RFC 6598, CGNAT).
|
# 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
|
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]]:
|
def _from_hostname() -> list[tuple[str, str]]:
|
||||||
"""Poslední záloha. Na Windows vrátí adresy všech rozhraní, jinde
|
"""Záloha pro systémy, kde předchozí cesta nefunguje."""
|
||||||
obvykle jen jednu — jména rozhraní se takhle nedozvíme."""
|
|
||||||
try:
|
try:
|
||||||
infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET)
|
infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -183,7 +144,7 @@ def addresses() -> list[Address]:
|
|||||||
|
|
||||||
primary = default_route_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]:
|
if primary and primary not in [ip for _name, ip in found]:
|
||||||
found.append(("", primary))
|
found.append(("", primary))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -1,25 +1,4 @@
|
|||||||
# This file was autogenerated by uv via the following command:
|
flask
|
||||||
# uv pip compile requirements.in -o requirements.txt --python-version 3.11
|
qrcode
|
||||||
blinker==1.9.0
|
tabulate
|
||||||
# via flask
|
pytest
|
||||||
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
|
|
||||||
+1
-29
@@ -373,7 +373,7 @@ button {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.join .address .what {
|
.join .address span {
|
||||||
display: block;
|
display: block;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
@@ -382,8 +382,6 @@ button {
|
|||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.join .address .url { display: block; }
|
|
||||||
|
|
||||||
.qr { width: 5.5rem; height: 5.5rem; fill: var(--ink); }
|
.qr { width: 5.5rem; height: 5.5rem; fill: var(--ink); }
|
||||||
|
|
||||||
.idle {
|
.idle {
|
||||||
@@ -482,24 +480,6 @@ button {
|
|||||||
|
|
||||||
.alt li { white-space: nowrap; }
|
.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 {
|
.alt em {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -513,11 +493,3 @@ button {
|
|||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
color: var(--ink);
|
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
@@ -16,30 +16,20 @@ from pathlib import Path
|
|||||||
|
|
||||||
from core import FINAL_SCORE, Game, Turn
|
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:
|
def default_db() -> Path:
|
||||||
"""Kam se ukládá databáze.
|
"""Kam se ukládá databáze.
|
||||||
|
|
||||||
Ze zdrojáků do pracovního adresáře — dá se tak mít víc rozehraných
|
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
|
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.
|
dočasného adresáře, takže relativní cesta by databázi rozsypala po disku.
|
||||||
"""
|
"""
|
||||||
chosen = os.environ.get("DICE_DB")
|
chosen = os.environ.get("DICE_DB")
|
||||||
if chosen:
|
if chosen:
|
||||||
return Path(chosen)
|
return Path(chosen)
|
||||||
if getattr(sys, "frozen", False):
|
if getattr(sys, "frozen", False):
|
||||||
return data_home() / "kostky" / "dice.db"
|
share = os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
|
||||||
|
return Path(share) / "kostky" / "dice.db"
|
||||||
return Path("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);
|
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:
|
def connect(path: Path | str | None = None) -> sqlite3.Connection:
|
||||||
target = Path(path or DB_PATH)
|
target = Path(path or DB_PATH)
|
||||||
@@ -132,7 +83,7 @@ def connect(path: Path | str | None = None) -> sqlite3.Connection:
|
|||||||
conn = sqlite3.connect(target)
|
conn = sqlite3.connect(target)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA foreign_keys = ON")
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
migrate(conn)
|
conn.executescript(SCHEMA)
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+9
-45
@@ -1,58 +1,22 @@
|
|||||||
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může
|
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může člověk. #}
|
||||||
člověk — QR kódy jsou vykreslené všechny, přepíná se jen ten viditelný. #}
|
{% if primary %}
|
||||||
{% if choices %}
|
|
||||||
<div class="join">
|
<div class="join">
|
||||||
<div class="qr-stack">
|
{{ target|qr }}
|
||||||
{% for choice in choices %}
|
|
||||||
<div class="qr-slot" data-ip="{{ choice.ip }}"{% if not loop.first %} hidden{% endif %}>
|
|
||||||
{{ choice.url|qr }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="address">
|
<div class="address">
|
||||||
<span class="what">Zapisuje se z telefonu</span>
|
<span>Zapisuje se z telefonu</span>
|
||||||
<span class="url">{{ choices[0].url }}</span>
|
{{ target }}
|
||||||
|
{% if others %}
|
||||||
{% if choices|length > 1 %}
|
|
||||||
<ul class="alt">
|
<ul class="alt">
|
||||||
{% for choice in choices %}
|
{% for address in others %}
|
||||||
<li data-ip="{{ choice.ip }}"{% if loop.first %} hidden{% endif %}>
|
<li>{{ address.ip }}:{{ port }} <em>{{ address.label }}</em></li>
|
||||||
<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>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% else %}
|
||||||
<div class="join">
|
<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=<adresa></code>.</p>
|
Spusť server s <code>DICE_HOST=<adresa></code>.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{# Fragment pro tabuli: obě části, které se během hry mění. #}
|
{# 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>
|
<div id="sheet">{% include "_sheet.html" %}</div>
|
||||||
@@ -8,6 +8,5 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% block body %}{% endblock %}
|
{% block body %}{% endblock %}
|
||||||
<footer class="verze">kostky {{ verze }}</footer>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="board">
|
<div class="board">
|
||||||
<header class="masthead">
|
<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" %}
|
{% include "_join.html" %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
<p class="eyebrow">Počitadlo</p>
|
<p class="eyebrow">Počitadlo</p>
|
||||||
<h1>Síň slávy</h1>
|
<h1>Síň slávy</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="meta"><a href="{{ url_for('index') }}">Nová hra</a>
|
<p class="meta"><a href="{{ url_for('index') }}">Nová hra</a></p>
|
||||||
· <a href="{{ url_for('export') }}">Stáhnout databázi</a></p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if records.best_turn %}
|
{% if records.best_turn %}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
@@ -90,50 +90,3 @@ def test_v_systemu_se_neco_najde():
|
|||||||
|
|
||||||
assert all(net.classify(a.ip, a.interface) for a in found)
|
assert all(net.classify(a.ip, a.interface) for a in found)
|
||||||
assert len({a.ip for a in found}) == len(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,4 +1,3 @@
|
|||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -149,28 +148,11 @@ def test_umisteni_databaze_z_binarky(monkeypatch, tmp_path):
|
|||||||
"""Binárka se rozbaluje do dočasného adresáře, databáze tam nesmí."""
|
"""Binárka se rozbaluje do dočasného adresáře, databáze tam nesmí."""
|
||||||
monkeypatch.delenv("DICE_DB", raising=False)
|
monkeypatch.delenv("DICE_DB", raising=False)
|
||||||
monkeypatch.setattr(storage.sys, "frozen", True, raising=False)
|
monkeypatch.setattr(storage.sys, "frozen", True, raising=False)
|
||||||
monkeypatch.setattr(storage.sys, "platform", "linux")
|
|
||||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||||
|
|
||||||
assert storage.default_db() == tmp_path / "kostky" / "dice.db"
|
assert storage.default_db() == tmp_path / "kostky" / "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"
|
|
||||||
|
|
||||||
|
|
||||||
def test_dice_db_prebije_oboje(monkeypatch, tmp_path):
|
def test_dice_db_prebije_oboje(monkeypatch, tmp_path):
|
||||||
monkeypatch.setattr(storage.sys, "frozen", True, raising=False)
|
monkeypatch.setattr(storage.sys, "frozen", True, raising=False)
|
||||||
monkeypatch.setenv("DICE_DB", str(tmp_path / "jinde.db"))
|
monkeypatch.setenv("DICE_DB", str(tmp_path / "jinde.db"))
|
||||||
@@ -183,69 +165,3 @@ def test_connect_zalozi_chybejici_adresar(tmp_path):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
assert (tmp_path / "novy" / "adresar" / "dice.db").exists()
|
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)
|
|
||||||
@@ -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
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import net
|
|
||||||
import storage
|
import storage
|
||||||
import web
|
import web
|
||||||
|
|
||||||
@@ -239,123 +236,3 @@ def test_kratka_hra_nic_neuseka(client):
|
|||||||
])
|
])
|
||||||
def test_sklonovani_kol(count, expected):
|
def test_sklonovani_kol(count, expected):
|
||||||
assert web.kola(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
|
|
||||||
@@ -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
@@ -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, `kostky.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
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# /// script
|
# /// script
|
||||||
# requires-python = ">=3.11"
|
# 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íť.
|
"""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.
|
Server-rendered HTML, formuláře přes POST/redirect/GET, žádný build step.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
from datetime import date
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import qrcode
|
import qrcode
|
||||||
from flask import (Flask, abort, g, redirect, render_template, request,
|
from flask import (Flask, abort, g, redirect, render_template, request,
|
||||||
send_file, url_for)
|
url_for)
|
||||||
from markupsafe import Markup, escape
|
from markupsafe import Markup
|
||||||
from waitress import serve
|
|
||||||
|
|
||||||
import console
|
|
||||||
import net
|
import net
|
||||||
import stats
|
import stats
|
||||||
import storage
|
import storage
|
||||||
import version
|
|
||||||
from core import FINAL_SCORE, Game, GameOver
|
from core import FINAL_SCORE, Game, GameOver
|
||||||
|
|
||||||
def bundled(folder: str) -> str:
|
def bundled(folder: str) -> str:
|
||||||
@@ -68,50 +61,31 @@ def close_db(_exception):
|
|||||||
|
|
||||||
# --- adresa a QR kód --------------------------------------------------------
|
# --- adresa a QR kód --------------------------------------------------------
|
||||||
|
|
||||||
def address_key() -> str:
|
def primary_ip() -> str:
|
||||||
"""Otisk všech adres. Tabule ho hlídá: jakmile se změní, načte se znovu,
|
"""Adresa, na kterou tabule ukazuje. Prázdno, když se nedá zjistit."""
|
||||||
protože nabídnuté adresy i QR kódy už neplatí."""
|
found = net.addresses()
|
||||||
return ",".join(address.ip for address in net.addresses())
|
return found[0].ip if found else ""
|
||||||
|
|
||||||
|
|
||||||
def links(path: str = "/") -> dict:
|
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
|
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ší,
|
z domácí WiFi na hotspot a zpátky.
|
||||||
zbytek si člověk může na tabuli přepnout.
|
|
||||||
"""
|
"""
|
||||||
|
found = net.addresses()
|
||||||
|
primary = found[0] if found else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"choices": [
|
"primary": primary,
|
||||||
{"ip": address.ip, "label": address.label,
|
"others": found[1:],
|
||||||
"url": address.url(PORT) + path}
|
|
||||||
for address in net.addresses()
|
|
||||||
],
|
|
||||||
"port": PORT,
|
"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:
|
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 = qrcode.QRCode(box_size=1, border=2)
|
||||||
code.add_data(data)
|
code.add_data(data)
|
||||||
code.make(fit=True)
|
code.make(fit=True)
|
||||||
@@ -119,24 +93,18 @@ def qr_svg(data: str) -> Markup:
|
|||||||
size = len(matrix)
|
size = len(matrix)
|
||||||
|
|
||||||
rects = [
|
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 y, row in enumerate(matrix)
|
||||||
for x, width in runs(row)
|
for x, dark in enumerate(row) if dark
|
||||||
]
|
]
|
||||||
|
|
||||||
return Markup(
|
return Markup(
|
||||||
f'<svg class="qr" viewBox="0 0 {size} {size}" role="img" '
|
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>'
|
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")
|
@app.template_filter("datum")
|
||||||
def datum(value: str) -> str:
|
def datum(value: str) -> str:
|
||||||
"""Z ISO textu udělá 12. 8. 2026."""
|
"""Z ISO textu udělá 12. 8. 2026."""
|
||||||
@@ -216,7 +184,7 @@ def view(game: Game, game_id: int, row) -> dict:
|
|||||||
"standings": game.standings(),
|
"standings": game.standings(),
|
||||||
"winner": winner,
|
"winner": winner,
|
||||||
"current": "" if winner else game.current_player,
|
"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))
|
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"kostky-{date.today().isoformat()}.db")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/stats")
|
@app.route("/stats")
|
||||||
def hall():
|
def hall():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -362,10 +300,7 @@ def hall():
|
|||||||
games=storage.recent_games(conn))
|
games=storage.recent_games(conn))
|
||||||
|
|
||||||
|
|
||||||
def announce() -> None:
|
if __name__ == "__main__":
|
||||||
"""Vypíše, kde všude je počitadlo k mání, a čím je zrovna spuštěné."""
|
|
||||||
print(f"Kostky {version.current()}\n")
|
|
||||||
|
|
||||||
found = net.addresses()
|
found = net.addresses()
|
||||||
|
|
||||||
if found:
|
if found:
|
||||||
@@ -379,18 +314,4 @@ def announce() -> None:
|
|||||||
|
|
||||||
print(f"\nDatabáze: {storage.DB_PATH.resolve()}")
|
print(f"\nDatabáze: {storage.DB_PATH.resolve()}")
|
||||||
|
|
||||||
|
app.run(host="0.0.0.0", port=PORT, threaded=True)
|
||||||
if __name__ == "__main__":
|
|
||||||
console.utf8()
|
|
||||||
|
|
||||||
if {"--version", "-V"} & set(sys.argv[1:]):
|
|
||||||
print(f"kostky {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)
|
|
||||||
Reference in new issue
Block a user