From 2c02ed4df3a7552cbd8e7818f1b56cf8cda89a82 Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Sat, 9 May 2026 02:03:50 +0200 Subject: [PATCH] feat: automatisches Update-System MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VERSION-Konstante in app.py (aktuell: 1.0.0) - version.txt als zentraler Versions-Vergleichspunkt - Background-Thread prüft alle 6 Stunden auf Updates - /api/update/status – aktueller Update-Status - /api/update/check – manueller Check auslösen - /api/update/install – Download + Syntax-Check + Neustart - Topbar-Badge zeigt "↑ v1.x.x verfügbar" wenn Update bereit - One-Click-Install mit Bestätigungsdialog + Auto-Reload - README: Update-Anleitung (Web-Interface, SSH, One-Liner) - README: Release-Prozess für Maintainer dokumentiert Co-Authored-By: Claude Sonnet 4.6 --- README.md | 59 +++++++++++++++++++++ app.py | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++-- version.txt | 1 + 3 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 version.txt diff --git a/README.md b/README.md index 5106ca7..84dba5f 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,24 @@ Der Hotspot startet automatisch beim Boot wenn das konfigurierte WLAN nicht verf ## Update +### Automatische Update-Benachrichtigung + +PiCopy prüft alle **6 Stunden** automatisch ob eine neue Version verfügbar ist. Sobald ein Update bereitsteht, erscheint in der Topbar des Web-Interfaces ein gelbes Badge: + +``` +↑ v1.1.0 verfügbar +``` + +Ein Klick auf das Badge → Bestätigungsdialog → PiCopy lädt die neue Version herunter, verifiziert sie und startet sich selbst neu. Das Interface ist dabei ca. 10 Sekunden nicht erreichbar. + +### Manuelles Update + +**Option A – über das Web-Interface:** +Topbar-Badge klicken (falls Update verfügbar) oder direkt: +`http://:8080` → Badge erscheint automatisch + +**Option B – per SSH:** + ```bash cd PiCopy git pull @@ -178,6 +196,13 @@ sudo cp app.py /opt/picopy/app.py sudo systemctl restart picopy ``` +**Option C – One-Liner:** + +```bash +curl -sSL https://git.leuschner.dev/Tobias/PiCopy/raw/branch/main/app.py \ + | sudo tee /opt/picopy/app.py > /dev/null && sudo systemctl restart picopy +``` + --- ## Deinstallation @@ -245,6 +270,40 @@ sudo systemctl stop picopy --- +## Neue Version veröffentlichen (für Maintainer) + +So wird ein neues Release erstellt, das alle Nutzer automatisch als Update angezeigt bekommen: + +**1. Versionen erhöhen** + +In `app.py`: +```python +VERSION = '1.1.0' # ← neue Versionsnummer +``` + +In `version.txt`: +``` +1.1.0 +``` + +**2. Committen & pushen** + +```bash +git add app.py version.txt +git commit -m "Release v1.1.0" +git push +``` + +**3. Release/Tag in Gitea erstellen** *(optional, aber empfohlen)* + +Unter [git.leuschner.dev/Tobias/PiCopy/releases](https://git.leuschner.dev/Tobias/PiCopy/releases) → *Neues Release* → Tag `v1.1.0` setzen. + +**Das war's.** Alle laufenden PiCopy-Instanzen erkennen das Update innerhalb von 6 Stunden automatisch und zeigen das Badge im Web-Interface an. + +> **Hinweis:** `version.txt` und `app.py` müssen immer dieselbe Versionsnummer haben. Die `version.txt` ist der einzige Vergleichspunkt – der Inhalt der `app.py` wird erst beim tatsächlichen Update-Install heruntergeladen. + +--- + ## Lizenz MIT License – siehe [LICENSE](LICENSE) diff --git a/app.py b/app.py index 4c59f6e..d37446b 100644 --- a/app.py +++ b/app.py @@ -10,12 +10,17 @@ import threading import subprocess import time import uuid as _uuid_mod +import urllib.request as _urlreq +import urllib.error as _urlerr from datetime import datetime from pathlib import Path from flask import Flask, jsonify, request app = Flask(__name__) +VERSION = '1.0.0' +RAW_BASE = 'https://git.leuschner.dev/Tobias/PiCopy/raw/branch/main' + BASE_DIR = Path('/opt/picopy') CONFIG_FILE = BASE_DIR / 'config.json' STATE_FILE = BASE_DIR / 'state.json' @@ -953,6 +958,96 @@ def r_browse(): +# ── Update-System ───────────────────────────────────────────────────────────── + +update_state = { + 'current': VERSION, + 'latest': None, + 'available': False, + 'checking': False, + 'error': None, + 'last_checked': None, +} +update_lock = threading.Lock() + + +def _vtuple(v): + try: + return tuple(int(x) for x in v.strip().lstrip('v').split('.')) + except Exception: + return (0,) + + +def check_for_updates(): + with update_lock: + if update_state['checking']: + return + update_state['checking'] = True + update_state['error'] = None + + try: + req = _urlreq.urlopen(f'{RAW_BASE}/version.txt', timeout=10) + latest = req.read().decode().strip() + avail = _vtuple(latest) > _vtuple(VERSION) + with update_lock: + update_state.update(latest=latest, available=avail, + last_checked=datetime.now().isoformat()) + if avail: + log.info(f'Update verfügbar: {VERSION} → {latest}') + except Exception as e: + with update_lock: + update_state['error'] = str(e) + log.warning(f'Update-Check fehlgeschlagen: {e}') + finally: + with update_lock: + update_state['checking'] = False + + +def update_check_loop(): + time.sleep(30) # Kurz nach Start einmalig prüfen + while True: + check_for_updates() + time.sleep(6 * 3600) # Dann alle 6 Stunden + + +@app.route('/api/update/status') +def r_update_status(): + with update_lock: + return jsonify(dict(update_state)) + + +@app.route('/api/update/check', methods=['POST']) +def r_update_check(): + threading.Thread(target=check_for_updates, daemon=True).start() + return jsonify(ok=True) + + +@app.route('/api/update/install', methods=['POST']) +def r_update_install(): + try: + log.info('Update wird heruntergeladen…') + req = _urlreq.urlopen(f'{RAW_BASE}/app.py', timeout=60) + new_code = req.read().decode() + + # Syntax-Check bevor wir irgendetwas überschreiben + compile(new_code, 'app.py', 'exec') + + tmp = Path('/tmp/picopy_update.py') + tmp.write_text(new_code) + shutil.copy(str(tmp), '/opt/picopy/app.py') + log.info('Update installiert – starte Dienst neu…') + + # Systemd startet den Dienst automatisch neu + subprocess.Popen(['systemctl', 'restart', 'picopy']) + return jsonify(ok=True) + + except SyntaxError as e: + return jsonify(error=f'Update-Datei ungültig: {e}'), 500 + except Exception as e: + log.exception('Update fehlgeschlagen') + return jsonify(error=str(e)), 500 + + # ── HTML Template ───────────────────────────────────────────────────────────── HTML = r""" @@ -994,6 +1089,8 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys .wdot.c{background:var(--grn);box-shadow:0 0 6px var(--grn)} .wdot.a{background:var(--pur)} .wdot.d{background:var(--brd2)} +.upd-badge{display:none;align-items:center;gap:.4rem;font-size:.78rem;font-weight:600;background:rgba(251,191,36,.12);border:1px solid rgba(251,191,36,.4);color:var(--ylw);border-radius:9999px;padding:.28rem .75rem;cursor:pointer;transition:.15s;white-space:nowrap} +.upd-badge:hover{background:rgba(251,191,36,.22)} #wifi-label{font-weight:600;color:var(--txt)} #wifi-ip{color:var(--sub);font-family:monospace;font-size:.76rem} @@ -1832,6 +1929,47 @@ function dismissStatus(){ $('st-dismiss').style.display='none'; } +// ── Update ──────────────────────────────────────────────────────────────────── +async function pollUpdate() { + try { + const u = await api('/update/status'); + const badge = $('upd-badge'), vEl = $('upd-version'); + if (u.available && u.latest) { + vEl.textContent = 'v' + u.latest; + badge.style.display = 'flex'; + } else { + badge.style.display = 'none'; + } + } catch(e) {} +} + +async function installUpdate() { + const u = await api('/update/status'); + const latest = (u.latest || '?'); + if (!confirm( + 'Update auf v' + latest + ' installieren?\n\n' + + 'PiCopy lädt die neue Version herunter und startet neu.\n' + + 'Das Web-Interface ist für ca. 10 Sekunden nicht erreichbar.' + )) return; + + $('upd-badge').innerHTML = '↻ Installiere…'; + $('upd-badge').style.pointerEvents = 'none'; + + try { + await api('/update/install', 'POST'); + } catch(e) {} + + // Warte bis der Dienst wieder läuft, dann reload + setTimeout(async function waitForRestart() { + try { + await fetch('/api/update/status'); + location.reload(); + } catch(e) { + setTimeout(waitForRestart, 2000); + } + }, 5000); +} + function flash(id,cls,msg){ const el=$(id); el.className='flash '+cls; el.textContent=msg; el.style.display='block'; if(cls==='ok') setTimeout(()=>el.style.display='none',3500); @@ -1844,7 +1982,9 @@ function flash(id,cls,msg){ expl.load(''); setInterval(poll,1500); setInterval(refreshDevices,8000); + setInterval(pollUpdate,60000); poll(); + pollUpdate(); })(); @@ -1853,7 +1993,8 @@ function flash(id,cls,msg){ if __name__ == '__main__': load_state() - threading.Thread(target=usb_monitor, daemon=True).start() - threading.Thread(target=wifi_monitor, daemon=True).start() - log.info('PiCopy v2 läuft auf http://0.0.0.0:8080') + threading.Thread(target=usb_monitor, daemon=True).start() + threading.Thread(target=wifi_monitor, daemon=True).start() + threading.Thread(target=update_check_loop, daemon=True).start() + log.info(f'PiCopy v{VERSION} läuft auf http://0.0.0.0:8080') app.run(host='0.0.0.0', port=8080, debug=False, use_reloader=False) diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +1.0.0