Author SHA1 Message Date
david-spacil e0413b6adf Merge pull request 'Čeština na výpisu nesmí shodit server' (#11) from agents/claude/cestina-na-windows into main
testy / pytest (3.11) (push) Successful in 1s
testy / pytest (3.12) (push) Successful in 1s
testy / pytest (3.13) (push) Successful in 1s
testy / pytest (3.14) (push) Successful in 1s
binárka / linux (push) Successful in 11s
Reviewed-on: #11
2026-08-22 16:17:10 +02:00
gitea-actionsandClaude Opus 5 5821385dc8 Čeština na výpisu nesmí shodit server
binárka / linux (pull_request) Successful in 10s
testy / pytest (3.11) (pull_request) Successful in 1s
testy / pytest (3.12) (pull_request) Successful in 0s
testy / pytest (3.13) (pull_request) Successful in 0s
testy / pytest (3.14) (pull_request) Successful in 1s
Windowsová binárka spadla dřív, než stihla vypsat adresu:

  print("Počitadlo je dostupné na:")
  UnicodeEncodeError: 'charmap' codec can't encode character 'č'

Windows sahá po historické kódové stránce 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.

Oba vstupní body si teď výstup přepnou na UTF-8. Na konzoli i na ostatních
systémech je to prázdná operace, protože tam je UTF-8 dávno výchozí.
Pojistka errors="replace" je pro zbytek případů: rozsypaný háček je pořád
lepší než spadlý server.

Chytila to zkouška po stavbě, ne uživatel. Test to reprodukuje přes
TextIOWrapper s cp1252, takže se to bez opravy neprojde ani na Linuxu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:12:16 +02:00
david-spacil 0fd8d072ec Merge pull request 'Opravit značky akcí a zrušený Intel běhoun' (#10) from agents/claude/oprava-znacek into main
testy / pytest (3.11) (push) Successful in 1s
testy / pytest (3.12) (push) Successful in 1s
testy / pytest (3.13) (push) Successful in 0s
testy / pytest (3.14) (push) Successful in 1s
Reviewed-on: #10
2026-08-22 16:07:11 +02:00
gitea-actionsandClaude Opus 5 d4b979e627 Opravit značky akcí a zrušený Intel běhoun
binárka / linux (pull_request) Successful in 10s
testy / pytest (3.11) (pull_request) Successful in 1s
testy / pytest (3.12) (pull_request) Successful in 1s
testy / pytest (3.13) (pull_request) Successful in 1s
testy / pytest (3.14) (pull_request) Successful in 0s
První ostrý běh na GitHubu spadl hned v přípravě jobu:

  Unable to resolve action `astral-sh/setup-uv@v10`

Vydání v10.0.1 existuje, plovoucí značka v10 ne — setup-uv je přestal
vydávat po v7. Obě akce se teď píšou přesnou verzí; u třetí strany je to
stejně poctivější, protože je vidět, co se doopravdy pouští.

Druhá věc: macos-13 GitHub zrušil, takže ten job jen visel ve frontě.
Intel se staví na macos-15-intel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:57:11 +02:00
david-spacil 68a9f1d19f Merge pull request 'Binárky pro Windows a macOS' (#9) from agents/claude/binarky-win-mac into main
testy / pytest (3.11) (push) Successful in 1s
testy / pytest (3.12) (push) Successful in 0s
testy / pytest (3.13) (push) Successful in 1s
testy / pytest (3.14) (push) Successful in 1s
Reviewed-on: #9
2026-08-22 13:46:55 +02:00
gitea-actionsandClaude Opus 5 a7621bb01b Binárky pro Windows a macOS přes GitHub Actions
binárka / linux (pull_request) Successful in 11s
testy / pytest (3.11) (pull_request) Successful in 1s
testy / pytest (3.12) (pull_request) Successful in 1s
testy / pytest (3.13) (pull_request) Successful in 1s
testy / pytest (3.14) (pull_request) Successful in 0s
PyInstaller neumí křížovou kompilaci — binárku pro každý systém musí
postavit ten systém. Vlastní runner pro Windows a hlavně pro macOS by byl
nepoměr, tak si tyhle stroje půjčujeme od GitHub Actions.

GitHub je jen půjčená dílna, ne druhý domov projektu: repozitář se tam
z Gitey zrcadlí, hotové soubory se posílají zpátky na zdejší release přes
Gitea API a stránka s releasy zůstává jedna. Linux se tam schválně
nestaví; doma to jde proti glibc 2.17 a je to zároveň pojistka, kdyby
GitHub vypadl.

build.sh je společný pro všechny tři systémy, liší se jen tím, že Windows
dává spustitelné soubory venvu do Scripts/ místo bin/. Ořezání symbolů
zůstává jen Linuxu: na macOS by rozbilo podpis, který si PyInstaller sám
přidává a bez kterého se binárka na Apple Silicon vůbec nespustí.

Zkouška po stavbě je stejná jako u linuxové: nastartovat, stáhnout si
stránku i styly a ověřit, že se založila databáze.

Nepodepsané binárky si Gatekeeper ani SmartScreen nenechají líbit, tak je
v README napsané, co s tím — certifikáty za tisíce ročně by na počitadlo
kostek byly nesmysl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:13:57 +02:00
gitea-actionsandClaude Opus 5 26338bfa29 Adresy a data podle systému, ne podle Linuxu
Než začneme rozdávat binárky pro macOS a Windows, musí se na nich chovat
rozumně to, co dosud počítalo s Linuxem.

Adresy rozhraní se hledaly linuxovým ioctl. Na Windows chybí fcntl, na
macOS to číslo znamená něco jiného, takže obojí propadlo až na poslední
zálohu přes hostname — a ta na macOS vrátí jednu adresu. Tabule by tam
nabídla jedinou volbu, přesně to, co jsme minule opravovali. Přibyla
mezizáloha přes ifconfig; spouští se jen tam, kde ioctl nic nenašel,
takže na Linuxu se neplatí nic. Parsování je zvlášť jako čistá funkce,
ať jde otestovat i bez macOS pod rukama.

Databáze z binárky mířila do XDG adresáře na všech systémech. Na Windows
by to znamenalo C:\Users\...\.local\share\, což tam nikdo nečeká. Teď se
volí podle systému: LOCALAPPDATA, Library/Application Support, XDG.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:13:41 +02:00
david-spacil 298919d71b Merge pull request 'QR kód se dá přepnout na jinou adresu' (#5) from agents/claude/vyber-adresy into main
testy / pytest (3.11) (push) Successful in 0s
testy / pytest (3.12) (push) Successful in 1s
testy / pytest (3.13) (push) Successful in 1s
testy / pytest (3.14) (push) Successful in 1s
binárka / linux (push) Successful in 11s
Reviewed-on: #5
2026-08-21 19:13:05 +02:00
gitea-actionsandClaude Opus 5 84db0c0380 QR kód se dá přepnout na jinou adresu
binárka / linux (pull_request) Successful in 10s
testy / pytest (3.11) (pull_request) Successful in 1s
testy / pytest (3.12) (pull_request) Successful in 1s
testy / pytest (3.13) (pull_request) Successful in 1s
testy / pytest (3.14) (pull_request) Successful in 0s
Tabule dosud zbylé adresy jen vypsala, ale naskenovat se dala pouze ta
první. Teď se na ně dá kliknout: vybraná se přesune nahoru velkým písmem,
QR kód se přepne na ni a ta předchozí se objeví v seznamu, takže se dá
kroužit dokola.

Kódy jsou vykreslené všechny předem a přepíná se jen ten viditelný —
žádné doptávání serveru, žádná QR knihovna na klientovi. Aby to stránku
nenafouklo, skládá se SVG ze souvislých úseků místo jednotlivých
čtverečků; tabule se třemi adresami má 23 kB.

Otisk v data-address zahrnuje všechny adresy, ne jen tu první. Když se
změní kterákoli z nich, nabídka i QR kódy jsou k ničemu a tabule se
načte znovu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:03:35 +02:00
david-spacil ca97857ca0 Aktualizovat README.md
testy / pytest (3.11) (push) Successful in 1s
testy / pytest (3.12) (push) Successful in 1s
testy / pytest (3.13) (push) Successful in 0s
testy / pytest (3.14) (push) Successful in 1s
2026-08-21 17:50:15 +02:00
david-spacil eeb6e0bc1c Merge pull request 'Kontrolní součet k binárce' (#4) from agents/claude/kontrolni-soucet into main
testy / pytest (3.11) (push) Successful in 0s
testy / pytest (3.12) (push) Successful in 1s
testy / pytest (3.13) (push) Successful in 1s
testy / pytest (3.14) (push) Successful in 1s
binárka / linux (push) Successful in 11s
Reviewed-on: #4
2026-08-21 17:48:53 +02:00
gitea-actionsandClaude Opus 5 42f454f754 k binárce i kontrolní součet
binárka / linux (pull_request) Successful in 10s
testy / pytest (3.11) (pull_request) Successful in 0s
testy / pytest (3.12) (pull_request) Successful in 1s
testy / pytest (3.13) (pull_request) Successful in 1s
testy / pytest (3.14) (pull_request) Successful in 1s
Věší se jako druhá příloha ve formátu sha256sum, počítaná nad jménem, pod
kterým se soubor stahuje — aby se dala ověřit rovnou přes
sha256sum -c, bez přejmenovávání.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:45:00 +02:00
david-spacil fa66635b95 Merge pull request 'Binárka ke stažení a release z tagu' (#3) from agents/claude/binarka-a-release into main
testy / pytest (3.11) (push) Successful in 0s
testy / pytest (3.12) (push) Successful in 0s
testy / pytest (3.13) (push) Successful in 1s
testy / pytest (3.14) (push) Successful in 1s
Reviewed-on: #3
2026-08-21 17:40:07 +02:00
17 changed files with 612 additions and 64 deletions

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
# Binárky pro Windows a macOS.
#
# 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 se tu schválně nestaví — o ten se stará vlastní runner
# (.gitea/workflows/binarka.yml) a dělá to líp, proti glibc 2.17.
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
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.txt --no-project pytest -q
- name: Binárka
run: ./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
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
+71 -19
View File
@@ -1,4 +1,4 @@
# Počitadlo v kostkách
# Dice Counter
Doprovod k fyzickým kostkám: **telefon zapisuje skóre, notebook ukazuje
zápisník** a hry se pamatují mezi večery.
@@ -8,16 +8,45 @@ ten samý Adam jako z minulého měsíce. Žádné účty, žádná hesla.
## Spuštění
Stáhni binárku z [releases](https://gitea.spacilovi.eu/david-spacil/dice-counter/releases)
a spusť ji:
Stáhni binárku pro svůj systém z
[releases](https://gitea.spacilovi.eu/david-spacil/dice-counter/releases):
| Systém | Soubor |
|---|---|
| Linux (x86-64) | `kostky-linux-x86_64` |
| 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
chmod +x kostky
./kostky
chmod +x kostky-*
./kostky-linux-x86_64
```
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.
Na Windows stačí na `.exe` poklepat.
Linuxová binárka je postavená proti glibc 2.17, takže jede prakticky všude.
### Než to poprvé pustíš
Binárky nejsou podepsané — podpisové certifikáty stojí tisíce ročně a na
počitadlo kostek by to byl nesmysl. Systémy si toho všimnou:
- **macOS** stažený soubor označí za karanténní a odmítne ho spustit. Buď
značku sundej (`xattr -dr com.apple.quarantine 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é:
@@ -37,8 +66,8 @@ Počitadlo je dostupné na:
```
Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule
zobrazuje i zbylé adresy — když jedna nefunguje, zkus další. Zastavuje se
`Ctrl+C`.
nabízí i zbylé adresy — když jedna nefunguje, klikni na jinou a QR kód se
přepne na ni. Zastavuje se `Ctrl+C`.
| Proměnná | K čemu je | Výchozí |
|---|---|---|
@@ -53,7 +82,9 @@ server vypíše při startu.
| Spuštěno | Databáze |
|---|---|
| binárkou | `~/.local/share/kostky/dice.db` |
| binárkou na Linuxu | `~/.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 |
Binárka se při každém spuštění rozbaluje do dočasného adresáře a pouští se
@@ -100,7 +131,10 @@ při remíze na prvním místě se hraje dál.
| `stats.py` | síň slávy a kariérní statistiky |
| `web.py` | Flask aplikace |
| `dice.py` | totéž v terminálu, bez ukládání |
| `console.py` | aby čeština prošla i windowsovou konzolí |
| `build.sh`, `kostky.spec` | stavba binárky |
| `.gitea/workflows/` | testy a linuxová binárka doma |
| `.github/workflows/` | binárky pro Windows a macOS |
Terminálová verze zůstává funkční jako záloha:
@@ -135,26 +169,44 @@ Staví se proti samostatnému CPythonu od `uv`, ne proti systémovému. Ten je
slinkovaný s glibc 2.17; postavené proti Pythonu z Fedory 44 by to chtělo
glibc 2.43 a nešlo by spustit skoro nikde — glibc drží zpětnou kompatibilitu,
ne dopřednou. Nic z hostitele se do binárky nedostane, takže kontejner k tomu
potřeba není. Výsledek je v `dist/kostky`.
potřeba není. Výsledek je v `dist/`.
Stejný `build.sh` staví i na macOS a na Windows (v Git Bash) — jen tam bez
té starosti o glibc. Křížem to nejde: PyInstaller balí do výsledku interpret
a knihovny toho systému, na kterém běží, takže **binárku pro každý systém
musí postavit ten systém.**
Terminálová verze součástí binárky není; ta se pouští ze zdrojáků.
Releasy si tohle dělají samy. Otagovaný commit spustí
`.gitea/workflows/binarka.yml`, ta binárku postaví, projede testy, zkusí
ji nastartovat a pověsí ji na release jako `kostky-linux-x86_64`:
### Jak vznikají releasy
Otagovaný commit spustí stavbu na obou stranách:
| Kde | Co staví | Workflow |
|---|---|---|
| vlastní runner u Gitey | Linux | `.gitea/workflows/binarka.yml` |
| GitHub Actions | Windows, macOS ×2 | `.github/workflows/binarky.yml` |
```bash
git tag v1.0.0 && git push origin v1.0.0
```
Vedle binárky se věší i `kostky-linux-x86_64.sha256`, takže se stažený
soubor dá ověřit:
GitHub je tu jen půjčená dílna. Repozitář se tam z Gitey zrcadlí
([mirror](https://github.com/david-spacil/dice-counter)), postavené soubory
se posílají zpátky na zdejší release přes Gitea API a projekt má pořád jednu
stránku s releasy — tuhle. Linux 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ěží.
Každá stavba nejdřív projede testy a zkusí hotovou binárku nastartovat, než
ji kamkoli pověsí. Vedle každé visí i `.sha256`, takže se stažený soubor dá
ověřit:
```bash
sha256sum -c kostky-linux-x86_64.sha256
sha256sum -c kostky-linux-x86_64.sha256 # na macOS: shasum -a 256 -c
```
Stejná stavba běží i nad každým pull requestem, jen se nikam nevěší.
Nad pull requesty se Linux staví taky, jen se nikam nevěší.
## Testy
@@ -165,5 +217,5 @@ uv run --with-requirements requirements.txt --no-project pytest
Nebo z připraveného prostředí prostě `pytest`.
Nad každým pull requestem a nad `main` běží `.gitea/workflows/testy.yml` —
84 testů na Pythonu 3.11, 3.12, 3.13 i 3.14, a k tomu skriptovaná partie
102 testů na Pythonu 3.11, 3.12, 3.13 i 3.14, a k tomu skriptovaná partie
v terminálové verzi, na kterou pytest nesahá.
+22 -9
View File
@@ -1,11 +1,15 @@
#!/usr/bin/env bash
# Postaví jednosouborovou binárku pro Linux do dist/kostky.
# Postaví jednosouborovou binárku do dist/ — pro systém, na kterém běží.
#
# Staví se proti samostatnému CPythonu od uv, ne proti systémovému. Ten 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.
# PyInstaller neumí křížovou kompilaci: binárku pro každý systém musí postavit
# ten systém. Linux se staví doma na vlastním runneru, Windows a macOS na
# půjčených strojích GitHub Actions (.github/workflows/binarky.yml).
#
# Staví se proti samostatnému CPythonu od uv, ne proti systémovému. Na Linuxu
# je slinkovaný s glibc 2.17, takže binárka jede i na letitých systémech.
# Postavená proti Pythonu z Fedory 44 by chtěla glibc 2.43 a nešla by spustit
# skoro nikde — glibc drží zpětnou kompatibilitu, ne dopřednou. Nic z hostitele
# se do ní nedostane, takže kontejner k tomu není potřeba.
set -euo pipefail
VERSION=${VERSION:-3.11}
@@ -13,10 +17,19 @@ BUILD=${BUILD:-.build}
uv python install "$VERSION"
uv venv --quiet --managed-python --python "$VERSION" "$BUILD"
uv pip install --quiet --python "$BUILD/bin/python" -r requirements.txt pyinstaller
"$BUILD/bin/pyinstaller" --clean --noconfirm \
# 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.txt pyinstaller
"$pyinstaller" --clean --noconfirm \
--distpath dist --workpath "$BUILD/work" kostky.spec
echo
ls -lh dist/kostky
ls -lh dist/
+20
View File
@@ -0,0 +1,20 @@
"""Aby čeština prošla i tam, kde konzole čeká něco jiného."""
import sys
def utf8() -> None:
"""Přepne standardní výstup na UTF-8.
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.
"""
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
+2
View File
@@ -12,6 +12,7 @@ from os import name, system
from tabulate import tabulate
import console
from core import FINAL_SCORE, Game
table_header = ["Hráč", "Skóre"]
@@ -128,4 +129,5 @@ def main() -> None:
if __name__ == "__main__":
console.utf8()
main()
+6 -1
View File
@@ -5,6 +5,8 @@ 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.
"""
import sys
analysis = Analysis(
["web.py"],
pathex=["."],
@@ -21,6 +23,9 @@ exe = EXE(
analysis.datas,
name="kostky",
console=True,
strip=True,
# Ořezání symbolů šetří pár megabajtů, ale jen na Linuxu. Na macOS by
# rozbilo podpis, který si PyInstaller sám přidává a bez kterého se
# binárka na Apple Silicon vůbec nespustí; na Windows nedělá nic.
strip=sys.platform == "linux",
upx=False,
)
+41 -2
View File
@@ -11,7 +11,9 @@ adresy a obecný typ rozhraní, ne to, co má kdo zrovna nainstalované.
import ipaddress
import os
import re
import socket
import subprocess
from dataclasses import dataclass
# Rozsah, ze kterého adresy přiděluje Tailscale (RFC 6598, CGNAT).
@@ -123,8 +125,45 @@ def _from_interfaces() -> list[tuple[str, str]]:
return found
def parse_ifconfig(text: str) -> list[tuple[str, str]]:
"""Rozhraní a jejich IPv4 adresy z výstupu `ifconfig`.
Oddělená čistá funkce, ať se dá otestovat i bez macOS pod rukama.
"""
found = []
name = ""
for line in text.splitlines():
head = re.match(r"(\S+):", line)
if head:
name = head.group(1)
continue
addr = re.search(r"\binet (\d+\.\d+\.\d+\.\d+)", line)
if addr:
found.append((name, addr.group(1)))
return found
def _from_ifconfig() -> list[tuple[str, str]]:
"""Adresy přes `ifconfig`. Záloha pro macOS, kde ioctl výše nesedí.
Spouští se jen tam, kde předchozí cesta nic nenašla, takže na Linuxu
se neplatí nic. Trvá jednotky milisekund, cachovat to nemá cenu.
"""
try:
done = subprocess.run(["ifconfig", "-a"], capture_output=True,
text=True, timeout=5, check=True)
except (OSError, subprocess.SubprocessError):
return []
return parse_ifconfig(done.stdout)
def _from_hostname() -> list[tuple[str, str]]:
"""Záloha pro systémy, kde předchozí cesta nefunguje."""
"""Poslední záloha. Na Windows vrátí adresy všech rozhraní, jinde
obvykle jen jednu — jména rozhraní se takhle nedozvíme."""
try:
infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET)
except OSError:
@@ -144,7 +183,7 @@ def addresses() -> list[Address]:
primary = default_route_address()
found = _from_interfaces() or _from_hostname()
found = _from_interfaces() or _from_ifconfig() or _from_hostname()
if primary and primary not in [ip for _name, ip in found]:
found.append(("", primary))
+21 -1
View File
@@ -373,7 +373,7 @@ button {
line-height: 1.4;
}
.join .address span {
.join .address .what {
display: block;
font-family: var(--font-ui);
font-size: 0.7rem;
@@ -382,6 +382,8 @@ button {
color: var(--ink-soft);
}
.join .address .url { display: block; }
.qr { width: 5.5rem; height: 5.5rem; fill: var(--ink); }
.idle {
@@ -480,6 +482,24 @@ button {
.alt li { white-space: nowrap; }
/* Adresa je tlačítko, ale nemá vypadat jako tlačítko — jen dát najevo,
že se na ni dá kliknout. */
.alt button {
font: inherit;
color: inherit;
background: none;
border: 0;
border-bottom: 1px dotted var(--rule);
padding: 0;
cursor: pointer;
}
.alt button:hover,
.alt button:focus-visible {
color: var(--ink);
border-bottom-color: currentColor;
}
.alt em {
font-family: var(--font-ui);
font-style: normal;
+13 -3
View File
@@ -16,20 +16,30 @@ from pathlib import Path
from core import FINAL_SCORE, Game, Turn
def data_home() -> Path:
"""Adresář, kam podle zvyklostí systému patří data aplikací."""
if sys.platform == "win32":
local = os.environ.get("LOCALAPPDATA")
return Path(local) if local else Path.home() / "AppData" / "Local"
if sys.platform == "darwin":
return Path.home() / "Library" / "Application Support"
return Path(os.environ.get("XDG_DATA_HOME")
or Path.home() / ".local" / "share")
def default_db() -> Path:
"""Kam se ukládá databáze.
Ze zdrojáků do pracovního adresáře — dá se tak mít víc rozehraných
sad vedle sebe a je vidět, s čím se pracuje. Z binárky do datového
adresáře podle XDG: binárka se spouští odkudkoli a rozbaluje se do
adresáře podle systému: binárka se spouští odkudkoli a rozbaluje se do
dočasného adresáře, takže relativní cesta by databázi rozsypala po disku.
"""
chosen = os.environ.get("DICE_DB")
if chosen:
return Path(chosen)
if getattr(sys, "frozen", False):
share = os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
return Path(share) / "kostky" / "dice.db"
return data_home() / "kostky" / "dice.db"
return Path("dice.db")
+45 -9
View File
@@ -1,22 +1,58 @@
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může člověk. #}
{% if primary %}
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může
člověk — QR kódy jsou vykreslené všechny, přepíná se jen ten viditelný. #}
{% if choices %}
<div class="join">
{{ target|qr }}
<div class="qr-stack">
{% for choice in choices %}
<div class="qr-slot" data-ip="{{ choice.ip }}"{% if not loop.first %} hidden{% endif %}>
{{ choice.url|qr }}
</div>
{% endfor %}
</div>
<div class="address">
<span>Zapisuje se z telefonu</span>
{{ target }}
{% if others %}
<span class="what">Zapisuje se z telefonu</span>
<span class="url">{{ choices[0].url }}</span>
{% if choices|length > 1 %}
<ul class="alt">
{% for address in others %}
<li>{{ address.ip }}:{{ port }} <em>{{ address.label }}</em></li>
{% for choice in choices %}
<li data-ip="{{ choice.ip }}"{% if loop.first %} hidden{% endif %}>
<button type="button" data-ip="{{ choice.ip }}" data-url="{{ choice.url }}"
aria-label="Ukázat QR kód pro {{ choice.url }}">
{{ choice.ip }}:{{ port }} <em>{{ choice.label }}</em>
</button>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
<script>
// Klik na jinou adresu jen prohodí, který QR kód a který řádek je vidět.
// Vybraná adresa ze seznamu zmizí a ta předchozí se v něm objeví, takže
// se dá kroužit dokola.
{
const join = document.currentScript.previousElementSibling;
const shown = join.querySelector(".address .url");
for (const button of join.querySelectorAll(".alt button")) {
button.addEventListener("click", () => {
for (const slot of join.querySelectorAll(".qr-slot")) {
slot.hidden = slot.dataset.ip !== button.dataset.ip;
}
for (const item of join.querySelectorAll(".alt li")) {
item.hidden = item.dataset.ip === button.dataset.ip;
}
shown.textContent = button.dataset.url;
});
}
}
</script>
{% else %}
<div class="join">
<p class="address"><span>Adresu se nepodařilo zjistit</span>
<p class="address"><span class="what">Adresu se nepodařilo zjistit</span>
Spusť server s <code>DICE_HOST=&lt;adresa&gt;</code>.</p>
</div>
{% endif %}
+1 -1
View File
@@ -1,3 +1,3 @@
{# Fragment pro tabuli: obě části, které se během hry mění. #}
<div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
<div id="head" data-address="{{ address_key }}">{% include "_head.html" %}</div>
<div id="sheet">{% include "_sheet.html" %}</div>
+1 -1
View File
@@ -4,7 +4,7 @@
{% block body %}
<div class="board">
<header class="masthead">
<div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
<div id="head" data-address="{{ address_key }}">{% include "_head.html" %}</div>
{% include "_join.html" %}
</header>
+48
View File
@@ -0,0 +1,48 @@
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"
def reconfigure(self, encoding, errors):
self.encoding = encoding
self.errors = errors
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")
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()
+47
View File
@@ -90,3 +90,50 @@ def test_v_systemu_se_neco_najde():
assert all(net.classify(a.ip, a.interface) for a in found)
assert len({a.ip for a in found}) == len(found)
IFCONFIG_MACOS = """\
lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
\toptions=1203<RXCSUM,TXCSUM,TXSTATUS,SW_TIMESTAMP>
\tinet 127.0.0.1 netmask 0xff000000
\tinet6 ::1 prefixlen 128
gif0: flags=8010<POINTOPOINT,MULTICAST> mtu 1280
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
\tether 3c:22:fb:aa:bb:cc
\tinet6 fe80::14b0:6b1a:9e2f:1a4c%en0 prefixlen 64 secured scopeid 0xb
\tinet 192.168.0.42 netmask 0xffffff00 broadcast 192.168.0.255
\tmedia: autoselect
\tstatus: active
utun4: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 1280
\tinet 100.99.98.97 --> 100.99.98.97 netmask 0xff000000
"""
def test_ifconfig_vytahne_rozhrani_a_adresy():
"""macOS na ioctl z Linuxu neslyší, tak se čte výstup ifconfigu."""
assert net.parse_ifconfig(IFCONFIG_MACOS) == [
("lo0", "127.0.0.1"),
("en0", "192.168.0.42"),
("utun4", "100.99.98.97"),
]
def test_ifconfig_si_neplete_inet6_s_inet():
"""`inet6` začíná stejně jako `inet`; IPv6 adresy tudy projít nesmí."""
assert net.parse_ifconfig("en0: flags=1\n\tinet6 fe80::1 prefixlen 64\n") == []
def test_adresy_z_ifconfigu_projdou_celym_retezem(monkeypatch):
"""Bez linuxového ioctl se sáhne po ifconfigu, teprve pak po hostname."""
monkeypatch.delenv("DICE_HOST", raising=False)
monkeypatch.setattr(net, "_from_interfaces", lambda: [])
monkeypatch.setattr(net, "_from_ifconfig",
lambda: net.parse_ifconfig(IFCONFIG_MACOS))
monkeypatch.setattr(net, "_from_hostname", lambda: pytest.fail("moc brzy"))
monkeypatch.setattr(net, "default_route_address", lambda: "192.168.0.42")
found = net.addresses()
assert [a.ip for a in found] == ["192.168.0.42", "100.99.98.97"]
assert found[0].primary and found[0].interface == "en0"
assert found[1].kind == "tailnet"
+17
View File
@@ -148,11 +148,28 @@ def test_umisteni_databaze_z_binarky(monkeypatch, tmp_path):
"""Binárka se rozbaluje do dočasného adresáře, databáze tam nesmí."""
monkeypatch.delenv("DICE_DB", raising=False)
monkeypatch.setattr(storage.sys, "frozen", True, raising=False)
monkeypatch.setattr(storage.sys, "platform", "linux")
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
assert storage.default_db() == tmp_path / "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):
monkeypatch.setattr(storage.sys, "frozen", True, raising=False)
monkeypatch.setenv("DICE_DB", str(tmp_path / "jinde.db"))
+90
View File
@@ -1,5 +1,8 @@
import re
import pytest
import net
import storage
import web
@@ -236,3 +239,90 @@ def test_kratka_hra_nic_neuseka(client):
])
def test_sklonovani_kol(count, expected):
assert web.kola(count) == expected
def offer(monkeypatch, *ips):
"""Server nabídne přesně tyhle adresy, v tomhle pořadí."""
found = [net.Address(ip=ip, interface="test", kind="lan", primary=not i)
for i, ip in enumerate(ips)]
monkeypatch.setattr(web.net, "addresses", lambda: found)
def test_tabule_nabidne_qr_ke_kazde_adrese(client, monkeypatch):
"""Kódy jsou vykreslené předem, přepíná se jen ten viditelný."""
offer(monkeypatch, "192.168.0.10", "100.91.0.24", "172.17.0.1")
game_id = start_game(client, "Adam", "Eva")
page = text(client.get(f"/board/{game_id}"))
assert page.count('<svg class="qr"') == 3
for ip in ("192.168.0.10", "100.91.0.24", "172.17.0.1"):
assert f'data-url="http://{ip}:8000/game/{game_id}"' in page
def test_vybrana_adresa_ze_seznamu_zmizi(client, monkeypatch):
"""Velkým písmem i v seznamu zároveň by byla dvakrát."""
offer(monkeypatch, "192.168.0.10", "100.91.0.24")
page = text(client.get("/board"))
assert '<li data-ip="192.168.0.10" hidden>' in page
assert '<li data-ip="100.91.0.24">' in page
def test_jedina_adresa_nema_co_prepinat(client, monkeypatch):
offer(monkeypatch, "192.168.0.10")
page = text(client.get("/board"))
assert '<ul class="alt">' not in page
assert page.count('<svg class="qr"') == 1
def test_otisk_hlida_vsechny_adresy(client, monkeypatch):
"""Tabule se načte znovu i když se změní jen ta druhá v pořadí —
nabídnuté QR kódy už by neplatily."""
game_id = start_game(client, "Adam", "Eva")
offer(monkeypatch, "192.168.0.10", "100.91.0.24")
before = text(client.get(f"/board/{game_id}"))
offer(monkeypatch, "192.168.0.10", "100.91.0.99")
after = text(client.get(f"/board/{game_id}"))
assert 'data-address="192.168.0.10,100.91.0.24"' in before
assert 'data-address="192.168.0.10,100.91.0.99"' in after
@pytest.mark.parametrize("row, expected", [
([], []),
([False, False], []),
([True], [(0, 1)]),
([True, True], [(0, 2)]),
([False, True, True], [(1, 2)]),
([True, True, False, True], [(0, 2), (3, 1)]),
])
def test_souvisle_useky(row, expected):
assert web.runs(row) == expected
def test_qr_obdelniky_kryji_stejne_moduly():
"""Slučování čtverečků do širších obdélníků nesmí kód změnit."""
import qrcode
data = "http://192.168.0.10:8000/"
code = qrcode.QRCode(box_size=1, border=2)
code.add_data(data)
code.make(fit=True)
matrix = code.get_matrix()
svg = str(web.qr_svg(data))
painted = set()
for x, y, width in re.findall(r'<rect x="(\d+)" y="(\d+)" width="(\d+)"', svg):
painted.update((int(x) + step, int(y)) for step in range(int(width)))
expected = {(x, y) for y, row in enumerate(matrix)
for x, dark in enumerate(row) if dark}
assert painted == expected
assert svg.count("<rect") < len(expected)
+40 -18
View File
@@ -16,8 +16,9 @@ from pathlib import Path
import qrcode
from flask import (Flask, abort, g, redirect, render_template, request,
url_for)
from markupsafe import Markup
from markupsafe import Markup, escape
import console
import net
import stats
import storage
@@ -61,31 +62,50 @@ def close_db(_exception):
# --- adresa a QR kód --------------------------------------------------------
def primary_ip() -> str:
"""Adresa, na kterou tabule ukazuje. Prázdno, když se nedá zjistit."""
found = net.addresses()
return found[0].ip if found else ""
def address_key() -> str:
"""Otisk všech adres. Tabule ho hlídá: jakmile se změní, načte se znovu,
protože nabídnuté adresy i QR kódy už neplatí."""
return ",".join(address.ip for address in net.addresses())
def links(path: str = "/") -> dict:
"""Adresy, na kterých je počitadlo dostupné, i s cílem pro QR kód.
"""Adresy, na kterých je počitadlo dostupné, i s cíli pro QR kódy.
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.
z domácí WiFi na hotspot a zpátky. První v pořadí je ta nejnadějnější,
zbytek si člověk může na tabuli přepnout.
"""
found = net.addresses()
primary = found[0] if found else None
return {
"primary": primary,
"others": found[1:],
"choices": [
{"ip": address.ip, "label": address.label,
"url": address.url(PORT) + path}
for address in net.addresses()
],
"port": PORT,
"target": primary.url(PORT) + path if primary else "",
}
def runs(row: list[bool]) -> list[tuple[int, int]]:
"""Souvislé tmavé úseky řádku jako (začátek, délka).
Jeden široký obdélník místo řady čtverečků. Na tabuli visí QR kód pro
každou adresu zvlášť, takže se to na velikosti stránky pozná.
"""
found = []
start = None
for x, dark in enumerate([*row, False]):
if dark and start is None:
start = x
elif not dark and start is not None:
found.append((start, x - start))
start = None
return found
def qr_svg(data: str) -> Markup:
"""QR jako SVG složené ze čtverečků — bez Pillow a bez práce s obrázky."""
"""QR jako SVG složené z obdélníků — bez Pillow a bez práce s obrázky."""
code = qrcode.QRCode(box_size=1, border=2)
code.add_data(data)
code.make(fit=True)
@@ -93,14 +113,14 @@ def qr_svg(data: str) -> Markup:
size = len(matrix)
rects = [
f'<rect x="{x}" y="{y}" width="1" height="1"/>'
f'<rect x="{x}" y="{y}" width="{width}" height="1"/>'
for y, row in enumerate(matrix)
for x, dark in enumerate(row) if dark
for x, width in runs(row)
]
return Markup(
f'<svg class="qr" viewBox="0 0 {size} {size}" role="img" '
f'aria-label="QR kód s adresou pro telefon" '
f'aria-label="QR kód s adresou {escape(data)}" '
f'shape-rendering="crispEdges">{"".join(rects)}</svg>'
)
@@ -184,7 +204,7 @@ def view(game: Game, game_id: int, row) -> dict:
"standings": game.standings(),
"winner": winner,
"current": "" if winner else game.current_player,
"primary_ip": primary_ip(),
"address_key": address_key(),
}
@@ -301,6 +321,8 @@ def hall():
if __name__ == "__main__":
console.utf8()
found = net.addresses()
if found: