feat: automatisches Update-System
- 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 <noreply@anthropic.com>
This commit is contained in:
59
README.md
59
README.md
@@ -171,6 +171,24 @@ Der Hotspot startet automatisch beim Boot wenn das konfigurierte WLAN nicht verf
|
|||||||
|
|
||||||
## Update
|
## 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://<pi-ip>:8080` → Badge erscheint automatisch
|
||||||
|
|
||||||
|
**Option B – per SSH:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd PiCopy
|
cd PiCopy
|
||||||
git pull
|
git pull
|
||||||
@@ -178,6 +196,13 @@ sudo cp app.py /opt/picopy/app.py
|
|||||||
sudo systemctl restart picopy
|
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
|
## 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
|
## Lizenz
|
||||||
|
|
||||||
MIT License – siehe [LICENSE](LICENSE)
|
MIT License – siehe [LICENSE](LICENSE)
|
||||||
|
|||||||
147
app.py
147
app.py
@@ -10,12 +10,17 @@ import threading
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import uuid as _uuid_mod
|
import uuid as _uuid_mod
|
||||||
|
import urllib.request as _urlreq
|
||||||
|
import urllib.error as _urlerr
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from flask import Flask, jsonify, request
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
VERSION = '1.0.0'
|
||||||
|
RAW_BASE = 'https://git.leuschner.dev/Tobias/PiCopy/raw/branch/main'
|
||||||
|
|
||||||
BASE_DIR = Path('/opt/picopy')
|
BASE_DIR = Path('/opt/picopy')
|
||||||
CONFIG_FILE = BASE_DIR / 'config.json'
|
CONFIG_FILE = BASE_DIR / 'config.json'
|
||||||
STATE_FILE = BASE_DIR / 'state.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 Template ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
HTML = r"""<!DOCTYPE html>
|
HTML = r"""<!DOCTYPE html>
|
||||||
@@ -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.c{background:var(--grn);box-shadow:0 0 6px var(--grn)}
|
||||||
.wdot.a{background:var(--pur)}
|
.wdot.a{background:var(--pur)}
|
||||||
.wdot.d{background:var(--brd2)}
|
.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-label{font-weight:600;color:var(--txt)}
|
||||||
#wifi-ip{color:var(--sub);font-family:monospace;font-size:.76rem}
|
#wifi-ip{color:var(--sub);font-family:monospace;font-size:.76rem}
|
||||||
|
|
||||||
@@ -1832,6 +1929,47 @@ function dismissStatus(){
|
|||||||
$('st-dismiss').style.display='none';
|
$('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){
|
function flash(id,cls,msg){
|
||||||
const el=$(id); el.className='flash '+cls; el.textContent=msg; el.style.display='block';
|
const el=$(id); el.className='flash '+cls; el.textContent=msg; el.style.display='block';
|
||||||
if(cls==='ok') setTimeout(()=>el.style.display='none',3500);
|
if(cls==='ok') setTimeout(()=>el.style.display='none',3500);
|
||||||
@@ -1844,7 +1982,9 @@ function flash(id,cls,msg){
|
|||||||
expl.load('');
|
expl.load('');
|
||||||
setInterval(poll,1500);
|
setInterval(poll,1500);
|
||||||
setInterval(refreshDevices,8000);
|
setInterval(refreshDevices,8000);
|
||||||
|
setInterval(pollUpdate,60000);
|
||||||
poll();
|
poll();
|
||||||
|
pollUpdate();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
@@ -1853,7 +1993,8 @@ function flash(id,cls,msg){
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
load_state()
|
load_state()
|
||||||
threading.Thread(target=usb_monitor, daemon=True).start()
|
threading.Thread(target=usb_monitor, daemon=True).start()
|
||||||
threading.Thread(target=wifi_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=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)
|
app.run(host='0.0.0.0', port=8080, debug=False, use_reloader=False)
|
||||||
|
|||||||
1
version.txt
Normal file
1
version.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
1.0.0
|
||||||
Reference in New Issue
Block a user