From 959e92552b0c9212bd8ee46fd43cf67564d8e5f6 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sat, 17 Oct 2026 09:17:18 +0200 Subject: [PATCH] Pacchetto Windows: test dell'avvio, workflow rilascio-windows, sezione README (TieMeasureFlow#76) Co-Authored-By: Claude Sonnet 5.5 --- .gitea/workflows/rilascio-windows.yml | 120 +++++++++++++++++ README.md | 48 +++++++ packaging/windows/avvio.py | 2 +- packaging/windows/tests/test_avvio.py | 186 ++++++++++++++++++++++++++ 4 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/rilascio-windows.yml create mode 100644 packaging/windows/tests/test_avvio.py diff --git a/.gitea/workflows/rilascio-windows.yml b/.gitea/workflows/rilascio-windows.yml new file mode 100644 index 0000000..685a2b1 --- /dev/null +++ b/.gitea/workflows/rilascio-windows.yml @@ -0,0 +1,120 @@ +# TieMeasureFlow#76: setup.exe di TieMeasureFlow per Windows, generato al tag v*. +# Cosa fa: submodule vendor/visionsuite (deploy key), build.ps1 (uv sync Python 3.13, Tailwind, +# Nuitka --standalone, migrazioni .pyc, DLL di WeasyPrint, Inno Setup), poi il setup come +# artifact e come asset della release Gitea del tag. +# Da cosa dipende: un runner Gitea con label `windows` che giri su un host Windows (non in +# container) con Visual Studio/Build Tools (MSVC), git e node (serve a actions/checkout e a +# Tailwind); uv e Inno Setup li installa il workflow se mancano. Secret SUBMODULI_SSH_KEY +# (deploy key di sola lettura di TieVision-Core, impostato sul repo il 17/10/2026). +# Il runner `windows` e' act_runner in modalita' host su daitarn (capacity 1). +# ⚠️ upload-artifact@v3: e' la versione che usa gia' vs-native.yml in TieVision-Core su questo +# Gitea; la v4 usa un protocollo che act_runner puo' non supportare. +# ⚠️ L'asset della release usa il token del job (secrets.GITHUB_TOKEN): se la chiamata risponde +# 403 serve un token personale in un secret (es. RILASCI_TOKEN) al posto di GITHUB_TOKEN. +# ⚠️ La build da freddo e' lunga (Nuitka compila backend, visione, plotly, opencv...): timeout 180 min. +name: rilascio-windows + +on: + push: + tags: ['v*'] + workflow_dispatch: {} + +jobs: + setup-windows: + runs-on: windows + timeout-minutes: 180 + defaults: + run: + shell: powershell + steps: + # Checkout senza ssh-key (come ci.yml): i submodule si inizializzano a parte, con la deploy key. + - uses: actions/checkout@v4 + + - name: Submodule privati (deploy key in sola lettura) + env: + SUBMODULI_SSH_KEY: ${{ secrets.SUBMODULI_SSH_KEY }} + run: | + $ErrorActionPreference = 'Stop' + # cartella temporanea del job, non ~/.ssh: su un runner host e' l'account dell'utente + $ssh = Join-Path $env:TEMP ("submoduli-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force $ssh | Out-Null + $chiave = Join-Path $ssh 'chiave' + # LF e newline finale: OpenSSH rifiuta una chiave con CRLF + [System.IO.File]::WriteAllText($chiave, ($env:SUBMODULI_SSH_KEY.Trim() + "`n"), (New-Object System.Text.UTF8Encoding $false)) + # OpenSSH su Windows rifiuta una chiave leggibile da altri utenti; (F) e non (R): + # con la sola lettura Remove-Item finale non puo' cancellarla + icacls $chiave /inheritance:r | Out-Null + icacls $chiave /grant:r "$($env:USERNAME):(F)" | Out-Null + $known = Join-Path $ssh 'known_hosts' + [System.IO.File]::WriteAllText($known, "[git.tielogic.xyz]:222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDvABW6Tvruqkf7KJhJZUiAX6NeCx0enwcNXzZbECQ8J`n", (New-Object System.Text.UTF8Encoding $false)) + $env:GIT_SSH_COMMAND = "ssh -i $($chiave -replace '\\','/') -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$($known -replace '\\','/')" + try { + git submodule update --init --recursive + if ($LASTEXITCODE -ne 0) { throw "git submodule update fallito" } + } finally { + # la deploy key non resta mai sul disco del runner + Remove-Item $ssh -Recurse -Force -ErrorAction SilentlyContinue + } + + - name: uv (se manca) + run: | + if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + irm https://astral.sh/uv/install.ps1 | iex + } + # il PATH del passo successivo + "$env:USERPROFILE\.local\bin" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH + + - name: Node (se manca) + run: | + # serve a Tailwind (npx) in build.ps1 + if (-not (Get-Command npx -ErrorAction SilentlyContinue)) { + winget install --id OpenJS.NodeJS.LTS -e --silent --accept-package-agreements --accept-source-agreements + } + "$env:ProgramFiles +odejs" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH + + - name: Inno Setup (se manca) + run: | + $trovato = (Get-Command ISCC.exe -ErrorAction SilentlyContinue) -or + (Test-Path "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe") -or + (Test-Path "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe") + if (-not $trovato) { + winget install --id JRSoftware.InnoSetup -e --silent --accept-package-agreements --accept-source-agreements + } + + - name: Build (Nuitka + Inno Setup) + # GITHUB_REF_NAME e' il tag (v0.1.0); con workflow_dispatch e' il ramo: build.ps1 rifiuta + # un nome senza numeri, quindi in quel caso si passa 0.0.0 + run: | + $v = '${{ github.ref_name }}' + if ($v -notmatch '^v?\d+\.\d+\.\d+') { $v = '0.0.0' } + powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione $v + if ($LASTEXITCODE -ne 0) { throw "build.ps1 fallito" } + + - name: Setup come artifact + uses: actions/upload-artifact@v3 + with: + name: TieMeasureFlow-setup + path: dist/TieMeasureFlow-setup-*.exe + if-no-files-found: error + + - name: Asset della release Gitea (solo ai tag) + if: startsWith(github.ref, 'refs/tags/') + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + $ErrorActionPreference = 'Stop' + $h = @{ Authorization = "token $env:TOKEN" } + # la release del tag: la crea il primo job che arriva, poi si riusa + try { + $rel = Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG" + } catch { + $corpo = @{ tag_name = $env:TAG; name = "TieMeasureFlow $env:TAG" } | ConvertTo-Json + $rel = Invoke-RestMethod -Method Post -Headers $h -ContentType 'application/json' -Body $corpo "$env:API/releases" + } + $setup = Get-ChildItem dist\TieMeasureFlow-setup-*.exe | Select-Object -First 1 + # curl.exe (incluso in Windows 10+) fa il multipart senza codice a mano + curl.exe --fail -sS -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@$($setup.FullName)" "$env:API/releases/$($rel.id)/assets?name=$($setup.Name)" + if ($LASTEXITCODE -ne 0) { throw "caricamento dell'asset fallito" } diff --git a/README.md b/README.md index 3d54059..b9b0810 100644 --- a/README.md +++ b/README.md @@ -532,6 +532,54 @@ Nella schermata Stazioni si compilano `camera_url` (per esempio `http://host.doc --- +## Pacchetto Windows + +Per i PC senza Docker (per esempio il PC della stazione in fiera) TieMeasureFlow si distribuisce come programma di installazione per Windows, `TieMeasureFlow-setup-.exe`. Funziona completamente offline, non richiede Docker né MySQL, e non contiene codice sorgente Python: il codice del prodotto è compilato con Nuitka. Il programma parte solo con una licenza valida, legata al computer. + +### Installazione + +1. Eseguire `TieMeasureFlow-setup-.exe` (serve un account amministratore; per una prova senza amministratore si può lanciare il setup con `/CURRENTUSER`). Per un'installazione silenziosa: `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART`. +2. Dal menu Start aprire «Impronta di questo computer» e comunicare l'impronta a Tielogic. L'impronta viene salvata anche in `C:\ProgramData\Tielogic\licenze\impronta-tiemeasureflow.txt`. +3. Copiare il file di licenza ricevuto in `C:\ProgramData\Tielogic\licenze\tiemeasureflow.json`. La licenza non è inclusa nel setup e non va mai inserita nel repository. +4. Avviare «TieMeasureFlow» dal menu Start o dal desktop. Senza una licenza valida (mancante, scaduta, di un altro computer o di un altro prodotto) il programma mostra il motivo e l'impronta ed esce. + +### Primo avvio + +Il programma gira in un solo processo, senza finestra, e apre il browser. Ascolta solo sul computer locale (`127.0.0.1`); per raggiungere l'interfaccia da altri dispositivi della rete si avvia l'exe con `--host 0.0.0.0` (per esempio da un collegamento personalizzato). + +| Componente | Indirizzo | +|---|---| +| Interfaccia web (Flask) | http://127.0.0.1:8780/ | +| Backend API (FastAPI) | http://127.0.0.1:8000/ | +| Worker di visione | http://127.0.0.1:8100/ | +| Camera della stazione | http://127.0.0.1:8765/ (`/camera/sources`) | + +Al primo avvio vengono create la cartella dati `C:\ProgramData\Tielogic\TieMeasureFlow` e il file `config.env` con le chiavi segrete generate (`SERVER_SECRET_KEY`, `CLIENT_SECRET_KEY`, `SETUP_PASSWORD`, `DB_PASSWORD`); al riavvio vengono rilette, quindi le sessioni e le password restano valide. Il database è un file SQLite (`tmf.db`); le migrazioni si applicano da sole a ogni avvio. + +Se il database non contiene ancora utenti, il browser si apre direttamente sulla pagina di setup `http://127.0.0.1:8000/api/setup`. La pagina chiede la `SETUP_PASSWORD`, che si legge in `config.env`, e permette di inizializzare il database e creare il primo amministratore (vedi «Setup Iniziale»). Terminato il setup si accede all'interfaccia su `http://127.0.0.1:8780/`. + +Per le camere della stazione si compila nella schermata Stazioni `camera_url` = `http://127.0.0.1:8765` e `camera_source` (`camera:` oppure `camera:folder`). Con `camera:folder` il servizio restituisce a rotazione le immagini che si copiano in `C:\ProgramData\Tielogic\TieMeasureFlow\camera`. Per le camere GigE/GenTL il driver (producer `.cti`) è quello installato dal produttore della camera. + +Altre informazioni utili: + +- I log sono in `C:\ProgramData\Tielogic\TieMeasureFlow\log\tiemeasureflow.log`. +- Una seconda esecuzione mentre il programma è già attivo apre soltanto il browser. +- Per fermare il programma si usa «Arresta TieMeasureFlow» nel menu Start (chiude il processo). +- La disinstallazione non cancella licenze, database, file caricati e `config.env`: sono dati del cliente. +- I report PDF usano le librerie grafiche incluse nel pacchetto e un browser Chrome o Edge già installato sul computer per i grafici. + +### Come si costruisce + +Il setup lo produce la CI Gitea (`.gitea/workflows/rilascio-windows.yml`) quando si crea un tag `v*`, su un runner con etichetta `windows`; il file compare come allegato della release. In locale, dalla radice del repository con il sottomodulo `vendor/visionsuite` inizializzato: + +```powershell +powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione 3.0.0 +``` + +Servono `uv`, Node.js, Visual Studio con il carico C++ e Inno Setup 6. Il risultato è `dist\TieMeasureFlow-setup-3.0.0.exe`. La prima compilazione richiede decine di minuti. I test dell'avvio si lanciano con l'ambiente Python 3.13 del pacchetto: `uv run --python 3.13 --extra server --extra client --extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q`. + +--- + ## Documentazione Indice completo: [`docs/README.md`](docs/README.md). diff --git a/packaging/windows/avvio.py b/packaging/windows/avvio.py index 1b13c92..39ad758 100644 --- a/packaging/windows/avvio.py +++ b/packaging/windows/avvio.py @@ -435,7 +435,7 @@ def main(argv=None) -> int: # Si resta vivi finche' tutti i server girano; se uno muore si ferma tutto try: - while all(t.is_alive() for t in thread): + while thread and all(t.is_alive() for t in thread): time.sleep(1) except KeyboardInterrupt: pass diff --git a/packaging/windows/tests/test_avvio.py b/packaging/windows/tests/test_avvio.py new file mode 100644 index 0000000..3eec8cd --- /dev/null +++ b/packaging/windows/tests/test_avvio.py @@ -0,0 +1,186 @@ +"""Test di packaging/windows/avvio.py (TieMeasureFlow#76): licenza firmata con la chiave di prova +del contratto v1, nessun server vero (avvia_server e migrazioni sono sostituiti). + +Si lanciano con l'ambiente 3.13 del pacchetto (serve visionsuite.license): + UV_PROJECT_ENVIRONMENT=build/venv313 uv run --python 3.13 --frozen --extra server --extra client \ + --extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q +""" +from __future__ import annotations + +import base64 +import functools +import importlib.util +import json +import os +import sqlite3 +from datetime import date, timedelta +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +RADICE = Path(__file__).resolve().parents[3] +# vettore di prova del contratto licenza v1 (chiave finta, solo test) +SEED_TEST = base64.b64decode("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=") +PUBBLICA_TEST = base64.b64decode("A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=") +IMPRONTA = "ab" * 32 + + +def _firma(dati: dict) -> str: + # stessa canonicalizzazione del contratto (byte firmati) + byte = json.dumps(dati, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + return base64.b64encode(Ed25519PrivateKey.from_private_bytes(SEED_TEST).sign(byte)).decode() + + +@pytest.fixture +def avvio(monkeypatch, tmp_path): + # avvio.py non e' in un pacchetto: lo si carica dal percorso + spec = importlib.util.spec_from_file_location("avvio_tmf", RADICE / "packaging/windows/avvio.py") + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + monkeypatch.setenv("PROGRAMDATA", str(tmp_path)) # ProgramData finta + monkeypatch.delenv("TIELOGIC_LICENZA", raising=False) + monkeypatch.setattr(os, "environ", os.environ.copy()) # le variabili impostate da avvio non escono dal test + monkeypatch.setattr(m.lic, "impronta", lambda: IMPRONTA) + # verifica reale (formato, firma, prodotto, macchina, scadenza) ma con la chiave di prova + verifica_reale = m.lic.verifica + monkeypatch.setattr(m.lic, "verifica", functools.partial( + verifica_reale, chiave_pubblica=PUBBLICA_TEST, impronta_attesa=IMPRONTA)) + m.chiamate = [] + monkeypatch.setattr(m, "avvia_server", lambda host, dati: m.chiamate.append((host, dati)) or []) + monkeypatch.setattr(m, "esegui_migrazioni", lambda: m.chiamate.append("migrazioni")) + m.aperti = [] + monkeypatch.setattr(m.webbrowser, "open", lambda url: m.aperti.append(url)) + monkeypatch.setattr(m, "_prepara_log", lambda dati=None: None) + monkeypatch.setattr(m, "_porta_aperta", lambda porta: False) + return m + + +def _scrivi_licenza(tmp_path: Path, **modifiche) -> Path: + dati = {"versione": 1, "prodotto": "tiemeasureflow", "cliente": "Prova Srl", "impronta": IMPRONTA, + "emessa": date.today().isoformat(), "scadenza": (date.today() + timedelta(days=30)).isoformat()} + dati.update(modifiche) + p = tmp_path / "Tielogic" / "licenze" / "tiemeasureflow.json" + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps({"dati": dati, "firma": _firma(dati)}), encoding="utf-8") + return p + + +def test_licenza_mancante_non_avvia_e_scrive_impronta(avvio, capsys): + assert avvio.main(["--senza-finestre"]) == 1 + assert avvio.chiamate == [] and avvio.aperti == [] + assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA + err = capsys.readouterr().err + assert "Licenza non trovata" in err and IMPRONTA in err and "tiemeasureflow.json" in err + + +@pytest.mark.parametrize("modifica,testo", [ + ({"scadenza": "2020-01-01"}, "scaduta"), + ({"impronta": "cd" * 32}, "altra macchina"), + ({"prodotto": "tievision-studio"}, "altro prodotto"), +]) +def test_licenza_non_valida_non_avvia(avvio, tmp_path, capsys, modifica, testo): + _scrivi_licenza(tmp_path, **modifica) + assert avvio.main(["--senza-finestre"]) == 1 + assert avvio.chiamate == [] + assert testo in capsys.readouterr().err + assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA + + +def test_licenza_manomessa_non_avvia(avvio, tmp_path, capsys): + p = _scrivi_licenza(tmp_path) + doc = json.loads(p.read_text(encoding="utf-8")) + doc["dati"]["cliente"] = "Altro Cliente" # dati cambiati dopo la firma + p.write_text(json.dumps(doc), encoding="utf-8") + assert avvio.main(["--senza-finestre"]) == 1 + assert "Firma" in capsys.readouterr().err + + +def test_licenza_valida_prepara_dati_e_avvia(avvio, tmp_path): + _scrivi_licenza(tmp_path) + assert avvio.main(["--senza-finestre", "--senza-browser", "--host", "0.0.0.0"]) == 0 + dati = tmp_path / "Tielogic" / "TieMeasureFlow" + # prima le migrazioni, poi i server + assert avvio.chiamate == ["migrazioni", ("0.0.0.0", dati)] + assert (dati / "config.env").is_file() and (dati / "uploads").is_dir() and (dati / "camera").is_dir() + assert not avvio.percorso_impronta().exists() # con licenza valida non si scrive nulla + assert avvio.aperti == [] + + +def test_variabili_del_contratto(avvio, tmp_path): + _scrivi_licenza(tmp_path) + avvio.main(["--senza-finestre", "--senza-browser"]) + e = os.environ + dati = tmp_path / "Tielogic" / "TieMeasureFlow" + assert e["APP_ENV"] == "production" + assert e["DB_URL"] == f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}" and "\\" not in e["DB_URL"] + assert e["UPLOAD_DIR"] == str(dati / "uploads") == e["VISION_RECIPES_ROOT"] + assert e["VISION_WORKER_URL"] == "http://127.0.0.1:8100" + assert e["API_SERVER_URL"] == "http://127.0.0.1:8000" + assert e["SESSION_COOKIE_SECURE"] == "0" + for k in ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD"): + assert len(e[k]) >= 32 + + +def test_config_env_generato_una_volta_e_riletto(avvio, tmp_path): + dati = tmp_path / "Tielogic" / "TieMeasureFlow" + primo = avvio.carica_segreti(dati) + testo = (dati / "config.env").read_text(encoding="utf-8") + assert set(primo) == {"SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD"} + assert len(set(primo.values())) == 4 # segreti distinti + secondo = avvio.carica_segreti(dati) + assert secondo == primo and (dati / "config.env").read_text(encoding="utf-8") == testo + + +def test_config_env_completa_i_segreti_mancanti(avvio, tmp_path): + dati = tmp_path / "Tielogic" / "TieMeasureFlow" + dati.mkdir(parents=True) + (dati / "config.env").write_text("# nota\nSETUP_PASSWORD=scelta-da-me\n", encoding="utf-8") + v = avvio.carica_segreti(dati) + assert v["SETUP_PASSWORD"] == "scelta-da-me" and len(v["SERVER_SECRET_KEY"]) >= 32 + assert avvio.carica_segreti(dati) == v + + +def test_seconda_istanza_apre_solo_il_browser(avvio, tmp_path, monkeypatch): + _scrivi_licenza(tmp_path) + monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True) + monkeypatch.setattr(avvio, "_e_tmf", lambda: True) + assert avvio.main(["--senza-finestre"]) == 0 + assert avvio.aperti == ["http://127.0.0.1:8780/"] and avvio.chiamate == [] + + +def test_porta_occupata_da_altro_programma(avvio, tmp_path, monkeypatch, capsys): + _scrivi_licenza(tmp_path) + monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True) + monkeypatch.setattr(avvio, "_e_tmf", lambda: False) + assert avvio.main(["--senza-finestre"]) == 2 + assert "8780" in capsys.readouterr().err and avvio.chiamate == [] + + +def test_migrazione_fallita_esce_con_3(avvio, tmp_path, monkeypatch, capsys): + _scrivi_licenza(tmp_path) + + def rotta(): + raise RuntimeError("db bloccato") + + monkeypatch.setattr(avvio, "esegui_migrazioni", rotta) + assert avvio.main(["--senza-finestre"]) == 3 + assert "db bloccato" in capsys.readouterr().err + + +def test_impronta_si_stampa_senza_licenza(avvio, capsys): + assert avvio.main(["--impronta", "--senza-finestre"]) == 0 + assert IMPRONTA in capsys.readouterr().out + + +def test_nessun_utente_rileva_il_primo_avvio(avvio, tmp_path): + db = tmp_path / "t.db" + assert avvio._nessun_utente(db) is False # file assente: nessuna conclusione + con = sqlite3.connect(db) + con.execute("CREATE TABLE users (id INTEGER)") + con.commit() + assert avvio._nessun_utente(db) is True + con.execute("INSERT INTO users VALUES (1)") + con.commit() + con.close() + assert avvio._nessun_utente(db) is False