mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-20 11:38:37 +00:00
Compare commits
29 Commits
a84b65fffb
...
fix/post-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a3e6f0685 | ||
|
|
5b6137f5f4 | ||
|
|
f97573c368 | ||
|
|
9a74db6de3 | ||
|
|
b7295129b5 | ||
|
|
3e94bb9664 | ||
|
|
44316d3bae | ||
|
|
baebe4f5f7 | ||
|
|
fccd6e70be | ||
|
|
2a74db3a59 | ||
|
|
4b39fbcfd1 | ||
|
|
7ba66e541c | ||
|
|
3f66d15af7 | ||
|
|
9490cf6023 | ||
|
|
15fc9003ac | ||
|
|
55a6a53fca | ||
|
|
c54718af2d | ||
|
|
e8afd23c98 | ||
|
|
5e6c40ad55 | ||
|
|
d6bd1ee215 | ||
|
|
acaf8a248e | ||
|
|
db9585cea9 | ||
|
|
65e3e8319b | ||
|
|
4ef3f8cad5 | ||
|
|
338bdc44cb | ||
|
|
73c00140df | ||
|
|
68a38c39f7 | ||
|
|
941291561a | ||
|
|
39ccdcf00d |
Binary file not shown.
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 657 KiB |
@@ -1,17 +1,9 @@
|
|||||||
{
|
{
|
||||||
"ledmatrix-weather": {
|
|
||||||
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
|
||||||
},
|
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||||
},
|
},
|
||||||
"music": {
|
|
||||||
"SPOTIFY_CLIENT_ID": "YOUR_SPOTIFY_CLIENT_ID_HERE",
|
|
||||||
"SPOTIFY_CLIENT_SECRET": "YOUR_SPOTIFY_CLIENT_SECRET_HERE",
|
|
||||||
"SPOTIFY_REDIRECT_URI": "http://127.0.0.1:8888/callback"
|
|
||||||
},
|
|
||||||
"github": {
|
"github": {
|
||||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -599,9 +599,13 @@ if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
|||||||
echo "⚠ Template config/config_secrets.template.json not found; creating a minimal secrets file"
|
echo "⚠ Template config/config_secrets.template.json not found; creating a minimal secrets file"
|
||||||
cat > "$PROJECT_ROOT_DIR/config/config_secrets.json" <<'EOF'
|
cat > "$PROJECT_ROOT_DIR/config/config_secrets.json" <<'EOF'
|
||||||
{
|
{
|
||||||
"weather": {
|
"youtube": {
|
||||||
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||||
}
|
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
# Check if service runs as root and set ownership accordingly
|
# Check if service runs as root and set ownership accordingly
|
||||||
@@ -1082,6 +1086,7 @@ SYSTEMCTL_PATH=$(which systemctl)
|
|||||||
REBOOT_PATH=$(which reboot)
|
REBOOT_PATH=$(which reboot)
|
||||||
POWEROFF_PATH=$(which poweroff)
|
POWEROFF_PATH=$(which poweroff)
|
||||||
BASH_PATH=$(which bash)
|
BASH_PATH=$(which bash)
|
||||||
|
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
|
||||||
|
|
||||||
# Create sudoers content
|
# Create sudoers content
|
||||||
cat > /tmp/ledmatrix_web_sudoers << EOF
|
cat > /tmp/ledmatrix_web_sudoers << EOF
|
||||||
@@ -1097,10 +1102,22 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
|
|||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controller.py
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controller.py
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
|
||||||
EOF
|
EOF
|
||||||
|
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||||
|
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -f "$SUDOERS_FILE" ] && cmp -s /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"; then
|
if [ -f "$SUDOERS_FILE" ] && cmp -s /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"; then
|
||||||
echo "Sudoers configuration already up to date"
|
echo "Sudoers configuration already up to date"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
requests>=2.28.0
|
requests>=2.28.0
|
||||||
Pillow>=9.1.0
|
Pillow>=10.3.0
|
||||||
pytz>=2022.1
|
pytz>=2022.1
|
||||||
numpy>=1.24.0
|
numpy>=1.24.0
|
||||||
|
|||||||
@@ -35,24 +35,24 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
"""Initialize the Web UI Info plugin."""
|
"""Initialize the Web UI Info plugin."""
|
||||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||||
|
|
||||||
|
# AP mode cache (must be initialized before _get_local_ip)
|
||||||
|
self._ap_mode_cached = False
|
||||||
|
self._ap_mode_cache_time = 0.0
|
||||||
|
self._ap_mode_cache_ttl = 60.0
|
||||||
|
|
||||||
# Get device hostname
|
# Get device hostname
|
||||||
try:
|
try:
|
||||||
self.device_id = socket.gethostname()
|
self.device_id = socket.gethostname()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Could not get hostname: {e}, using 'localhost'")
|
self.logger.warning(f"Could not get hostname: {e}, using 'localhost'")
|
||||||
self.device_id = "localhost"
|
self.device_id = "localhost"
|
||||||
|
|
||||||
# Get device IP address
|
# Get device IP address
|
||||||
self.device_ip = self._get_local_ip()
|
self.device_ip = self._get_local_ip()
|
||||||
|
|
||||||
# IP refresh tracking
|
# IP refresh tracking
|
||||||
self.last_ip_refresh = time.time()
|
self.last_ip_refresh = time.time()
|
||||||
self.ip_refresh_interval = 300.0 # Refresh IP every 5 minutes
|
self.ip_refresh_interval = 300.0
|
||||||
|
|
||||||
# AP mode cache
|
|
||||||
self._ap_mode_cached = False
|
|
||||||
self._ap_mode_cache_time = 0.0
|
|
||||||
self._ap_mode_cache_ttl = 60.0 # Cache AP mode check for 60 seconds
|
|
||||||
|
|
||||||
# Rotation state
|
# Rotation state
|
||||||
self.current_display_mode = "hostname" # "hostname" or "ip"
|
self.current_display_mode = "hostname" # "hostname" or "ip"
|
||||||
@@ -200,9 +200,7 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
elif current_interface == "wlan0":
|
elif current_interface == "wlan0":
|
||||||
self.logger.debug(f"Found WiFi IP: {ip} on {current_interface}")
|
self.logger.debug(f"Found WiFi IP: {ip} on {current_interface}")
|
||||||
return ip
|
return ip
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Last resort: try hostname resolution (often returns 127.0.0.1)
|
# Last resort: try hostname resolution (often returns 127.0.0.1)
|
||||||
try:
|
try:
|
||||||
ip = socket.gethostbyname(socket.gethostname())
|
ip = socket.gethostbyname(socket.gethostname())
|
||||||
|
|||||||
@@ -89,9 +89,9 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
|||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
|
||||||
|
|
||||||
# Optional: journalctl (non-critical — skip if not found)
|
# Optional: journalctl (non-critical — skip if not found)
|
||||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import signal
|
import signal
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Add project root to path (parent of scripts/utils/)
|
# Add project root to path (parent of scripts/utils/)
|
||||||
@@ -43,7 +44,11 @@ class WiFiMonitorDaemon:
|
|||||||
self.wifi_manager = WiFiManager()
|
self.wifi_manager = WiFiManager()
|
||||||
self.running = True
|
self.running = True
|
||||||
self.last_state = None
|
self.last_state = None
|
||||||
|
# Counts consecutive checks where nmcli says "connected" but internet is unreachable.
|
||||||
|
# After _nm_restart_threshold failures, NetworkManager is restarted as a recovery step.
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
self._nm_restart_threshold = 5 # ~2.5 min at 30s interval
|
||||||
|
|
||||||
# Register signal handlers for graceful shutdown
|
# Register signal handlers for graceful shutdown
|
||||||
signal.signal(signal.SIGINT, self._signal_handler)
|
signal.signal(signal.SIGINT, self._signal_handler)
|
||||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||||
@@ -122,6 +127,43 @@ class WiFiMonitorDaemon:
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
||||||
|
|
||||||
|
# Escalating recovery: if nmcli reports connected but actual internet
|
||||||
|
# is unreachable for several consecutive checks, restart NetworkManager.
|
||||||
|
# This is done HERE (not inside check_and_manage_ap_mode) to keep the
|
||||||
|
# AP-enable trigger clean and avoid false-positive AP enables from
|
||||||
|
# transient packet loss on otherwise working WiFi.
|
||||||
|
if updated_status.connected and not updated_status.ap_mode_active:
|
||||||
|
if not self.wifi_manager.check_internet_connectivity():
|
||||||
|
self._consecutive_internet_failures += 1
|
||||||
|
logger.warning(
|
||||||
|
f"Internet unreachable despite nmcli connection "
|
||||||
|
f"({self._consecutive_internet_failures}/{self._nm_restart_threshold})"
|
||||||
|
)
|
||||||
|
if self._consecutive_internet_failures >= self._nm_restart_threshold:
|
||||||
|
logger.warning("Restarting NetworkManager to recover internet connectivity")
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["/usr/bin/systemctl", "restart", "NetworkManager"],
|
||||||
|
capture_output=True, timeout=20, check=True
|
||||||
|
)
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
# NM restart causes a brief WiFi drop; reset the AP-mode grace
|
||||||
|
# counter so that transient disconnect doesn't count toward
|
||||||
|
# triggering AP mode.
|
||||||
|
self.wifi_manager._disconnected_checks = 0
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"NetworkManager restart failed (rc={e.returncode}); "
|
||||||
|
"resetting failure counter to avoid tight retry loop")
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"NetworkManager restart error: {e}; "
|
||||||
|
"resetting failure counter to avoid tight retry loop")
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
else:
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
else:
|
||||||
|
self._consecutive_internet_failures = 0
|
||||||
|
|
||||||
# Sleep until next check
|
# Sleep until next check
|
||||||
time.sleep(self.check_interval)
|
time.sleep(self.check_interval)
|
||||||
|
|
||||||
|
|||||||
605
src/backup_manager.py
Normal file
605
src/backup_manager.py
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
"""
|
||||||
|
User configuration backup and restore.
|
||||||
|
|
||||||
|
Packages the user's LEDMatrix configuration, secrets, WiFi settings,
|
||||||
|
user-uploaded fonts, plugin image uploads, and installed-plugin manifest
|
||||||
|
into a single ``.zip`` that can be exported from one installation and
|
||||||
|
imported on a fresh install.
|
||||||
|
|
||||||
|
This module is intentionally Flask-free so it can be unit-tested and
|
||||||
|
used from scripts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
# Filenames shipped with the LEDMatrix repository under ``assets/fonts/``.
|
||||||
|
# Anything present on disk but NOT in this set is treated as a user upload
|
||||||
|
# and included in backups. Keep this snapshot in sync with the repo — regenerate
|
||||||
|
# with::
|
||||||
|
#
|
||||||
|
# ls assets/fonts/
|
||||||
|
#
|
||||||
|
# Tests assert the set matches the checked-in fonts.
|
||||||
|
BUNDLED_FONTS: frozenset[str] = frozenset({
|
||||||
|
"10x20.bdf",
|
||||||
|
"4x6.bdf",
|
||||||
|
"4x6-font.ttf",
|
||||||
|
"5by7.regular.ttf",
|
||||||
|
"5x7.bdf",
|
||||||
|
"5x8.bdf",
|
||||||
|
"6x9.bdf",
|
||||||
|
"6x10.bdf",
|
||||||
|
"6x12.bdf",
|
||||||
|
"6x13.bdf",
|
||||||
|
"6x13B.bdf",
|
||||||
|
"6x13O.bdf",
|
||||||
|
"7x13.bdf",
|
||||||
|
"7x13B.bdf",
|
||||||
|
"7x13O.bdf",
|
||||||
|
"7x14.bdf",
|
||||||
|
"7x14B.bdf",
|
||||||
|
"8x13.bdf",
|
||||||
|
"8x13B.bdf",
|
||||||
|
"8x13O.bdf",
|
||||||
|
"9x15.bdf",
|
||||||
|
"9x15B.bdf",
|
||||||
|
"9x18.bdf",
|
||||||
|
"9x18B.bdf",
|
||||||
|
"AUTHORS",
|
||||||
|
"bdf_font_guide",
|
||||||
|
"clR6x12.bdf",
|
||||||
|
"helvR12.bdf",
|
||||||
|
"ic8x8u.bdf",
|
||||||
|
"MatrixChunky8.bdf",
|
||||||
|
"MatrixChunky8X.bdf",
|
||||||
|
"MatrixLight6.bdf",
|
||||||
|
"MatrixLight6X.bdf",
|
||||||
|
"MatrixLight8X.bdf",
|
||||||
|
"PressStart2P-Regular.ttf",
|
||||||
|
"README",
|
||||||
|
"README.md",
|
||||||
|
"texgyre-27.bdf",
|
||||||
|
"tom-thumb.bdf",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Relative paths inside the project that the backup knows how to round-trip.
|
||||||
|
_CONFIG_REL = Path("config/config.json")
|
||||||
|
_SECRETS_REL = Path("config/config_secrets.json")
|
||||||
|
_WIFI_REL = Path("config/wifi_config.json")
|
||||||
|
_FONTS_REL = Path("assets/fonts")
|
||||||
|
_PLUGIN_UPLOADS_REL = Path("assets/plugins")
|
||||||
|
_STATE_REL = Path("data/plugin_state.json")
|
||||||
|
|
||||||
|
MANIFEST_NAME = "manifest.json"
|
||||||
|
PLUGINS_MANIFEST_NAME = "plugins.json"
|
||||||
|
|
||||||
|
# Hard cap on the size of a single file we'll accept inside an uploaded ZIP
|
||||||
|
# to limit zip-bomb risk. 50 MB matches the existing plugin-image upload cap.
|
||||||
|
_MAX_MEMBER_BYTES = 50 * 1024 * 1024
|
||||||
|
# Hard cap on the total uncompressed size of an uploaded ZIP.
|
||||||
|
_MAX_TOTAL_BYTES = 200 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data classes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RestoreOptions:
|
||||||
|
"""Which sections of a backup should be restored."""
|
||||||
|
|
||||||
|
restore_config: bool = True
|
||||||
|
restore_secrets: bool = True
|
||||||
|
restore_wifi: bool = True
|
||||||
|
restore_fonts: bool = True
|
||||||
|
restore_plugin_uploads: bool = True
|
||||||
|
reinstall_plugins: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RestoreResult:
|
||||||
|
"""Outcome of a restore operation."""
|
||||||
|
|
||||||
|
success: bool = False
|
||||||
|
restored: List[str] = field(default_factory=list)
|
||||||
|
skipped: List[str] = field(default_factory=list)
|
||||||
|
plugins_to_install: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
|
plugins_installed: List[str] = field(default_factory=list)
|
||||||
|
plugins_failed: List[Dict[str, str]] = field(default_factory=list)
|
||||||
|
errors: List[str] = field(default_factory=list)
|
||||||
|
manifest: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Manifest helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _ledmatrix_version(project_root: Path) -> str:
|
||||||
|
"""Best-effort version string for the current install."""
|
||||||
|
version_file = project_root / "VERSION"
|
||||||
|
if version_file.exists():
|
||||||
|
try:
|
||||||
|
return version_file.read_text(encoding="utf-8").strip() or "unknown"
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
head_file = project_root / ".git" / "HEAD"
|
||||||
|
if head_file.exists():
|
||||||
|
try:
|
||||||
|
head = head_file.read_text(encoding="utf-8").strip()
|
||||||
|
if head.startswith("ref: "):
|
||||||
|
ref = head[5:]
|
||||||
|
ref_path = project_root / ".git" / ref
|
||||||
|
if ref_path.exists():
|
||||||
|
return ref_path.read_text(encoding="utf-8").strip()[:12] or "unknown"
|
||||||
|
return head[:12] or "unknown"
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_manifest(contents: List[str], project_root: Path) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
|
"ledmatrix_version": _ledmatrix_version(project_root),
|
||||||
|
"hostname": socket.gethostname(),
|
||||||
|
"contents": contents,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Installed-plugin enumeration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Return a list of currently-installed plugins suitable for the backup
|
||||||
|
manifest. Each entry has ``plugin_id`` and ``version``.
|
||||||
|
|
||||||
|
Reads ``data/plugin_state.json`` if present; otherwise walks the plugin
|
||||||
|
directory and reads each ``manifest.json``.
|
||||||
|
"""
|
||||||
|
plugins: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
state_file = project_root / _STATE_REL
|
||||||
|
if state_file.exists():
|
||||||
|
try:
|
||||||
|
with state_file.open("r", encoding="utf-8") as f:
|
||||||
|
state = json.load(f)
|
||||||
|
raw_plugins = state.get("states", {}) if isinstance(state, dict) else {}
|
||||||
|
if isinstance(raw_plugins, dict):
|
||||||
|
for plugin_id, info in raw_plugins.items():
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
continue
|
||||||
|
plugins[plugin_id] = {
|
||||||
|
"plugin_id": plugin_id,
|
||||||
|
"version": info.get("version") or "",
|
||||||
|
"enabled": bool(info.get("enabled", True)),
|
||||||
|
}
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning("Could not read plugin_state.json: %s", e)
|
||||||
|
|
||||||
|
# Fall back to scanning plugin-repos/ for manifests.
|
||||||
|
plugins_root = project_root / "plugin-repos"
|
||||||
|
if plugins_root.exists():
|
||||||
|
for entry in sorted(plugins_root.iterdir()):
|
||||||
|
if not entry.is_dir():
|
||||||
|
continue
|
||||||
|
manifest = entry / "manifest.json"
|
||||||
|
if not manifest.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with manifest.open("r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
plugin_id = data.get("id") or entry.name
|
||||||
|
if plugin_id not in plugins:
|
||||||
|
plugins[plugin_id] = {
|
||||||
|
"plugin_id": plugin_id,
|
||||||
|
"version": data.get("version", ""),
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted(plugins.values(), key=lambda p: p["plugin_id"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Font filtering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def iter_user_fonts(project_root: Path) -> List[Path]:
|
||||||
|
"""Return absolute paths to user-uploaded fonts (anything in
|
||||||
|
``assets/fonts/`` not listed in :data:`BUNDLED_FONTS`)."""
|
||||||
|
fonts_dir = project_root / _FONTS_REL
|
||||||
|
if not fonts_dir.exists():
|
||||||
|
return []
|
||||||
|
user_fonts: List[Path] = []
|
||||||
|
for entry in sorted(fonts_dir.iterdir()):
|
||||||
|
if entry.is_file() and entry.name not in BUNDLED_FONTS:
|
||||||
|
user_fonts.append(entry)
|
||||||
|
return user_fonts
|
||||||
|
|
||||||
|
|
||||||
|
def iter_plugin_uploads(project_root: Path) -> List[Path]:
|
||||||
|
"""Return every file under ``assets/plugins/*/uploads/`` (recursive)."""
|
||||||
|
plugin_root = project_root / _PLUGIN_UPLOADS_REL
|
||||||
|
if not plugin_root.exists():
|
||||||
|
return []
|
||||||
|
out: List[Path] = []
|
||||||
|
for plugin_dir in sorted(plugin_root.iterdir()):
|
||||||
|
if not plugin_dir.is_dir():
|
||||||
|
continue
|
||||||
|
uploads = plugin_dir / "uploads"
|
||||||
|
if not uploads.exists():
|
||||||
|
continue
|
||||||
|
for root, _dirs, files in os.walk(uploads):
|
||||||
|
for name in sorted(files):
|
||||||
|
out.append(Path(root) / name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Export
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def create_backup(
|
||||||
|
project_root: Path,
|
||||||
|
output_dir: Optional[Path] = None,
|
||||||
|
) -> Path:
|
||||||
|
"""
|
||||||
|
Build a backup ZIP and write it into ``output_dir`` (defaults to
|
||||||
|
``<project_root>/config/backups/exports/``). Returns the path to the
|
||||||
|
created file.
|
||||||
|
"""
|
||||||
|
project_root = Path(project_root).resolve()
|
||||||
|
if output_dir is None:
|
||||||
|
output_dir = project_root / "config" / "backups" / "exports"
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
hostname = socket.gethostname() or "ledmatrix"
|
||||||
|
safe_host = "".join(c for c in hostname if c.isalnum() or c in "-_") or "ledmatrix"
|
||||||
|
zip_name = f"ledmatrix-backup-{safe_host}-{timestamp}.zip"
|
||||||
|
zip_path = output_dir / zip_name
|
||||||
|
|
||||||
|
contents: List[str] = []
|
||||||
|
|
||||||
|
# Stream directly to a temp file so we never hold the whole ZIP in memory.
|
||||||
|
tmp_path = zip_path.with_suffix(".zip.tmp")
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
# Config files.
|
||||||
|
if (project_root / _CONFIG_REL).exists():
|
||||||
|
zf.write(project_root / _CONFIG_REL, _CONFIG_REL.as_posix())
|
||||||
|
contents.append("config")
|
||||||
|
if (project_root / _SECRETS_REL).exists():
|
||||||
|
zf.write(project_root / _SECRETS_REL, _SECRETS_REL.as_posix())
|
||||||
|
contents.append("secrets")
|
||||||
|
if (project_root / _WIFI_REL).exists():
|
||||||
|
zf.write(project_root / _WIFI_REL, _WIFI_REL.as_posix())
|
||||||
|
contents.append("wifi")
|
||||||
|
|
||||||
|
# User-uploaded fonts.
|
||||||
|
user_fonts = iter_user_fonts(project_root)
|
||||||
|
if user_fonts:
|
||||||
|
for font in user_fonts:
|
||||||
|
arcname = font.relative_to(project_root).as_posix()
|
||||||
|
zf.write(font, arcname)
|
||||||
|
contents.append("fonts")
|
||||||
|
|
||||||
|
# Plugin uploads.
|
||||||
|
plugin_uploads = iter_plugin_uploads(project_root)
|
||||||
|
if plugin_uploads:
|
||||||
|
for upload in plugin_uploads:
|
||||||
|
arcname = upload.relative_to(project_root).as_posix()
|
||||||
|
zf.write(upload, arcname)
|
||||||
|
contents.append("plugin_uploads")
|
||||||
|
|
||||||
|
# Installed plugins manifest.
|
||||||
|
plugins = list_installed_plugins(project_root)
|
||||||
|
if plugins:
|
||||||
|
zf.writestr(
|
||||||
|
PLUGINS_MANIFEST_NAME,
|
||||||
|
json.dumps(plugins, indent=2),
|
||||||
|
)
|
||||||
|
contents.append("plugins")
|
||||||
|
|
||||||
|
# Manifest goes last so that `contents` reflects what we actually wrote.
|
||||||
|
manifest = _build_manifest(contents, project_root)
|
||||||
|
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
|
||||||
|
|
||||||
|
os.replace(tmp_path, zip_path)
|
||||||
|
except Exception:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
logger.info("Created backup %s (%d bytes)", zip_path, zip_path.stat().st_size)
|
||||||
|
return zip_path
|
||||||
|
|
||||||
|
|
||||||
|
def preview_backup_contents(project_root: Path) -> Dict[str, Any]:
|
||||||
|
"""Return a summary of what ``create_backup`` would include."""
|
||||||
|
project_root = Path(project_root).resolve()
|
||||||
|
return {
|
||||||
|
"has_config": (project_root / _CONFIG_REL).exists(),
|
||||||
|
"has_secrets": (project_root / _SECRETS_REL).exists(),
|
||||||
|
"has_wifi": (project_root / _WIFI_REL).exists(),
|
||||||
|
"user_fonts": [p.name for p in iter_user_fonts(project_root)],
|
||||||
|
"plugin_uploads": len(iter_plugin_uploads(project_root)),
|
||||||
|
"plugins": list_installed_plugins(project_root),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_extract_path(base_dir: Path, member_name: str) -> Optional[Path]:
|
||||||
|
"""Resolve a ZIP member name against ``base_dir`` and reject anything
|
||||||
|
that escapes it. Returns the resolved absolute path, or ``None`` if the
|
||||||
|
name is unsafe."""
|
||||||
|
# Reject absolute paths and Windows-style drives outright.
|
||||||
|
if member_name.startswith(("/", "\\")) or (len(member_name) >= 2 and member_name[1] == ":"):
|
||||||
|
return None
|
||||||
|
target = (base_dir / member_name).resolve()
|
||||||
|
try:
|
||||||
|
target.relative_to(base_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Inspect a backup ZIP without extracting to disk.
|
||||||
|
|
||||||
|
Returns ``(ok, error_message, manifest_dict)``. ``manifest_dict`` contains
|
||||||
|
the parsed manifest plus diagnostic fields:
|
||||||
|
- ``detected_contents``: list of section names present in the archive
|
||||||
|
- ``plugins``: parsed plugins.json if present
|
||||||
|
- ``total_uncompressed``: sum of uncompressed sizes
|
||||||
|
"""
|
||||||
|
zip_path = Path(zip_path)
|
||||||
|
if not zip_path.exists():
|
||||||
|
return False, f"Backup file not found: {zip_path}", {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
if MANIFEST_NAME not in names:
|
||||||
|
return False, "Backup is missing manifest.json", {}
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
with tempfile.TemporaryDirectory() as _sandbox:
|
||||||
|
sandbox = Path(_sandbox)
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.file_size > _MAX_MEMBER_BYTES:
|
||||||
|
return False, f"Member {info.filename} is too large", {}
|
||||||
|
total += info.file_size
|
||||||
|
if total > _MAX_TOTAL_BYTES:
|
||||||
|
return False, "Backup exceeds maximum allowed size", {}
|
||||||
|
# Safety: reject members with unsafe paths up front.
|
||||||
|
if _safe_extract_path(sandbox, info.filename) is None:
|
||||||
|
return False, f"Unsafe path in backup: {info.filename}", {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
||||||
|
manifest = json.loads(manifest_raw)
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
||||||
|
return False, f"Invalid manifest.json: {e}", {}
|
||||||
|
|
||||||
|
if not isinstance(manifest, dict) or "schema_version" not in manifest:
|
||||||
|
return False, "Invalid manifest structure", {}
|
||||||
|
if manifest.get("schema_version") != SCHEMA_VERSION:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"Unsupported backup schema version: {manifest.get('schema_version')}",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
detected: List[str] = []
|
||||||
|
if _CONFIG_REL.as_posix() in names:
|
||||||
|
detected.append("config")
|
||||||
|
if _SECRETS_REL.as_posix() in names:
|
||||||
|
detected.append("secrets")
|
||||||
|
if _WIFI_REL.as_posix() in names:
|
||||||
|
detected.append("wifi")
|
||||||
|
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
|
||||||
|
detected.append("fonts")
|
||||||
|
if any(
|
||||||
|
n.startswith(_PLUGIN_UPLOADS_REL.as_posix() + "/") and "/uploads/" in n
|
||||||
|
for n in names
|
||||||
|
):
|
||||||
|
detected.append("plugin_uploads")
|
||||||
|
|
||||||
|
plugins: List[Dict[str, Any]] = []
|
||||||
|
if PLUGINS_MANIFEST_NAME in names:
|
||||||
|
try:
|
||||||
|
plugins = json.loads(zf.read(PLUGINS_MANIFEST_NAME).decode("utf-8"))
|
||||||
|
if not isinstance(plugins, list):
|
||||||
|
plugins = []
|
||||||
|
else:
|
||||||
|
detected.append("plugins")
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
plugins = []
|
||||||
|
|
||||||
|
result_manifest = dict(manifest)
|
||||||
|
result_manifest["detected_contents"] = detected
|
||||||
|
result_manifest["plugins"] = plugins
|
||||||
|
result_manifest["total_uncompressed"] = total
|
||||||
|
result_manifest["file_count"] = len(names)
|
||||||
|
return True, "", result_manifest
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
return False, "File is not a valid ZIP archive", {}
|
||||||
|
except OSError as e:
|
||||||
|
return False, f"Could not read backup: {e}", {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Restore
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_zip_safe(zip_path: Path, dest_dir: Path) -> None:
|
||||||
|
"""Extract ``zip_path`` into ``dest_dir`` rejecting any unsafe members."""
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
for info in zf.infolist():
|
||||||
|
target = _safe_extract_path(dest_dir, info.filename)
|
||||||
|
if target is None:
|
||||||
|
raise ValueError(f"Unsafe path in backup: {info.filename}")
|
||||||
|
if info.is_dir():
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
continue
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zf.open(info, "r") as src, open(target, "wb") as dst:
|
||||||
|
shutil.copyfileobj(src, dst, length=64 * 1024)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_file(src: Path, dst: Path) -> None:
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
|
|
||||||
|
def restore_backup(
|
||||||
|
zip_path: Path,
|
||||||
|
project_root: Path,
|
||||||
|
options: Optional[RestoreOptions] = None,
|
||||||
|
) -> RestoreResult:
|
||||||
|
"""
|
||||||
|
Restore ``zip_path`` into ``project_root`` according to ``options``.
|
||||||
|
|
||||||
|
Plugin reinstalls are NOT performed here — the caller is responsible for
|
||||||
|
walking ``result.plugins_to_install`` and calling the store manager. This
|
||||||
|
keeps this module Flask-free and side-effect free beyond the filesystem.
|
||||||
|
"""
|
||||||
|
if options is None:
|
||||||
|
options = RestoreOptions()
|
||||||
|
project_root = Path(project_root).resolve()
|
||||||
|
result = RestoreResult()
|
||||||
|
|
||||||
|
ok, err, manifest = validate_backup(zip_path)
|
||||||
|
if not ok:
|
||||||
|
result.errors.append(err)
|
||||||
|
return result
|
||||||
|
result.manifest = manifest
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="ledmatrix_restore_") as tmp:
|
||||||
|
tmp_dir = Path(tmp)
|
||||||
|
try:
|
||||||
|
_extract_zip_safe(Path(zip_path), tmp_dir)
|
||||||
|
except (ValueError, zipfile.BadZipFile, OSError) as e:
|
||||||
|
result.errors.append(f"Failed to extract backup: {e}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Main config.
|
||||||
|
if options.restore_config and (tmp_dir / _CONFIG_REL).exists():
|
||||||
|
try:
|
||||||
|
_copy_file(tmp_dir / _CONFIG_REL, project_root / _CONFIG_REL)
|
||||||
|
result.restored.append("config")
|
||||||
|
except OSError as e:
|
||||||
|
result.errors.append(f"Failed to restore config.json: {e}")
|
||||||
|
elif (tmp_dir / _CONFIG_REL).exists():
|
||||||
|
result.skipped.append("config")
|
||||||
|
|
||||||
|
# Secrets.
|
||||||
|
if options.restore_secrets and (tmp_dir / _SECRETS_REL).exists():
|
||||||
|
try:
|
||||||
|
_copy_file(tmp_dir / _SECRETS_REL, project_root / _SECRETS_REL)
|
||||||
|
result.restored.append("secrets")
|
||||||
|
except OSError as e:
|
||||||
|
result.errors.append(f"Failed to restore config_secrets.json: {e}")
|
||||||
|
elif (tmp_dir / _SECRETS_REL).exists():
|
||||||
|
result.skipped.append("secrets")
|
||||||
|
|
||||||
|
# WiFi.
|
||||||
|
if options.restore_wifi and (tmp_dir / _WIFI_REL).exists():
|
||||||
|
try:
|
||||||
|
_copy_file(tmp_dir / _WIFI_REL, project_root / _WIFI_REL)
|
||||||
|
result.restored.append("wifi")
|
||||||
|
except OSError as e:
|
||||||
|
result.errors.append(f"Failed to restore wifi_config.json: {e}")
|
||||||
|
elif (tmp_dir / _WIFI_REL).exists():
|
||||||
|
result.skipped.append("wifi")
|
||||||
|
|
||||||
|
# User fonts — skip anything that collides with a bundled font.
|
||||||
|
tmp_fonts = tmp_dir / _FONTS_REL
|
||||||
|
if options.restore_fonts and tmp_fonts.exists():
|
||||||
|
restored_count = 0
|
||||||
|
for font in sorted(tmp_fonts.iterdir()):
|
||||||
|
if not font.is_file():
|
||||||
|
continue
|
||||||
|
if font.name in BUNDLED_FONTS:
|
||||||
|
result.skipped.append(f"font:{font.name} (bundled)")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_copy_file(font, project_root / _FONTS_REL / font.name)
|
||||||
|
restored_count += 1
|
||||||
|
except OSError as e:
|
||||||
|
result.errors.append(f"Failed to restore font {font.name}: {e}")
|
||||||
|
if restored_count:
|
||||||
|
result.restored.append(f"fonts ({restored_count})")
|
||||||
|
elif tmp_fonts.exists():
|
||||||
|
result.skipped.append("fonts")
|
||||||
|
|
||||||
|
# Plugin uploads.
|
||||||
|
tmp_uploads = tmp_dir / _PLUGIN_UPLOADS_REL
|
||||||
|
if options.restore_plugin_uploads and tmp_uploads.exists():
|
||||||
|
count = 0
|
||||||
|
for root, _dirs, files in os.walk(tmp_uploads):
|
||||||
|
for name in files:
|
||||||
|
src = Path(root) / name
|
||||||
|
rel = src.relative_to(tmp_dir)
|
||||||
|
if "/uploads/" not in rel.as_posix():
|
||||||
|
result.errors.append(f"Rejected unexpected plugin path: {rel}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_copy_file(src, project_root / rel)
|
||||||
|
count += 1
|
||||||
|
except OSError as e:
|
||||||
|
result.errors.append(f"Failed to restore {rel}: {e}")
|
||||||
|
if count:
|
||||||
|
result.restored.append(f"plugin_uploads ({count})")
|
||||||
|
elif tmp_uploads.exists():
|
||||||
|
result.skipped.append("plugin_uploads")
|
||||||
|
|
||||||
|
# Plugins list (for caller to reinstall).
|
||||||
|
if options.reinstall_plugins and (tmp_dir / PLUGINS_MANIFEST_NAME).exists():
|
||||||
|
try:
|
||||||
|
with (tmp_dir / PLUGINS_MANIFEST_NAME).open("r", encoding="utf-8") as f:
|
||||||
|
plugins = json.load(f)
|
||||||
|
if isinstance(plugins, list):
|
||||||
|
result.plugins_to_install = [
|
||||||
|
{"plugin_id": p.get("plugin_id"), "version": p.get("version", "")}
|
||||||
|
for p in plugins
|
||||||
|
if isinstance(p, dict) and p.get("plugin_id")
|
||||||
|
]
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
result.errors.append(f"Could not read plugins.json: {e}")
|
||||||
|
|
||||||
|
result.success = not result.errors
|
||||||
|
return result
|
||||||
@@ -677,6 +677,44 @@ class PluginManager:
|
|||||||
# Default: 60 seconds
|
# Default: 60 seconds
|
||||||
return 60.0
|
return 60.0
|
||||||
|
|
||||||
|
def _record_update_failure(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
exc: Optional[Exception] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Apply the standard failure-recovery path for a plugin update.
|
||||||
|
|
||||||
|
Stamps plugin_last_update with the actual failure time so the full
|
||||||
|
configured interval elapses before the next retry, then transitions
|
||||||
|
the plugin back to ENABLED (not ERROR) with structured error context
|
||||||
|
so automatic recovery happens on the next scheduled cycle.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: Plugin identifier
|
||||||
|
exc: The exception that caused the failure, if any. When None a
|
||||||
|
synthetic ExecutionFailure exception is constructed from the
|
||||||
|
timeout/executor-error path.
|
||||||
|
"""
|
||||||
|
failure_time = time.time()
|
||||||
|
if exc is not None:
|
||||||
|
err: Exception = exc
|
||||||
|
error_type = type(exc).__name__
|
||||||
|
else:
|
||||||
|
err = Exception(f"Plugin {plugin_id} execution failed (timeout or executor error)")
|
||||||
|
error_type = 'ExecutionFailure'
|
||||||
|
|
||||||
|
error_info = {
|
||||||
|
'error': str(err),
|
||||||
|
'error_type': error_type,
|
||||||
|
'timestamp': failure_time,
|
||||||
|
'recoverable': True,
|
||||||
|
}
|
||||||
|
self.logger.warning("Plugin %s update() failed; will retry after interval", plugin_id)
|
||||||
|
self.plugin_last_update[plugin_id] = failure_time
|
||||||
|
self.state_manager.set_state_with_error(plugin_id, PluginState.ENABLED, error_info, error=err)
|
||||||
|
if self.health_tracker:
|
||||||
|
self.health_tracker.record_failure(plugin_id, err)
|
||||||
|
|
||||||
def run_scheduled_updates(self, current_time: Optional[float] = None) -> None:
|
def run_scheduled_updates(self, current_time: Optional[float] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Trigger plugin updates based on their defined update intervals.
|
Trigger plugin updates based on their defined update intervals.
|
||||||
@@ -734,16 +772,10 @@ class PluginManager:
|
|||||||
if self.health_tracker:
|
if self.health_tracker:
|
||||||
self.health_tracker.record_success(plugin_id)
|
self.health_tracker.record_success(plugin_id)
|
||||||
else:
|
else:
|
||||||
# Execution failed (timeout or error)
|
self._record_update_failure(plugin_id)
|
||||||
self.state_manager.set_state(plugin_id, PluginState.ERROR)
|
|
||||||
if self.health_tracker:
|
|
||||||
self.health_tracker.record_failure(plugin_id, Exception("Plugin execution failed"))
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=exc)
|
self._record_update_failure(plugin_id, exc=exc)
|
||||||
# Record failure
|
|
||||||
if self.health_tracker:
|
|
||||||
self.health_tracker.record_failure(plugin_id, exc)
|
|
||||||
|
|
||||||
def update_all_plugins(self) -> None:
|
def update_all_plugins(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -769,14 +801,12 @@ class PluginManager:
|
|||||||
if success:
|
if success:
|
||||||
self.plugin_last_update[plugin_id] = time.time()
|
self.plugin_last_update[plugin_id] = time.time()
|
||||||
self.state_manager.record_update(plugin_id)
|
self.state_manager.record_update(plugin_id)
|
||||||
# Update state back to ENABLED
|
|
||||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||||
else:
|
else:
|
||||||
# Execution failed
|
self._record_update_failure(plugin_id)
|
||||||
self.state_manager.set_state(plugin_id, PluginState.ERROR)
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=exc)
|
self._record_update_failure(plugin_id, exc=exc)
|
||||||
|
|
||||||
def get_plugin_health_metrics(self) -> Dict[str, Any]:
|
def get_plugin_health_metrics(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Manages plugin state machine (loaded → enabled → running → error)
|
|||||||
with state transitions and queries.
|
with state transitions and queries.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -34,6 +35,7 @@ class PluginStateManager:
|
|||||||
logger: Optional logger instance
|
logger: Optional logger instance
|
||||||
"""
|
"""
|
||||||
self.logger = logger or get_logger(__name__)
|
self.logger = logger or get_logger(__name__)
|
||||||
|
self._lock = threading.RLock()
|
||||||
self._states: Dict[str, PluginState] = {}
|
self._states: Dict[str, PluginState] = {}
|
||||||
self._state_history: Dict[str, list] = {}
|
self._state_history: Dict[str, list] = {}
|
||||||
self._error_info: Dict[str, Dict[str, Any]] = {}
|
self._error_info: Dict[str, Dict[str, Any]] = {}
|
||||||
@@ -48,44 +50,44 @@ class PluginStateManager:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Set plugin state and record transition.
|
Set plugin state and record transition.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
state: New state
|
state: New state
|
||||||
error: Optional error if transitioning to ERROR state
|
error: Optional error if transitioning to ERROR state
|
||||||
"""
|
"""
|
||||||
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
with self._lock:
|
||||||
self._states[plugin_id] = state
|
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||||
|
self._states[plugin_id] = state
|
||||||
# Record state transition
|
|
||||||
if plugin_id not in self._state_history:
|
if plugin_id not in self._state_history:
|
||||||
self._state_history[plugin_id] = []
|
self._state_history[plugin_id] = []
|
||||||
|
|
||||||
transition = {
|
transition = {
|
||||||
'timestamp': datetime.now(),
|
'timestamp': datetime.now(),
|
||||||
'from': old_state.value,
|
'from': old_state.value,
|
||||||
'to': state.value,
|
'to': state.value,
|
||||||
'error': str(error) if error else None
|
'error': str(error) if error else None
|
||||||
}
|
|
||||||
self._state_history[plugin_id].append(transition)
|
|
||||||
|
|
||||||
# Store error info if transitioning to ERROR state
|
|
||||||
if state == PluginState.ERROR and error:
|
|
||||||
self._error_info[plugin_id] = {
|
|
||||||
'error': str(error),
|
|
||||||
'error_type': type(error).__name__,
|
|
||||||
'timestamp': datetime.now()
|
|
||||||
}
|
}
|
||||||
elif state != PluginState.ERROR:
|
self._state_history[plugin_id].append(transition)
|
||||||
# Clear error info when leaving ERROR state
|
|
||||||
self._error_info.pop(plugin_id, None)
|
# Store error info if transitioning to ERROR state
|
||||||
|
if state == PluginState.ERROR and error:
|
||||||
self.logger.debug(
|
self._error_info[plugin_id] = {
|
||||||
"Plugin %s state transition: %s → %s",
|
'error': str(error),
|
||||||
plugin_id,
|
'error_type': type(error).__name__,
|
||||||
old_state.value,
|
'timestamp': datetime.now()
|
||||||
state.value
|
}
|
||||||
)
|
elif state != PluginState.ERROR:
|
||||||
|
# Clear error info when leaving ERROR state
|
||||||
|
self._error_info.pop(plugin_id, None)
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
"Plugin %s state transition: %s → %s",
|
||||||
|
plugin_id,
|
||||||
|
old_state.value,
|
||||||
|
state.value
|
||||||
|
)
|
||||||
|
|
||||||
def get_state(self, plugin_id: str) -> PluginState:
|
def get_state(self, plugin_id: str) -> PluginState:
|
||||||
"""
|
"""
|
||||||
@@ -136,17 +138,82 @@ class PluginStateManager:
|
|||||||
"""
|
"""
|
||||||
return self._state_history.get(plugin_id, [])
|
return self._state_history.get(plugin_id, [])
|
||||||
|
|
||||||
def get_error_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
def set_error_info(self, plugin_id: str, error_info: Dict[str, Any]) -> None:
|
||||||
"""
|
"""
|
||||||
Get error information for a plugin in ERROR state.
|
Persist structured error context without changing plugin state.
|
||||||
|
|
||||||
|
Used for recoverable failures (e.g. update timeout) where the plugin
|
||||||
|
stays ENABLED but the error details should remain queryable.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
|
error_info: Arbitrary dict describing the error
|
||||||
Returns:
|
|
||||||
Error information dict or None
|
|
||||||
"""
|
"""
|
||||||
return self._error_info.get(plugin_id)
|
with self._lock:
|
||||||
|
self._error_info[plugin_id] = dict(error_info)
|
||||||
|
|
||||||
|
def set_state_with_error(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
state: PluginState,
|
||||||
|
error_info: Dict[str, Any],
|
||||||
|
error: Optional[Exception] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Set plugin state and persist error context atomically.
|
||||||
|
|
||||||
|
Unlike calling set_state() then set_error_info() separately, this
|
||||||
|
method holds ``_lock`` for both writes so no reader can observe the
|
||||||
|
new state without the accompanying error context.
|
||||||
|
|
||||||
|
Intentionally does not clear ``_error_info`` the way set_state() does
|
||||||
|
for non-ERROR transitions — this is the recoverable-failure path where
|
||||||
|
the error dict is the entire point.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: Plugin identifier
|
||||||
|
state: New state
|
||||||
|
error_info: Structured error dict to persist alongside the state
|
||||||
|
error: Optional exception recorded in the transition history
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||||
|
self._states[plugin_id] = state
|
||||||
|
|
||||||
|
if plugin_id not in self._state_history:
|
||||||
|
self._state_history[plugin_id] = []
|
||||||
|
self._state_history[plugin_id].append({
|
||||||
|
'timestamp': datetime.now(),
|
||||||
|
'from': old_state.value,
|
||||||
|
'to': state.value,
|
||||||
|
'error': str(error) if error else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
self._error_info[plugin_id] = dict(error_info)
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
"Plugin %s state transition: %s → %s (recoverable error stored)",
|
||||||
|
plugin_id,
|
||||||
|
old_state.value,
|
||||||
|
state.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_error_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get error information for a plugin.
|
||||||
|
|
||||||
|
Returns the stored error dict whether the plugin is in ERROR state or
|
||||||
|
still ENABLED after a recoverable failure. Returns a shallow copy so
|
||||||
|
callers cannot mutate the stored snapshot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: Plugin identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Copy of the error information dict, or None
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
info = self._error_info.get(plugin_id)
|
||||||
|
return dict(info) if info is not None else None
|
||||||
|
|
||||||
def record_update(self, plugin_id: str) -> None:
|
def record_update(self, plugin_id: str) -> None:
|
||||||
"""Record that plugin update() was called."""
|
"""Record that plugin update() was called."""
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Detects and fixes inconsistencies between:
|
|||||||
- State manager state
|
- State manager state
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional, Set
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -86,16 +86,38 @@ class StateReconciliation:
|
|||||||
self.plugins_dir = Path(plugins_dir)
|
self.plugins_dir = Path(plugins_dir)
|
||||||
self.store_manager = store_manager
|
self.store_manager = store_manager
|
||||||
self.logger = get_logger(__name__)
|
self.logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Plugin IDs that failed auto-repair and should NOT be retried this
|
||||||
|
# process lifetime. Prevents the infinite "attempt to reinstall missing
|
||||||
|
# plugin" loop when a config entry references a plugin that isn't in
|
||||||
|
# the registry (e.g. legacy 'github', 'youtube' entries). A process
|
||||||
|
# restart — or an explicit user-initiated reconcile with force=True —
|
||||||
|
# clears this so recovery is possible after the underlying issue is
|
||||||
|
# fixed.
|
||||||
|
self._unrecoverable_missing_on_disk: Set[str] = set()
|
||||||
|
|
||||||
def reconcile_state(self) -> ReconciliationResult:
|
def reconcile_state(self, force: bool = False) -> ReconciliationResult:
|
||||||
"""
|
"""
|
||||||
Perform state reconciliation.
|
Perform state reconciliation.
|
||||||
|
|
||||||
Compares state from all sources and fixes safe inconsistencies.
|
Compares state from all sources and fixes safe inconsistencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force: If True, clear the unrecoverable-plugin cache before
|
||||||
|
reconciling so previously-failed auto-repairs are retried.
|
||||||
|
Intended for user-initiated reconcile requests after the
|
||||||
|
underlying issue (e.g. registry update) has been fixed.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ReconciliationResult with findings and fixes
|
ReconciliationResult with findings and fixes
|
||||||
"""
|
"""
|
||||||
|
if force and self._unrecoverable_missing_on_disk:
|
||||||
|
self.logger.info(
|
||||||
|
"Force reconcile requested; clearing %d cached unrecoverable plugin(s)",
|
||||||
|
len(self._unrecoverable_missing_on_disk),
|
||||||
|
)
|
||||||
|
self._unrecoverable_missing_on_disk.clear()
|
||||||
|
|
||||||
self.logger.info("Starting state reconciliation")
|
self.logger.info("Starting state reconciliation")
|
||||||
|
|
||||||
inconsistencies = []
|
inconsistencies = []
|
||||||
@@ -280,7 +302,26 @@ class StateReconciliation:
|
|||||||
|
|
||||||
# Check: Plugin in config but not on disk
|
# Check: Plugin in config but not on disk
|
||||||
if config.get('exists_in_config') and not disk.get('exists_on_disk'):
|
if config.get('exists_in_config') and not disk.get('exists_on_disk'):
|
||||||
can_repair = self.store_manager is not None
|
# Skip plugins that previously failed auto-repair in this process.
|
||||||
|
# Re-attempting wastes CPU (network + git clone each request) and
|
||||||
|
# spams the logs with the same "Plugin not found in registry"
|
||||||
|
# error. The entry is still surfaced as MANUAL_FIX_REQUIRED so the
|
||||||
|
# UI can show it, but no auto-repair will run.
|
||||||
|
previously_unrecoverable = plugin_id in self._unrecoverable_missing_on_disk
|
||||||
|
# Also refuse to re-install a plugin that the user just uninstalled
|
||||||
|
# through the UI — prevents a race where the reconciler fires
|
||||||
|
# between file removal and config cleanup and resurrects the
|
||||||
|
# plugin the user just deleted.
|
||||||
|
recently_uninstalled = (
|
||||||
|
self.store_manager is not None
|
||||||
|
and hasattr(self.store_manager, 'was_recently_uninstalled')
|
||||||
|
and self.store_manager.was_recently_uninstalled(plugin_id)
|
||||||
|
)
|
||||||
|
can_repair = (
|
||||||
|
self.store_manager is not None
|
||||||
|
and not previously_unrecoverable
|
||||||
|
and not recently_uninstalled
|
||||||
|
)
|
||||||
inconsistencies.append(Inconsistency(
|
inconsistencies.append(Inconsistency(
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
inconsistency_type=InconsistencyType.PLUGIN_MISSING_ON_DISK,
|
inconsistency_type=InconsistencyType.PLUGIN_MISSING_ON_DISK,
|
||||||
@@ -342,7 +383,13 @@ class StateReconciliation:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _auto_repair_missing_plugin(self, plugin_id: str) -> bool:
|
def _auto_repair_missing_plugin(self, plugin_id: str) -> bool:
|
||||||
"""Attempt to reinstall a missing plugin from the store."""
|
"""Attempt to reinstall a missing plugin from the store.
|
||||||
|
|
||||||
|
On failure, records plugin_id in ``_unrecoverable_missing_on_disk`` so
|
||||||
|
subsequent reconciliation passes within this process do not retry and
|
||||||
|
spam the log / CPU. A process restart (or an explicit ``force=True``
|
||||||
|
reconcile) is required to clear the cache.
|
||||||
|
"""
|
||||||
if not self.store_manager:
|
if not self.store_manager:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -351,6 +398,43 @@ class StateReconciliation:
|
|||||||
if plugin_id.startswith('ledmatrix-'):
|
if plugin_id.startswith('ledmatrix-'):
|
||||||
candidates.append(plugin_id[len('ledmatrix-'):])
|
candidates.append(plugin_id[len('ledmatrix-'):])
|
||||||
|
|
||||||
|
# Cheap pre-check: is any candidate actually present in the registry
|
||||||
|
# at all? If not, we know up-front this is unrecoverable and can skip
|
||||||
|
# the expensive install_plugin path (which does a forced GitHub fetch
|
||||||
|
# before failing).
|
||||||
|
#
|
||||||
|
# IMPORTANT: we must pass raise_on_failure=True here. The default
|
||||||
|
# fetch_registry() silently falls back to a stale cache or an empty
|
||||||
|
# dict on network failure, which would make it impossible to tell
|
||||||
|
# "plugin genuinely not in registry" from "I can't reach the
|
||||||
|
# registry right now" — in the second case we'd end up poisoning
|
||||||
|
# _unrecoverable_missing_on_disk with every config entry on a fresh
|
||||||
|
# boot with no cache.
|
||||||
|
registry_has_candidate = False
|
||||||
|
try:
|
||||||
|
registry = self.store_manager.fetch_registry(raise_on_failure=True)
|
||||||
|
registry_ids = {
|
||||||
|
p.get('id') for p in (registry.get('plugins', []) or []) if p.get('id')
|
||||||
|
}
|
||||||
|
registry_has_candidate = any(c in registry_ids for c in candidates)
|
||||||
|
except Exception as e:
|
||||||
|
# If we can't reach the registry, treat this as transient — don't
|
||||||
|
# mark unrecoverable, let the next pass try again.
|
||||||
|
self.logger.warning(
|
||||||
|
"[AutoRepair] Could not read registry to check %s: %s", plugin_id, e
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not registry_has_candidate:
|
||||||
|
self.logger.warning(
|
||||||
|
"[AutoRepair] %s not present in registry; marking unrecoverable "
|
||||||
|
"(will not retry this session). Reinstall from the Plugin Store "
|
||||||
|
"or remove the stale config entry to clear this warning.",
|
||||||
|
plugin_id,
|
||||||
|
)
|
||||||
|
self._unrecoverable_missing_on_disk.add(plugin_id)
|
||||||
|
return False
|
||||||
|
|
||||||
for candidate_id in candidates:
|
for candidate_id in candidates:
|
||||||
try:
|
try:
|
||||||
self.logger.info("[AutoRepair] Attempting to reinstall missing plugin: %s", candidate_id)
|
self.logger.info("[AutoRepair] Attempting to reinstall missing plugin: %s", candidate_id)
|
||||||
@@ -366,6 +450,11 @@ class StateReconciliation:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error("[AutoRepair] Error reinstalling %s: %s", candidate_id, e, exc_info=True)
|
self.logger.error("[AutoRepair] Error reinstalling %s: %s", candidate_id, e, exc_info=True)
|
||||||
|
|
||||||
self.logger.warning("[AutoRepair] Could not reinstall %s from store", plugin_id)
|
self.logger.warning(
|
||||||
|
"[AutoRepair] Could not reinstall %s from store; marking unrecoverable "
|
||||||
|
"(will not retry this session).",
|
||||||
|
plugin_id,
|
||||||
|
)
|
||||||
|
self._unrecoverable_missing_on_disk.add(plugin_id)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ import zipfile
|
|||||||
import tempfile
|
import tempfile
|
||||||
import requests
|
import requests
|
||||||
import time
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional, Any
|
from typing import List, Dict, Optional, Any, Tuple
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from src.common.permission_utils import sudo_remove_directory
|
from src.common.permission_utils import sudo_remove_directory
|
||||||
@@ -52,19 +53,89 @@ class PluginStoreManager:
|
|||||||
self.registry_cache = None
|
self.registry_cache = None
|
||||||
self.registry_cache_time = None # Timestamp of when registry was cached
|
self.registry_cache_time = None # Timestamp of when registry was cached
|
||||||
self.github_cache = {} # Cache for GitHub API responses
|
self.github_cache = {} # Cache for GitHub API responses
|
||||||
self.cache_timeout = 3600 # 1 hour cache timeout
|
self.cache_timeout = 3600 # 1 hour cache timeout (repo info: stars, default_branch)
|
||||||
self.registry_cache_timeout = 300 # 5 minutes for registry cache
|
# 15 minutes for registry cache. Long enough that the plugin list
|
||||||
|
# endpoint on a warm cache never hits the network, short enough that
|
||||||
|
# new plugins show up within a reasonable window. See also the
|
||||||
|
# stale-cache fallback in fetch_registry for transient network
|
||||||
|
# failures.
|
||||||
|
self.registry_cache_timeout = 900
|
||||||
self.commit_info_cache = {} # Cache for latest commit info: {key: (timestamp, data)}
|
self.commit_info_cache = {} # Cache for latest commit info: {key: (timestamp, data)}
|
||||||
self.commit_cache_timeout = 300 # 5 minutes (same as registry)
|
# 30 minutes for commit/manifest caches. Plugin Store users browse
|
||||||
|
# the catalog via /plugins/store/list which fetches commit info and
|
||||||
|
# manifest data per plugin. 5-min TTLs meant every fresh browse on
|
||||||
|
# a Pi4 paid for ~3 HTTP requests x N plugins (30-60s serial). 30
|
||||||
|
# minutes keeps the cache warm across a realistic session while
|
||||||
|
# still picking up upstream updates within a reasonable window.
|
||||||
|
self.commit_cache_timeout = 1800
|
||||||
self.manifest_cache = {} # Cache for GitHub manifest fetches: {key: (timestamp, data)}
|
self.manifest_cache = {} # Cache for GitHub manifest fetches: {key: (timestamp, data)}
|
||||||
self.manifest_cache_timeout = 300 # 5 minutes
|
self.manifest_cache_timeout = 1800
|
||||||
self.github_token = self._load_github_token()
|
self.github_token = self._load_github_token()
|
||||||
self._token_validation_cache = {} # Cache for token validation results: {token: (is_valid, timestamp, error_message)}
|
self._token_validation_cache = {} # Cache for token validation results: {token: (is_valid, timestamp, error_message)}
|
||||||
self._token_validation_cache_timeout = 300 # 5 minutes cache for token validation
|
self._token_validation_cache_timeout = 300 # 5 minutes cache for token validation
|
||||||
|
|
||||||
|
# Per-plugin tombstone timestamps for plugins that were uninstalled
|
||||||
|
# recently via the UI. Used by the state reconciler to avoid
|
||||||
|
# resurrecting a plugin the user just deleted when reconciliation
|
||||||
|
# races against the uninstall operation. Cleared after ``_uninstall_tombstone_ttl``.
|
||||||
|
self._uninstall_tombstones: Dict[str, float] = {}
|
||||||
|
self._uninstall_tombstone_ttl = 300 # 5 minutes
|
||||||
|
|
||||||
|
# Cache for _get_local_git_info: {plugin_path_str: (signature, data)}
|
||||||
|
# where ``signature`` is a tuple of (head_mtime, resolved_ref_mtime,
|
||||||
|
# head_contents) so a fast-forward update to the current branch
|
||||||
|
# (which touches .git/refs/heads/<branch> but NOT .git/HEAD) still
|
||||||
|
# invalidates the cache. Before this cache, every
|
||||||
|
# /plugins/installed request fired 4 git subprocesses per plugin,
|
||||||
|
# which pegged the CPU on a Pi4 with a dozen plugins. The cached
|
||||||
|
# ``data`` dict is the same shape returned by ``_get_local_git_info``
|
||||||
|
# itself (sha / short_sha / branch / optional remote_url, date_iso,
|
||||||
|
# date) — all string-keyed strings.
|
||||||
|
self._git_info_cache: Dict[str, Tuple[Tuple, Dict[str, str]]] = {}
|
||||||
|
|
||||||
|
# How long to wait before re-attempting a failed GitHub metadata
|
||||||
|
# fetch after we've already served a stale cache hit. Without this,
|
||||||
|
# a single expired-TTL + network-error would cause every subsequent
|
||||||
|
# request to re-hit the network (and fail again) until the network
|
||||||
|
# actually came back — amplifying the failure and blocking request
|
||||||
|
# handlers. Bumping the cached-entry timestamp on failure serves
|
||||||
|
# the stale payload cheaply until the backoff expires.
|
||||||
|
self._failure_backoff_seconds = 60
|
||||||
|
|
||||||
# Ensure plugins directory exists
|
# Ensure plugins directory exists
|
||||||
self.plugins_dir.mkdir(exist_ok=True)
|
self.plugins_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
def _record_cache_backoff(self, cache_dict: Dict, cache_key: str,
|
||||||
|
cache_timeout: int, payload: Any) -> None:
|
||||||
|
"""Bump a cache entry's timestamp so subsequent lookups hit the
|
||||||
|
cache rather than re-failing over the network.
|
||||||
|
|
||||||
|
Used by the stale-on-error fallbacks in the GitHub metadata fetch
|
||||||
|
paths. Without this, a cache entry whose TTL just expired would
|
||||||
|
cause every subsequent request to re-hit the network and fail
|
||||||
|
again until the network actually came back. We write a synthetic
|
||||||
|
timestamp ``(now + backoff - cache_timeout)`` so the cache-valid
|
||||||
|
check ``(now - ts) < cache_timeout`` succeeds for another
|
||||||
|
``backoff`` seconds.
|
||||||
|
"""
|
||||||
|
synthetic_ts = time.time() + self._failure_backoff_seconds - cache_timeout
|
||||||
|
cache_dict[cache_key] = (synthetic_ts, payload)
|
||||||
|
|
||||||
|
def mark_recently_uninstalled(self, plugin_id: str) -> None:
|
||||||
|
"""Record that ``plugin_id`` was just uninstalled by the user."""
|
||||||
|
self._uninstall_tombstones[plugin_id] = time.time()
|
||||||
|
|
||||||
|
def was_recently_uninstalled(self, plugin_id: str) -> bool:
|
||||||
|
"""Return True if ``plugin_id`` has an active uninstall tombstone."""
|
||||||
|
ts = self._uninstall_tombstones.get(plugin_id)
|
||||||
|
if ts is None:
|
||||||
|
return False
|
||||||
|
if time.time() - ts > self._uninstall_tombstone_ttl:
|
||||||
|
# Expired — clean up so the dict doesn't grow unbounded.
|
||||||
|
self._uninstall_tombstones.pop(plugin_id, None)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def _load_github_token(self) -> Optional[str]:
|
def _load_github_token(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Load GitHub API token from config_secrets.json if available.
|
Load GitHub API token from config_secrets.json if available.
|
||||||
@@ -308,7 +379,25 @@ class PluginStoreManager:
|
|||||||
if self.github_token:
|
if self.github_token:
|
||||||
headers['Authorization'] = f'token {self.github_token}'
|
headers['Authorization'] = f'token {self.github_token}'
|
||||||
|
|
||||||
response = requests.get(api_url, headers=headers, timeout=10)
|
try:
|
||||||
|
response = requests.get(api_url, headers=headers, timeout=10)
|
||||||
|
except requests.RequestException as req_err:
|
||||||
|
# Network error: prefer a stale cache hit over an
|
||||||
|
# empty default so the UI keeps working on a flaky
|
||||||
|
# Pi WiFi link. Bump the cached entry's timestamp
|
||||||
|
# into a short backoff window so subsequent
|
||||||
|
# requests serve the stale payload cheaply instead
|
||||||
|
# of re-hitting the network on every request.
|
||||||
|
if cache_key in self.github_cache:
|
||||||
|
_, stale = self.github_cache[cache_key]
|
||||||
|
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||||
|
self.logger.warning(
|
||||||
|
"GitHub repo info fetch failed for %s (%s); serving stale cache.",
|
||||||
|
cache_key, req_err,
|
||||||
|
)
|
||||||
|
return stale
|
||||||
|
raise
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
pushed_at = data.get('pushed_at', '') or data.get('updated_at', '')
|
pushed_at = data.get('pushed_at', '') or data.get('updated_at', '')
|
||||||
@@ -328,7 +417,20 @@ class PluginStoreManager:
|
|||||||
self.github_cache[cache_key] = (time.time(), repo_info)
|
self.github_cache[cache_key] = (time.time(), repo_info)
|
||||||
return repo_info
|
return repo_info
|
||||||
elif response.status_code == 403:
|
elif response.status_code == 403:
|
||||||
# Rate limit or authentication issue
|
# Rate limit or authentication issue. If we have a
|
||||||
|
# previously-cached value, serve it rather than
|
||||||
|
# returning empty defaults — a stale star count is
|
||||||
|
# better than a reset to zero. Apply the same
|
||||||
|
# failure-backoff bump as the network-error path
|
||||||
|
# so we don't hammer the API with repeat requests
|
||||||
|
# while rate-limited.
|
||||||
|
if cache_key in self.github_cache:
|
||||||
|
_, stale = self.github_cache[cache_key]
|
||||||
|
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||||
|
self.logger.warning(
|
||||||
|
"GitHub API 403 for %s; serving stale cache.", cache_key,
|
||||||
|
)
|
||||||
|
return stale
|
||||||
if not self.github_token:
|
if not self.github_token:
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
f"GitHub API rate limit likely exceeded (403). "
|
f"GitHub API rate limit likely exceeded (403). "
|
||||||
@@ -342,6 +444,10 @@ class PluginStoreManager:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"GitHub API request failed: {response.status_code} for {api_url}")
|
self.logger.warning(f"GitHub API request failed: {response.status_code} for {api_url}")
|
||||||
|
if cache_key in self.github_cache:
|
||||||
|
_, stale = self.github_cache[cache_key]
|
||||||
|
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
|
||||||
|
return stale
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'stars': 0,
|
'stars': 0,
|
||||||
@@ -442,23 +548,34 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Error fetching registry from URL: {e}", exc_info=True)
|
self.logger.error(f"Error fetching registry from URL: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def fetch_registry(self, force_refresh: bool = False) -> Dict:
|
def fetch_registry(self, force_refresh: bool = False, raise_on_failure: bool = False) -> Dict:
|
||||||
"""
|
"""
|
||||||
Fetch the plugin registry from GitHub.
|
Fetch the plugin registry from GitHub.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
force_refresh: Force refresh even if cached
|
force_refresh: Force refresh even if cached
|
||||||
|
raise_on_failure: If True, re-raise network / JSON errors instead
|
||||||
|
of silently falling back to stale cache / empty dict. UI
|
||||||
|
callers prefer the stale-fallback default so the plugin
|
||||||
|
list keeps working on flaky WiFi; the state reconciler
|
||||||
|
needs the explicit failure signal so it can distinguish
|
||||||
|
"plugin genuinely not in registry" from "I couldn't reach
|
||||||
|
the registry at all" and not mark everything unrecoverable.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Registry data with list of available plugins
|
Registry data with list of available plugins
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
requests.RequestException / json.JSONDecodeError when
|
||||||
|
``raise_on_failure`` is True and the fetch fails.
|
||||||
"""
|
"""
|
||||||
# Check if cache is still valid (within timeout)
|
# Check if cache is still valid (within timeout)
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
if (self.registry_cache and self.registry_cache_time and
|
if (self.registry_cache and self.registry_cache_time and
|
||||||
not force_refresh and
|
not force_refresh and
|
||||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||||
return self.registry_cache
|
return self.registry_cache
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
||||||
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
||||||
@@ -469,9 +586,30 @@ class PluginStoreManager:
|
|||||||
return self.registry_cache
|
return self.registry_cache
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
self.logger.error(f"Error fetching registry: {e}")
|
self.logger.error(f"Error fetching registry: {e}")
|
||||||
|
if raise_on_failure:
|
||||||
|
raise
|
||||||
|
# Prefer stale cache over an empty list so the plugin list UI
|
||||||
|
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
|
||||||
|
# registry_cache_time into a short backoff window so the next
|
||||||
|
# request serves the stale payload cheaply instead of
|
||||||
|
# re-hitting the network on every request (matches the
|
||||||
|
# pattern used by github_cache / commit_info_cache).
|
||||||
|
if self.registry_cache:
|
||||||
|
self.logger.warning("Falling back to stale registry cache")
|
||||||
|
self.registry_cache_time = (
|
||||||
|
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||||
|
)
|
||||||
|
return self.registry_cache
|
||||||
return {"plugins": []}
|
return {"plugins": []}
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
self.logger.error(f"Error parsing registry JSON: {e}")
|
self.logger.error(f"Error parsing registry JSON: {e}")
|
||||||
|
if raise_on_failure:
|
||||||
|
raise
|
||||||
|
if self.registry_cache:
|
||||||
|
self.registry_cache_time = (
|
||||||
|
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||||
|
)
|
||||||
|
return self.registry_cache
|
||||||
return {"plugins": []}
|
return {"plugins": []}
|
||||||
|
|
||||||
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
|
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
|
||||||
@@ -517,68 +655,95 @@ class PluginStoreManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Failed to fetch plugins from saved repository {repo_url}: {e}")
|
self.logger.warning(f"Failed to fetch plugins from saved repository {repo_url}: {e}")
|
||||||
|
|
||||||
results = []
|
# First pass: apply cheap filters (category/tags/query) so we only
|
||||||
|
# fetch GitHub metadata for plugins that will actually be returned.
|
||||||
|
filtered: List[Dict] = []
|
||||||
for plugin in plugins:
|
for plugin in plugins:
|
||||||
# Category filter
|
|
||||||
if category and plugin.get('category') != category:
|
if category and plugin.get('category') != category:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Tags filter (match any tag)
|
|
||||||
if tags and not any(tag in plugin.get('tags', []) for tag in tags):
|
if tags and not any(tag in plugin.get('tags', []) for tag in tags):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Query search (case-insensitive)
|
|
||||||
if query:
|
if query:
|
||||||
query_lower = query.lower()
|
query_lower = query.lower()
|
||||||
searchable_text = ' '.join([
|
searchable_text = ' '.join([
|
||||||
plugin.get('name', ''),
|
plugin.get('name', ''),
|
||||||
plugin.get('description', ''),
|
plugin.get('description', ''),
|
||||||
plugin.get('id', ''),
|
plugin.get('id', ''),
|
||||||
plugin.get('author', '')
|
plugin.get('author', ''),
|
||||||
]).lower()
|
]).lower()
|
||||||
|
|
||||||
if query_lower not in searchable_text:
|
if query_lower not in searchable_text:
|
||||||
continue
|
continue
|
||||||
|
filtered.append(plugin)
|
||||||
|
|
||||||
# Enhance plugin data with GitHub metadata
|
def _enrich(plugin: Dict) -> Dict:
|
||||||
|
"""Enrich a single plugin with GitHub metadata.
|
||||||
|
|
||||||
|
Called concurrently from a ThreadPoolExecutor. Each underlying
|
||||||
|
HTTP helper (``_get_github_repo_info`` / ``_get_latest_commit_info``
|
||||||
|
/ ``_fetch_manifest_from_github``) is thread-safe — they use
|
||||||
|
``requests`` and write their own cache keys on Python dicts,
|
||||||
|
which is atomic under the GIL for single-key assignments.
|
||||||
|
"""
|
||||||
enhanced_plugin = plugin.copy()
|
enhanced_plugin = plugin.copy()
|
||||||
|
|
||||||
# Get real GitHub stars
|
|
||||||
repo_url = plugin.get('repo', '')
|
repo_url = plugin.get('repo', '')
|
||||||
if repo_url:
|
if not repo_url:
|
||||||
github_info = self._get_github_repo_info(repo_url)
|
return enhanced_plugin
|
||||||
enhanced_plugin['stars'] = github_info.get('stars', plugin.get('stars', 0))
|
|
||||||
enhanced_plugin['default_branch'] = github_info.get('default_branch', plugin.get('branch', 'main'))
|
|
||||||
enhanced_plugin['last_updated_iso'] = github_info.get('last_commit_iso')
|
|
||||||
enhanced_plugin['last_updated'] = github_info.get('last_commit_date')
|
|
||||||
|
|
||||||
if fetch_commit_info:
|
github_info = self._get_github_repo_info(repo_url)
|
||||||
branch = plugin.get('branch') or github_info.get('default_branch', 'main')
|
enhanced_plugin['stars'] = github_info.get('stars', plugin.get('stars', 0))
|
||||||
|
enhanced_plugin['default_branch'] = github_info.get('default_branch', plugin.get('branch', 'main'))
|
||||||
|
enhanced_plugin['last_updated_iso'] = github_info.get('last_commit_iso')
|
||||||
|
enhanced_plugin['last_updated'] = github_info.get('last_commit_date')
|
||||||
|
|
||||||
commit_info = self._get_latest_commit_info(repo_url, branch)
|
if fetch_commit_info:
|
||||||
if commit_info:
|
branch = plugin.get('branch') or github_info.get('default_branch', 'main')
|
||||||
enhanced_plugin['last_commit'] = commit_info.get('short_sha')
|
|
||||||
enhanced_plugin['last_commit_sha'] = commit_info.get('sha')
|
|
||||||
enhanced_plugin['last_updated'] = commit_info.get('date') or enhanced_plugin.get('last_updated')
|
|
||||||
enhanced_plugin['last_updated_iso'] = commit_info.get('date_iso') or enhanced_plugin.get('last_updated_iso')
|
|
||||||
enhanced_plugin['last_commit_message'] = commit_info.get('message')
|
|
||||||
enhanced_plugin['last_commit_author'] = commit_info.get('author')
|
|
||||||
enhanced_plugin['branch'] = commit_info.get('branch', branch)
|
|
||||||
enhanced_plugin['last_commit_branch'] = commit_info.get('branch')
|
|
||||||
|
|
||||||
# Fetch manifest from GitHub for additional metadata (description, etc.)
|
commit_info = self._get_latest_commit_info(repo_url, branch)
|
||||||
plugin_subpath = plugin.get('plugin_path', '')
|
if commit_info:
|
||||||
manifest_rel = f"{plugin_subpath}/manifest.json" if plugin_subpath else "manifest.json"
|
enhanced_plugin['last_commit'] = commit_info.get('short_sha')
|
||||||
github_manifest = self._fetch_manifest_from_github(repo_url, branch, manifest_rel)
|
enhanced_plugin['last_commit_sha'] = commit_info.get('sha')
|
||||||
if github_manifest:
|
enhanced_plugin['last_updated'] = commit_info.get('date') or enhanced_plugin.get('last_updated')
|
||||||
if 'last_updated' in github_manifest and not enhanced_plugin.get('last_updated'):
|
enhanced_plugin['last_updated_iso'] = commit_info.get('date_iso') or enhanced_plugin.get('last_updated_iso')
|
||||||
enhanced_plugin['last_updated'] = github_manifest['last_updated']
|
enhanced_plugin['last_commit_message'] = commit_info.get('message')
|
||||||
if 'description' in github_manifest:
|
enhanced_plugin['last_commit_author'] = commit_info.get('author')
|
||||||
enhanced_plugin['description'] = github_manifest['description']
|
enhanced_plugin['branch'] = commit_info.get('branch', branch)
|
||||||
|
enhanced_plugin['last_commit_branch'] = commit_info.get('branch')
|
||||||
|
|
||||||
results.append(enhanced_plugin)
|
# Intentionally NO per-plugin manifest.json fetch here.
|
||||||
|
# The registry's plugins.json already carries ``description``
|
||||||
|
# (it is generated from each plugin's manifest by
|
||||||
|
# ``update_registry.py``), and ``last_updated`` is filled in
|
||||||
|
# from the commit info above. An earlier implementation
|
||||||
|
# fetched manifest.json per plugin anyway, which meant one
|
||||||
|
# extra HTTPS round trip per result; on a Pi4 with a flaky
|
||||||
|
# WiFi link the tail retries of that one extra call
|
||||||
|
# (_http_get_with_retries does 3 attempts with exponential
|
||||||
|
# backoff) dominated wall time even after parallelization.
|
||||||
|
|
||||||
return results
|
return enhanced_plugin
|
||||||
|
|
||||||
|
# Fan out the per-plugin GitHub enrichment. The previous
|
||||||
|
# implementation did this serially, which on a Pi4 with ~15 plugins
|
||||||
|
# and a fresh cache meant 30+ HTTP requests in strict sequence (the
|
||||||
|
# "connecting to display" hang reported by users). With a thread
|
||||||
|
# pool, latency is dominated by the slowest request rather than
|
||||||
|
# their sum. Workers capped at 10 to stay well under the
|
||||||
|
# unauthenticated GitHub rate limit burst and avoid overwhelming a
|
||||||
|
# Pi's WiFi link. For a small number of plugins the pool is
|
||||||
|
# essentially free.
|
||||||
|
if not filtered:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Not worth the pool overhead for tiny workloads. Parenthesized to
|
||||||
|
# make Python's default ``and`` > ``or`` precedence explicit: a
|
||||||
|
# single plugin, OR a small batch where we don't need commit info.
|
||||||
|
if (len(filtered) == 1) or ((not fetch_commit_info) and (len(filtered) < 4)):
|
||||||
|
return [_enrich(p) for p in filtered]
|
||||||
|
|
||||||
|
max_workers = min(10, len(filtered))
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix='plugin-search') as executor:
|
||||||
|
# executor.map preserves input order, which the UI relies on.
|
||||||
|
return list(executor.map(_enrich, filtered))
|
||||||
|
|
||||||
def _fetch_manifest_from_github(self, repo_url: str, branch: str = "master", manifest_path: str = "manifest.json", force_refresh: bool = False) -> Optional[Dict]:
|
def _fetch_manifest_from_github(self, repo_url: str, branch: str = "master", manifest_path: str = "manifest.json", force_refresh: bool = False) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
@@ -676,7 +841,28 @@ class PluginStoreManager:
|
|||||||
last_error = None
|
last_error = None
|
||||||
for branch_name in branches_to_try:
|
for branch_name in branches_to_try:
|
||||||
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/{branch_name}"
|
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/{branch_name}"
|
||||||
response = requests.get(api_url, headers=headers, timeout=10)
|
try:
|
||||||
|
response = requests.get(api_url, headers=headers, timeout=10)
|
||||||
|
except requests.RequestException as req_err:
|
||||||
|
# Network failure: fall back to a stale cache hit if
|
||||||
|
# available so the plugin store UI keeps populating
|
||||||
|
# commit info on a flaky WiFi link. Bump the cached
|
||||||
|
# timestamp into the backoff window so we don't
|
||||||
|
# re-retry on every request.
|
||||||
|
if cache_key in self.commit_info_cache:
|
||||||
|
_, stale = self.commit_info_cache[cache_key]
|
||||||
|
if stale is not None:
|
||||||
|
self._record_cache_backoff(
|
||||||
|
self.commit_info_cache, cache_key,
|
||||||
|
self.commit_cache_timeout, stale,
|
||||||
|
)
|
||||||
|
self.logger.warning(
|
||||||
|
"GitHub commit fetch failed for %s (%s); serving stale cache.",
|
||||||
|
cache_key, req_err,
|
||||||
|
)
|
||||||
|
return stale
|
||||||
|
last_error = str(req_err)
|
||||||
|
continue
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
commit_data = response.json()
|
commit_data = response.json()
|
||||||
commit_sha_full = commit_data.get('sha', '')
|
commit_sha_full = commit_data.get('sha', '')
|
||||||
@@ -706,7 +892,23 @@ class PluginStoreManager:
|
|||||||
if last_error:
|
if last_error:
|
||||||
self.logger.debug(f"Unable to fetch commit info for {repo_url}: {last_error}")
|
self.logger.debug(f"Unable to fetch commit info for {repo_url}: {last_error}")
|
||||||
|
|
||||||
# Cache negative result to avoid repeated failing calls
|
# All branches returned a non-200 response (e.g. 404 on every
|
||||||
|
# candidate, or a transient 5xx). If we already had a good
|
||||||
|
# cached value, prefer serving that — overwriting it with
|
||||||
|
# None here would wipe out commit info the UI just showed
|
||||||
|
# on the previous request. Bump the timestamp into the
|
||||||
|
# backoff window so subsequent lookups hit the cache.
|
||||||
|
if cache_key in self.commit_info_cache:
|
||||||
|
_, prior = self.commit_info_cache[cache_key]
|
||||||
|
if prior is not None:
|
||||||
|
self._record_cache_backoff(
|
||||||
|
self.commit_info_cache, cache_key,
|
||||||
|
self.commit_cache_timeout, prior,
|
||||||
|
)
|
||||||
|
return prior
|
||||||
|
|
||||||
|
# No prior good value — cache the negative result so we don't
|
||||||
|
# hammer a plugin that genuinely has no reachable commits.
|
||||||
self.commit_info_cache[cache_key] = (time.time(), None)
|
self.commit_info_cache[cache_key] = (time.time(), None)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1560,12 +1762,93 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Unexpected error installing dependencies for {plugin_path.name}: {e}", exc_info=True)
|
self.logger.error(f"Unexpected error installing dependencies for {plugin_path.name}: {e}", exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _git_cache_signature(self, git_dir: Path) -> Optional[Tuple]:
|
||||||
|
"""Build a cache signature that invalidates on the kind of updates
|
||||||
|
a plugin user actually cares about.
|
||||||
|
|
||||||
|
Caching on ``.git/HEAD`` mtime alone is not enough: a ``git pull``
|
||||||
|
that fast-forwards the current branch updates
|
||||||
|
``.git/refs/heads/<branch>`` (or ``.git/packed-refs``) but leaves
|
||||||
|
HEAD's contents and mtime untouched. And the cached ``result``
|
||||||
|
dict includes ``remote_url`` — a value read from ``.git/config`` —
|
||||||
|
so a config-only change (e.g. a monorepo-migration re-pointing
|
||||||
|
``remote.origin.url``) must also invalidate the cache.
|
||||||
|
|
||||||
|
Signature components:
|
||||||
|
- HEAD contents (catches detach / branch switch)
|
||||||
|
- HEAD mtime
|
||||||
|
- if HEAD points at a ref, that ref file's mtime (catches
|
||||||
|
fast-forward / reset on the current branch)
|
||||||
|
- packed-refs mtime as a coarse fallback for repos using packed refs
|
||||||
|
- .git/config contents + mtime (catches remote URL changes and
|
||||||
|
any other config-only edit that affects what the cached
|
||||||
|
``remote_url`` field should contain)
|
||||||
|
|
||||||
|
Returns ``None`` if HEAD cannot be read at all (caller will skip
|
||||||
|
the cache and take the slow path).
|
||||||
|
"""
|
||||||
|
head_file = git_dir / 'HEAD'
|
||||||
|
try:
|
||||||
|
head_mtime = head_file.stat().st_mtime
|
||||||
|
head_contents = head_file.read_text(encoding='utf-8', errors='replace').strip()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ref_mtime = None
|
||||||
|
if head_contents.startswith('ref: '):
|
||||||
|
ref_path = head_contents[len('ref: '):].strip()
|
||||||
|
# ``ref_path`` looks like ``refs/heads/main``. It lives either
|
||||||
|
# as a loose file under .git/ or inside .git/packed-refs.
|
||||||
|
loose_ref = git_dir / ref_path
|
||||||
|
try:
|
||||||
|
ref_mtime = loose_ref.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
ref_mtime = None
|
||||||
|
|
||||||
|
packed_refs_mtime = None
|
||||||
|
if ref_mtime is None:
|
||||||
|
try:
|
||||||
|
packed_refs_mtime = (git_dir / 'packed-refs').stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
packed_refs_mtime = None
|
||||||
|
|
||||||
|
config_mtime = None
|
||||||
|
config_contents = None
|
||||||
|
config_file = git_dir / 'config'
|
||||||
|
try:
|
||||||
|
config_mtime = config_file.stat().st_mtime
|
||||||
|
config_contents = config_file.read_text(encoding='utf-8', errors='replace').strip()
|
||||||
|
except OSError:
|
||||||
|
config_mtime = None
|
||||||
|
config_contents = None
|
||||||
|
|
||||||
|
return (
|
||||||
|
head_contents, head_mtime,
|
||||||
|
ref_mtime, packed_refs_mtime,
|
||||||
|
config_contents, config_mtime,
|
||||||
|
)
|
||||||
|
|
||||||
def _get_local_git_info(self, plugin_path: Path) -> Optional[Dict[str, str]]:
|
def _get_local_git_info(self, plugin_path: Path) -> Optional[Dict[str, str]]:
|
||||||
"""Return local git branch, commit hash, and commit date if the plugin is a git checkout."""
|
"""Return local git branch, commit hash, and commit date if the plugin is a git checkout.
|
||||||
|
|
||||||
|
Results are cached keyed on a signature that includes HEAD
|
||||||
|
contents plus the mtime of HEAD AND the resolved ref (or
|
||||||
|
packed-refs). Repeated calls skip the four ``git`` subprocesses
|
||||||
|
when nothing has changed, and a ``git pull`` that fast-forwards
|
||||||
|
the branch correctly invalidates the cache.
|
||||||
|
"""
|
||||||
git_dir = plugin_path / '.git'
|
git_dir = plugin_path / '.git'
|
||||||
if not git_dir.exists():
|
if not git_dir.exists():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
cache_key = str(plugin_path)
|
||||||
|
signature = self._git_cache_signature(git_dir)
|
||||||
|
|
||||||
|
if signature is not None:
|
||||||
|
cached = self._git_info_cache.get(cache_key)
|
||||||
|
if cached is not None and cached[0] == signature:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sha_result = subprocess.run(
|
sha_result = subprocess.run(
|
||||||
['git', '-C', str(plugin_path), 'rev-parse', 'HEAD'],
|
['git', '-C', str(plugin_path), 'rev-parse', 'HEAD'],
|
||||||
@@ -1623,6 +1906,8 @@ class PluginStoreManager:
|
|||||||
result['date_iso'] = commit_date_iso
|
result['date_iso'] = commit_date_iso
|
||||||
result['date'] = self._iso_to_date(commit_date_iso)
|
result['date'] = self._iso_to_date(commit_date_iso)
|
||||||
|
|
||||||
|
if signature is not None:
|
||||||
|
self._git_info_cache[cache_key] = (signature, result)
|
||||||
return result
|
return result
|
||||||
except subprocess.CalledProcessError as err:
|
except subprocess.CalledProcessError as err:
|
||||||
self.logger.debug(f"Failed to read git info for {plugin_path.name}: {err}")
|
self.logger.debug(f"Failed to read git info for {plugin_path.name}: {err}")
|
||||||
|
|||||||
@@ -60,12 +60,16 @@ def get_wifi_config_path():
|
|||||||
|
|
||||||
HOSTAPD_CONFIG_PATH = Path("/etc/hostapd/hostapd.conf")
|
HOSTAPD_CONFIG_PATH = Path("/etc/hostapd/hostapd.conf")
|
||||||
DNSMASQ_CONFIG_PATH = Path("/etc/dnsmasq.d/ledmatrix-captive.conf")
|
DNSMASQ_CONFIG_PATH = Path("/etc/dnsmasq.d/ledmatrix-captive.conf")
|
||||||
|
# Drop-in config for NetworkManager's built-in dnsmasq (ipv4.method=shared).
|
||||||
|
# Writing address=/#/<ap_ip> here causes NM to resolve every hostname to the AP,
|
||||||
|
# triggering the OS captive-portal popup automatically on iOS/Android/Windows/macOS.
|
||||||
|
NM_DNSMASQ_SHARED_DIR = Path("/etc/NetworkManager/dnsmasq-shared.d")
|
||||||
|
NM_DNSMASQ_SHARED_CONF = NM_DNSMASQ_SHARED_DIR / "ledmatrix-captive.conf"
|
||||||
HOSTAPD_SERVICE = "hostapd"
|
HOSTAPD_SERVICE = "hostapd"
|
||||||
DNSMASQ_SERVICE = "dnsmasq"
|
DNSMASQ_SERVICE = "dnsmasq"
|
||||||
|
|
||||||
# Default AP settings
|
# Default AP settings
|
||||||
DEFAULT_AP_SSID = "LEDMatrix-Setup"
|
DEFAULT_AP_SSID = "LEDMatrix-Setup"
|
||||||
DEFAULT_AP_PASSWORD = "ledmatrix123"
|
|
||||||
DEFAULT_AP_CHANNEL = 7
|
DEFAULT_AP_CHANNEL = 7
|
||||||
|
|
||||||
# LED status message file (for display_controller integration)
|
# LED status message file (for display_controller integration)
|
||||||
@@ -138,6 +142,11 @@ class WiFiManager:
|
|||||||
self._disconnected_checks = 0
|
self._disconnected_checks = 0
|
||||||
self._disconnected_checks_required = 3 # Require 3 consecutive disconnected checks (90 seconds at 30s interval)
|
self._disconnected_checks_required = 3 # Require 3 consecutive disconnected checks (90 seconds at 30s interval)
|
||||||
|
|
||||||
|
# Timestamp set when AP mode is enabled; used for the idle-timeout check
|
||||||
|
self._ap_enabled_at: Optional[float] = None
|
||||||
|
# Which redirect backend was used (iptables/nftables/None); set per-instance
|
||||||
|
self._redirect_backend: Optional[str] = None
|
||||||
|
|
||||||
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
|
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
|
||||||
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
||||||
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
|
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
|
||||||
@@ -201,6 +210,24 @@ class WiFiManager:
|
|||||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _find_command_path(self, command: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Return the absolute path of a command, checking sbin locations that may not
|
||||||
|
be on PATH in restricted service environments. Returns None if not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["which", command], capture_output=True,
|
||||||
|
text=True, timeout=2)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
||||||
|
pass
|
||||||
|
for path in [f"/usr/sbin/{command}", f"/sbin/{command}",
|
||||||
|
f"/usr/local/sbin/{command}"]:
|
||||||
|
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
def _discover_wifi_interface(self) -> str:
|
def _discover_wifi_interface(self) -> str:
|
||||||
"""
|
"""
|
||||||
Discover the primary WiFi interface name dynamically.
|
Discover the primary WiFi interface name dynamically.
|
||||||
@@ -303,7 +330,6 @@ class WiFiManager:
|
|||||||
else:
|
else:
|
||||||
self.config = {
|
self.config = {
|
||||||
"ap_ssid": DEFAULT_AP_SSID,
|
"ap_ssid": DEFAULT_AP_SSID,
|
||||||
"ap_password": DEFAULT_AP_PASSWORD,
|
|
||||||
"ap_channel": DEFAULT_AP_CHANNEL,
|
"ap_channel": DEFAULT_AP_CHANNEL,
|
||||||
"auto_enable_ap_mode": True, # Default: auto-enable when no network (safe due to grace period)
|
"auto_enable_ap_mode": True, # Default: auto-enable when no network (safe due to grace period)
|
||||||
"saved_networks": []
|
"saved_networks": []
|
||||||
@@ -658,7 +684,286 @@ class WiFiManager:
|
|||||||
return False
|
return False
|
||||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved")
|
||||||
|
|
||||||
|
def _validate_ap_config(self) -> Tuple[str, int]:
|
||||||
|
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
|
||||||
|
ssid = str(self.config.get("ap_ssid", DEFAULT_AP_SSID))
|
||||||
|
if not ssid or len(ssid) > 32 or not re.match(r'^[\x20-\x7E]+$', ssid):
|
||||||
|
logger.warning(f"AP SSID '{ssid}' is invalid, falling back to default")
|
||||||
|
ssid = DEFAULT_AP_SSID
|
||||||
|
try:
|
||||||
|
channel = int(self.config.get("ap_channel", DEFAULT_AP_CHANNEL))
|
||||||
|
if channel < 1 or channel > 14:
|
||||||
|
raise ValueError
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning("AP channel out of range, falling back to default")
|
||||||
|
channel = DEFAULT_AP_CHANNEL
|
||||||
|
return ssid, channel
|
||||||
|
|
||||||
|
def _setup_iptables_redirect(self) -> bool:
|
||||||
|
"""
|
||||||
|
Add port 80 → 5000 redirect rules for the captive portal.
|
||||||
|
|
||||||
|
Tries iptables first, falls back to nftables (used by Debian Trixie).
|
||||||
|
When neither tool is available, logs a warning and returns True — the AP
|
||||||
|
still works and DNS spoofing still triggers the OS popup; users just land
|
||||||
|
on port 5000 directly rather than being redirected from port 80.
|
||||||
|
|
||||||
|
Only returns False when a tool was found but the rule addition itself failed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
iptables = self._find_command_path("iptables")
|
||||||
|
nft = self._find_command_path("nft")
|
||||||
|
|
||||||
|
if not iptables and not nft:
|
||||||
|
logger.warning(
|
||||||
|
"Neither iptables nor nft found; captive portal port-80 redirect unavailable. "
|
||||||
|
"DNS spoofing will still trigger the OS popup but HTTP on port 80 won't reach Flask."
|
||||||
|
)
|
||||||
|
self._redirect_backend = None
|
||||||
|
return True # AP works; redirect is best-effort
|
||||||
|
|
||||||
|
if iptables:
|
||||||
|
return self._setup_iptables_redirect_iptables(iptables)
|
||||||
|
else:
|
||||||
|
return self._setup_iptables_redirect_nftables(nft)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not set up port redirect: {e}")
|
||||||
|
try:
|
||||||
|
self._teardown_iptables_redirect()
|
||||||
|
except Exception as cleanup_e:
|
||||||
|
logger.warning(f"Cleanup after redirect exception also failed: {cleanup_e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _setup_iptables_redirect_iptables(self, iptables: str) -> bool:
|
||||||
|
"""Set up port 80→5000 redirect using iptables."""
|
||||||
|
# Save ip_forward state before enabling
|
||||||
|
try:
|
||||||
|
current_fwd = Path("/proc/sys/net/ipv4/ip_forward").read_text().strip()
|
||||||
|
except OSError:
|
||||||
|
current_fwd = None
|
||||||
|
if current_fwd is not None:
|
||||||
|
try:
|
||||||
|
self._IP_FORWARD_SAVE_PATH.write_text(current_fwd)
|
||||||
|
except OSError:
|
||||||
|
current_fwd = None
|
||||||
|
logger.warning("Could not write ip_forward save file; state will not be restored")
|
||||||
|
|
||||||
|
if current_fwd != "1":
|
||||||
|
sysctl = self._find_command_path("sysctl")
|
||||||
|
sysctl_bin = sysctl if sysctl else "sysctl"
|
||||||
|
r = subprocess.run(["sudo", sysctl_bin, "-w", "net.ipv4.ip_forward=1"],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
if r.returncode != 0:
|
||||||
|
logger.error(f"Failed to enable ip_forward: {r.stderr.strip()}")
|
||||||
|
self._teardown_iptables_redirect()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if subprocess.run(
|
||||||
|
["sudo", iptables, "-t", "nat", "-C", "PREROUTING",
|
||||||
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||||
|
"-j", "REDIRECT", "--to-port", "5000"],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
).returncode != 0:
|
||||||
|
r = subprocess.run(
|
||||||
|
["sudo", iptables, "-t", "nat", "-A", "PREROUTING",
|
||||||
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||||
|
"-j", "REDIRECT", "--to-port", "5000"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
logger.error(f"Failed to add PREROUTING rule: {r.stderr.strip()}")
|
||||||
|
self._teardown_iptables_redirect()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if subprocess.run(
|
||||||
|
["sudo", iptables, "-C", "INPUT",
|
||||||
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000", "-j", "ACCEPT"],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
).returncode != 0:
|
||||||
|
r = subprocess.run(
|
||||||
|
["sudo", iptables, "-A", "INPUT",
|
||||||
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000", "-j", "ACCEPT"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
logger.error(f"Failed to add INPUT rule: {r.stderr.strip()}")
|
||||||
|
self._teardown_iptables_redirect()
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._redirect_backend = "iptables"
|
||||||
|
logger.info("iptables: port 80→5000 redirect rules added")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _setup_iptables_redirect_nftables(self, nft: str) -> bool:
|
||||||
|
"""Set up port 80→5000 redirect using nftables (Debian Trixie / modern systems)."""
|
||||||
|
# NM's ipv4.method=shared already enables ip_forward; no sysctl needed.
|
||||||
|
cmds = [
|
||||||
|
["sudo", nft, "add", "table", "ip", "ledmatrix"],
|
||||||
|
["sudo", nft, "add", "chain", "ip", "ledmatrix", "prerouting",
|
||||||
|
"{", "type", "nat", "hook", "prerouting", "priority", "-100", ";", "}"],
|
||||||
|
["sudo", nft, "add", "rule", "ip", "ledmatrix", "prerouting",
|
||||||
|
"iif", self._wifi_interface, "tcp", "dport", "80", "redirect", "to", ":5000"],
|
||||||
|
]
|
||||||
|
for cmd in cmds:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||||
|
if r.returncode != 0:
|
||||||
|
# Table/chain may already exist — only fail on rule add
|
||||||
|
if "add rule" in " ".join(cmd):
|
||||||
|
logger.error(f"Failed to add nftables redirect rule: {r.stderr.strip()}")
|
||||||
|
self._teardown_iptables_redirect()
|
||||||
|
return False
|
||||||
|
logger.debug(f"nft cmd non-zero (may already exist): {r.stderr.strip()}")
|
||||||
|
|
||||||
|
self._redirect_backend = "nftables"
|
||||||
|
logger.info("nftables: port 80→5000 redirect rule added")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _teardown_iptables_redirect(self) -> None:
|
||||||
|
"""Remove the port 80→5000 redirect rules and restore ip_forward if saved."""
|
||||||
|
try:
|
||||||
|
backend = self._redirect_backend
|
||||||
|
self._redirect_backend = None
|
||||||
|
|
||||||
|
if backend == "iptables":
|
||||||
|
iptables = self._find_command_path("iptables")
|
||||||
|
if iptables:
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", iptables, "-t", "nat", "-D", "PREROUTING",
|
||||||
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||||
|
"-j", "REDIRECT", "--to-port", "5000"],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", iptables, "-D", "INPUT",
|
||||||
|
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000",
|
||||||
|
"-j", "ACCEPT"],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
# Restore ip_forward only when we saved it
|
||||||
|
if self._IP_FORWARD_SAVE_PATH.exists():
|
||||||
|
try:
|
||||||
|
saved = self._IP_FORWARD_SAVE_PATH.read_text().strip()
|
||||||
|
self._IP_FORWARD_SAVE_PATH.unlink(missing_ok=True)
|
||||||
|
sysctl = self._find_command_path("sysctl")
|
||||||
|
sysctl_bin = sysctl if sysctl else "sysctl"
|
||||||
|
subprocess.run(["sudo", sysctl_bin, "-w", f"net.ipv4.ip_forward={saved}"],
|
||||||
|
capture_output=True, timeout=5)
|
||||||
|
logger.info(f"ip_forward restored to {saved}")
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Could not restore ip_forward: {e}")
|
||||||
|
else:
|
||||||
|
logger.debug("ip_forward not modified by setup; leaving unchanged")
|
||||||
|
|
||||||
|
elif backend == "nftables":
|
||||||
|
nft = self._find_command_path("nft")
|
||||||
|
if nft:
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", nft, "delete", "table", "ip", "ledmatrix"],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
logger.info("nftables ledmatrix table removed")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# No redirect was set up (neither tool available); nothing to tear down
|
||||||
|
self._IP_FORWARD_SAVE_PATH.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not tear down port redirect: {e}")
|
||||||
|
|
||||||
|
def _write_nm_dnsmasq_captive_conf(self, ap_ip: str = "192.168.4.1") -> None:
|
||||||
|
"""
|
||||||
|
Write the NM dnsmasq-shared.d drop-in that makes NM's built-in dnsmasq
|
||||||
|
resolve every hostname to the AP IP. This triggers the OS captive-portal
|
||||||
|
popup automatically on iOS / Android / Windows / macOS as soon as the
|
||||||
|
device connects — no manual navigation required.
|
||||||
|
|
||||||
|
NetworkManager reads /etc/NetworkManager/dnsmasq-shared.d/*.conf when it
|
||||||
|
starts the dnsmasq instance for ipv4.method=shared connections.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = f"# LEDMatrix captive portal: resolve all hostnames to AP\naddress=/#/{ap_ip}\n"
|
||||||
|
with open("/tmp/ledmatrix-nm-dnsmasq.conf", "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", "mkdir", "-p", str(NM_DNSMASQ_SHARED_DIR)],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", "cp", "/tmp/ledmatrix-nm-dnsmasq.conf", str(NM_DNSMASQ_SHARED_CONF)],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
logger.info(f"Wrote NM dnsmasq captive-portal config: {NM_DNSMASQ_SHARED_CONF}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not write NM dnsmasq captive config: {e}")
|
||||||
|
|
||||||
|
def _remove_nm_dnsmasq_captive_conf(self) -> None:
|
||||||
|
"""Remove the NM dnsmasq-shared.d drop-in written by _write_nm_dnsmasq_captive_conf."""
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", "rm", "-f", str(NM_DNSMASQ_SHARED_CONF)],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
logger.info("Removed NM dnsmasq captive-portal config")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not remove NM dnsmasq captive config: {e}")
|
||||||
|
|
||||||
|
def _check_internet_connectivity(self, timeout: int = 5) -> bool:
|
||||||
|
"""
|
||||||
|
Test actual internet reachability — not just nmcli association state.
|
||||||
|
|
||||||
|
A device can be 'connected' in nmcli (associated with an AP) while the
|
||||||
|
router has no WAN link. This check catches that case so the daemon can
|
||||||
|
auto-enable AP mode even when nmcli reports a connection.
|
||||||
|
|
||||||
|
Returns True if at least one reachability method succeeds.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["ping", "-c", "1", "-W", str(timeout), "8.8.8.8"],
|
||||||
|
capture_output=True, timeout=timeout + 1
|
||||||
|
)
|
||||||
|
if r.returncode == 0:
|
||||||
|
logger.debug("Internet connectivity confirmed via ping 8.8.8.8")
|
||||||
|
return True
|
||||||
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import urllib.request as _ureq
|
||||||
|
_ureq.urlopen("http://connectivity-check.ubuntu.com/", timeout=timeout)
|
||||||
|
logger.debug("Internet connectivity confirmed via HTTP check")
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
logger.debug("Internet connectivity check failed (both ping and HTTP)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_internet_connectivity(self, timeout: int = 5) -> bool:
|
||||||
|
"""Public wrapper around _check_internet_connectivity for use by the daemon."""
|
||||||
|
return self._check_internet_connectivity(timeout=timeout)
|
||||||
|
|
||||||
|
def _has_ap_clients(self) -> bool:
|
||||||
|
"""
|
||||||
|
Return True if at least one client is associated with the AP.
|
||||||
|
Uses 'iw dev <iface> station dump' which works for both hostapd and
|
||||||
|
nmcli AP modes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["iw", "dev", self._wifi_interface, "station", "dump"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
return bool(result.stdout.strip())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def scan_networks(self, allow_cached: bool = True) -> Tuple[List[WiFiNetwork], bool]:
|
def scan_networks(self, allow_cached: bool = True) -> Tuple[List[WiFiNetwork], bool]:
|
||||||
"""
|
"""
|
||||||
Scan for available WiFi networks.
|
Scan for available WiFi networks.
|
||||||
@@ -1293,12 +1598,27 @@ class WiFiManager:
|
|||||||
error_msg = result.stderr.strip() or result.stdout.strip()
|
error_msg = result.stderr.strip() or result.stdout.strip()
|
||||||
logger.error(f"Failed to connect to {ssid}: {error_msg}")
|
logger.error(f"Failed to connect to {ssid}: {error_msg}")
|
||||||
self._show_led_message("Connection failed", duration=5)
|
self._show_led_message("Connection failed", duration=5)
|
||||||
|
if self._is_wrong_password_error(error_msg):
|
||||||
|
return False, f"wrong_password: {error_msg}"
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error connecting with nmcli: {e}")
|
logger.error(f"Error connecting with nmcli: {e}")
|
||||||
self._show_led_message("Connection error", duration=5)
|
self._show_led_message("Connection error", duration=5)
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_wrong_password_error(error_msg: str) -> bool:
|
||||||
|
"""Return True when nmcli's error output indicates an authentication failure."""
|
||||||
|
indicators = [
|
||||||
|
"secrets were required",
|
||||||
|
"no secret agent",
|
||||||
|
"802-11-wireless-security.psk",
|
||||||
|
"authentication rejected",
|
||||||
|
"association rejected",
|
||||||
|
]
|
||||||
|
lower = error_msg.lower()
|
||||||
|
return any(ind in lower for ind in indicators)
|
||||||
|
|
||||||
def _connect_wpa_supplicant(self, ssid: str, password: str) -> Tuple[bool, str]:
|
def _connect_wpa_supplicant(self, ssid: str, password: str) -> Tuple[bool, str]:
|
||||||
"""Connect using wpa_supplicant (fallback)"""
|
"""Connect using wpa_supplicant (fallback)"""
|
||||||
try:
|
try:
|
||||||
@@ -1570,14 +1890,18 @@ class WiFiManager:
|
|||||||
if self.has_hostapd and self.has_dnsmasq:
|
if self.has_hostapd and self.has_dnsmasq:
|
||||||
result = self._enable_ap_mode_hostapd()
|
result = self._enable_ap_mode_hostapd()
|
||||||
if result[0]:
|
if result[0]:
|
||||||
|
self._ap_enabled_at = time.time()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Fallback to nmcli hotspot (simpler, no captive portal)
|
# Fallback to nmcli hotspot (simpler, no captive portal)
|
||||||
if self.has_nmcli:
|
if self.has_nmcli:
|
||||||
logger.info("hostapd/dnsmasq failed or unavailable, trying nmcli hotspot fallback...")
|
logger.info("hostapd/dnsmasq failed or unavailable, trying nmcli hotspot fallback...")
|
||||||
self._show_led_message("Setup Mode", duration=5)
|
self._show_led_message("Setup Mode", duration=5)
|
||||||
return self._enable_ap_mode_nmcli_hotspot()
|
result = self._enable_ap_mode_nmcli_hotspot()
|
||||||
|
if result[0]:
|
||||||
|
self._ap_enabled_at = time.time()
|
||||||
|
return result
|
||||||
|
|
||||||
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in enable_ap_mode: {e}")
|
logger.error(f"Error in enable_ap_mode: {e}")
|
||||||
@@ -1649,63 +1973,21 @@ class WiFiManager:
|
|||||||
subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE], timeout=5)
|
subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE], timeout=5)
|
||||||
return False, f"Failed to start dnsmasq: {result.stderr}"
|
return False, f"Failed to start dnsmasq: {result.stderr}"
|
||||||
|
|
||||||
# Set up iptables port forwarding: redirect port 80 to 5000
|
# Set up iptables port forwarding (port 80 → 5000) and save ip_forward state
|
||||||
# This makes the captive portal work on standard HTTP port
|
if not self._setup_iptables_redirect():
|
||||||
try:
|
logger.error("Captive-portal redirect setup failed; stopping AP services")
|
||||||
# Check if iptables is available
|
subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE],
|
||||||
iptables_check = subprocess.run(
|
capture_output=True, timeout=10)
|
||||||
["which", "iptables"],
|
subprocess.run(["sudo", "systemctl", "stop", DNSMASQ_SERVICE],
|
||||||
capture_output=True,
|
capture_output=True, timeout=10)
|
||||||
timeout=2
|
return False, "AP started but captive-portal redirect setup failed"
|
||||||
)
|
|
||||||
|
|
||||||
if iptables_check.returncode == 0:
|
|
||||||
# Enable IP forwarding (needed for NAT)
|
|
||||||
subprocess.run(
|
|
||||||
["sudo", "sysctl", "-w", "net.ipv4.ip_forward=1"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add NAT rule to redirect port 80 to 5000 on WiFi interface
|
|
||||||
# First check if rule already exists
|
|
||||||
check_result = subprocess.run(
|
|
||||||
["sudo", "iptables", "-t", "nat", "-C", "PREROUTING", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
if check_result.returncode != 0:
|
|
||||||
# Rule doesn't exist, add it
|
|
||||||
subprocess.run(
|
|
||||||
["sudo", "iptables", "-t", "nat", "-A", "PREROUTING", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
logger.info("Added iptables rule to redirect port 80 to 5000")
|
|
||||||
|
|
||||||
# Also allow incoming connections on port 80
|
|
||||||
check_input = subprocess.run(
|
|
||||||
["sudo", "iptables", "-C", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
if check_input.returncode != 0:
|
|
||||||
subprocess.run(
|
|
||||||
["sudo", "iptables", "-A", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.debug("iptables not available, port forwarding not set up")
|
|
||||||
logger.info("Note: Port 80 forwarding requires iptables. Users will need to access port 5000 directly.")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not set up iptables port forwarding: {e}")
|
|
||||||
# Continue anyway - port 5000 will still work
|
|
||||||
|
|
||||||
logger.info("AP mode enabled successfully")
|
logger.info("AP mode enabled successfully")
|
||||||
self._show_led_message("Setup Mode Active", duration=5)
|
# Use the validated SSID so the displayed name matches what hostapd broadcast
|
||||||
|
ap_ssid, _ = self._validate_ap_config()
|
||||||
|
self._show_led_message(
|
||||||
|
f"WiFi Setup\n{ap_ssid}\nNo password\n192.168.4.1:5000", duration=10
|
||||||
|
)
|
||||||
return True, "AP mode enabled"
|
return True, "AP mode enabled"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error starting AP services: {e}")
|
logger.error(f"Error starting AP services: {e}")
|
||||||
@@ -1716,245 +1998,120 @@ class WiFiManager:
|
|||||||
|
|
||||||
def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]:
|
def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Enable AP mode using nmcli hotspot.
|
Enable AP mode using nmcli as an open (passwordless) access point.
|
||||||
|
|
||||||
This method is optimized for both Bookworm and Trixie:
|
Uses 'nmcli connection add type wifi 802-11-wireless.mode ap' instead of
|
||||||
- Trixie: Uses Netplan, connections stored in /run/NetworkManager/system-connections
|
'nmcli device wifi hotspot' because the hotspot subcommand always creates a
|
||||||
- Bookworm: Traditional NetworkManager, connections in /etc/NetworkManager/system-connections
|
WPA2-protected network on Bookworm/Trixie and silently ignores attempts to
|
||||||
|
strip security after creation.
|
||||||
|
|
||||||
On Trixie, we also disable PMF (Protected Management Frames) which can cause
|
Tested for both Bookworm and Trixie (Netplan-based NetworkManager).
|
||||||
connection issues with certain WiFi adapters and clients.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Stop any existing connection
|
# Stop any existing connection
|
||||||
self.disconnect_from_network()
|
self.disconnect_from_network()
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
# Delete any existing hotspot connections (more thorough cleanup)
|
ap_ssid, ap_channel = self._validate_ap_config()
|
||||||
# First, list all connections to find any with the same SSID or hotspot-related ones
|
|
||||||
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
|
|
||||||
result = subprocess.run(
|
|
||||||
["nmcli", "-t", "-f", "NAME,TYPE,802-11-wireless.ssid", "connection", "show"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
for line in result.stdout.strip().split('\n'):
|
|
||||||
if ':' in line:
|
|
||||||
parts = line.split(':')
|
|
||||||
if len(parts) >= 2:
|
|
||||||
conn_name = parts[0].strip()
|
|
||||||
conn_type = parts[1].strip().lower() if len(parts) > 1 else ""
|
|
||||||
conn_ssid = parts[2].strip() if len(parts) > 2 else ""
|
|
||||||
|
|
||||||
# Delete if:
|
# Delete only the specific application-managed AP profiles by name.
|
||||||
# 1. It's a hotspot type
|
# Never delete by SSID — that would destroy a user's saved home network.
|
||||||
# 2. It has the same SSID as our AP
|
|
||||||
# 3. It matches our known connection names
|
|
||||||
should_delete = (
|
|
||||||
'hotspot' in conn_type or
|
|
||||||
conn_ssid == ap_ssid or
|
|
||||||
'hotspot' in conn_name.lower() or
|
|
||||||
conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if should_delete:
|
|
||||||
logger.info(f"Deleting existing connection: {conn_name} (type: {conn_type}, SSID: {conn_ssid})")
|
|
||||||
# First disconnect it if active
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "down", conn_name],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
# Then delete it
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "delete", conn_name],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
# Also explicitly delete known connection names (in case they weren't caught above)
|
|
||||||
for conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]:
|
for conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]:
|
||||||
subprocess.run(
|
subprocess.run(["nmcli", "connection", "down", conn_name],
|
||||||
["nmcli", "connection", "down", conn_name],
|
capture_output=True, timeout=5)
|
||||||
capture_output=True,
|
subprocess.run(["nmcli", "connection", "delete", conn_name],
|
||||||
timeout=5
|
capture_output=True, timeout=10)
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "delete", conn_name],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait a moment for deletions to complete
|
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
# Get AP settings from config
|
# Create an open AP connection profile from scratch.
|
||||||
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
|
# Using 'connection add' instead of 'device wifi hotspot' because the
|
||||||
ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL)
|
# hotspot subcommand always attaches a WPA2 PSK on Bookworm/Trixie and
|
||||||
|
# ignores post-creation security modifications.
|
||||||
# Use nmcli hotspot command (simpler, works with Broadcom chips)
|
logger.info(f"Creating open AP with nmcli connection add: {ap_ssid} on "
|
||||||
# Open network (no password) for easy setup access
|
f"{self._wifi_interface} (no password)")
|
||||||
logger.info(f"Creating open hotspot with nmcli: {ap_ssid} on {self._wifi_interface} (no password)")
|
|
||||||
|
|
||||||
# Note: Some NetworkManager versions add a default password to hotspots
|
|
||||||
# We'll create it and then immediately remove all security settings
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"nmcli", "device", "wifi", "hotspot",
|
"nmcli", "connection", "add",
|
||||||
"ifname", self._wifi_interface,
|
"type", "wifi",
|
||||||
"con-name", "LEDMatrix-Setup-AP",
|
"con-name", "LEDMatrix-Setup-AP",
|
||||||
|
"ifname", self._wifi_interface,
|
||||||
"ssid", ap_ssid,
|
"ssid", ap_ssid,
|
||||||
"band", "bg", # 2.4GHz for maximum compatibility
|
"802-11-wireless.mode", "ap",
|
||||||
"channel", str(ap_channel),
|
"802-11-wireless.band", "bg", # 2.4 GHz for maximum compatibility
|
||||||
# Don't pass password parameter - we'll remove security after creation
|
"802-11-wireless.channel", str(ap_channel),
|
||||||
|
"ipv4.method", "shared",
|
||||||
|
"ipv4.addresses", "192.168.4.1/24",
|
||||||
|
# No 802-11-wireless-security section → open network
|
||||||
]
|
]
|
||||||
|
|
||||||
result = subprocess.run(
|
# PMF (Protected Management Frames) is only meaningful for WPA2/WPA3.
|
||||||
cmd,
|
# An open AP has no security section, so adding 802-11-wireless-security.pmf
|
||||||
capture_output=True,
|
# would cause NM to require key-mgmt too, breaking the connection add on
|
||||||
text=True,
|
# Trixie NM 1.52+. Leave PMF untouched — open APs have no frame protection.
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
# Always explicitly remove all security settings to ensure open network
|
|
||||||
# NetworkManager sometimes adds default security even when not specified
|
|
||||||
logger.info("Ensuring hotspot is open (no password)...")
|
|
||||||
time.sleep(2) # Give it a moment to create
|
|
||||||
|
|
||||||
# Remove all possible security settings
|
if result.returncode != 0:
|
||||||
security_settings = [
|
|
||||||
("802-11-wireless-security.key-mgmt", "none"),
|
|
||||||
("802-11-wireless-security.psk", ""),
|
|
||||||
("802-11-wireless-security.wep-key", ""),
|
|
||||||
("802-11-wireless-security.wep-key-type", ""),
|
|
||||||
("802-11-wireless-security.auth-alg", "open"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# On Trixie, also disable PMF (Protected Management Frames)
|
|
||||||
# This can cause connection issues with certain WiFi adapters and clients
|
|
||||||
if self._is_trixie:
|
|
||||||
security_settings.append(("802-11-wireless-security.pmf", "disable"))
|
|
||||||
logger.info("Trixie detected: disabling PMF for better client compatibility")
|
|
||||||
|
|
||||||
for setting, value in security_settings:
|
|
||||||
result_modify = subprocess.run(
|
|
||||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP", setting, str(value)],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
if result_modify.returncode != 0:
|
|
||||||
logger.debug(f"Could not set {setting} to {value}: {result_modify.stderr}")
|
|
||||||
|
|
||||||
# On Trixie, set static IP address for the hotspot (default is 10.42.0.1)
|
|
||||||
# We want 192.168.4.1 for consistency
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
|
|
||||||
"ipv4.addresses", "192.168.4.1/24",
|
|
||||||
"ipv4.method", "shared"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify it's open
|
|
||||||
verify_result = subprocess.run(
|
|
||||||
["nmcli", "-t", "-f", "802-11-wireless-security.key-mgmt,802-11-wireless-security.psk", "connection", "show", "LEDMatrix-Setup-AP"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
if verify_result.returncode == 0:
|
|
||||||
output = verify_result.stdout.strip()
|
|
||||||
key_mgmt = ""
|
|
||||||
psk = ""
|
|
||||||
for line in output.split('\n'):
|
|
||||||
if 'key-mgmt:' in line:
|
|
||||||
key_mgmt = line.split(':', 1)[1].strip() if ':' in line else ""
|
|
||||||
elif 'psk:' in line:
|
|
||||||
psk = line.split(':', 1)[1].strip() if ':' in line else ""
|
|
||||||
|
|
||||||
if key_mgmt != "none" or (psk and psk != ""):
|
|
||||||
logger.warning(f"Hotspot still has security (key-mgmt={key_mgmt}, psk={'set' if psk else 'empty'}), deleting and recreating...")
|
|
||||||
# Delete and recreate as last resort
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
time.sleep(1)
|
|
||||||
# Recreate without any password parameters
|
|
||||||
cmd_recreate = [
|
|
||||||
"nmcli", "device", "wifi", "hotspot",
|
|
||||||
"ifname", self._wifi_interface,
|
|
||||||
"con-name", "LEDMatrix-Setup-AP",
|
|
||||||
"ssid", ap_ssid,
|
|
||||||
"band", "bg",
|
|
||||||
"channel", str(ap_channel),
|
|
||||||
]
|
|
||||||
subprocess.run(cmd_recreate, capture_output=True, timeout=30)
|
|
||||||
# Set IP address for consistency
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
|
|
||||||
"ipv4.addresses", "192.168.4.1/24",
|
|
||||||
"ipv4.method", "shared"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
# Disable PMF on Trixie
|
|
||||||
if self._is_trixie:
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
|
|
||||||
"802-11-wireless-security.pmf", "disable"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
logger.info("Recreated hotspot as open network")
|
|
||||||
else:
|
|
||||||
logger.info("Hotspot verified as open (no password)")
|
|
||||||
|
|
||||||
# Restart the connection to apply all changes
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
time.sleep(1)
|
|
||||||
subprocess.run(
|
|
||||||
["nmcli", "connection", "up", "LEDMatrix-Setup-AP"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
logger.info("Hotspot restarted with open network settings")
|
|
||||||
logger.info(f"AP mode started via nmcli hotspot: {ap_ssid}")
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
# Verify hotspot is running
|
|
||||||
status = self._get_ap_status_nmcli()
|
|
||||||
if status.get('active'):
|
|
||||||
ip = status.get('ip', '192.168.4.1')
|
|
||||||
logger.info(f"AP mode confirmed active at {ip}")
|
|
||||||
self._show_led_message(f"Setup: {ip}", duration=5)
|
|
||||||
return True, f"AP mode enabled (hotspot mode) - Access at {ip}:5000"
|
|
||||||
else:
|
|
||||||
logger.error("AP mode started but not verified")
|
|
||||||
return False, "AP mode started but verification failed"
|
|
||||||
else:
|
|
||||||
error_msg = result.stderr.strip() or result.stdout.strip()
|
error_msg = result.stderr.strip() or result.stdout.strip()
|
||||||
logger.error(f"Failed to start AP mode via nmcli: {error_msg}")
|
logger.error(f"Failed to create AP connection profile: {error_msg}")
|
||||||
self._show_led_message("AP mode failed", duration=5)
|
self._show_led_message("AP mode failed", duration=5)
|
||||||
return False, f"Failed to start AP mode: {error_msg}"
|
return False, f"Failed to create AP profile: {error_msg}"
|
||||||
|
|
||||||
|
# Write the NM dnsmasq-shared.d captive-portal config BEFORE bringing up
|
||||||
|
# the connection so NM's dnsmasq picks it up at start time.
|
||||||
|
# This causes every hostname DNS query from a connected device to resolve
|
||||||
|
# to 192.168.4.1, automatically triggering the OS captive-portal popup.
|
||||||
|
self._write_nm_dnsmasq_captive_conf()
|
||||||
|
|
||||||
|
logger.info("AP connection profile created, bringing it up...")
|
||||||
|
up_result = subprocess.run(
|
||||||
|
["nmcli", "connection", "up", "LEDMatrix-Setup-AP"],
|
||||||
|
capture_output=True, text=True, timeout=20
|
||||||
|
)
|
||||||
|
if up_result.returncode != 0:
|
||||||
|
error_msg = up_result.stderr.strip() or up_result.stdout.strip()
|
||||||
|
logger.error(f"Failed to bring up AP connection: {error_msg}")
|
||||||
|
self._remove_nm_dnsmasq_captive_conf()
|
||||||
|
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||||
|
capture_output=True, timeout=10)
|
||||||
|
self._show_led_message("AP mode failed", duration=5)
|
||||||
|
return False, f"Failed to start AP: {error_msg}"
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# NM's ipv4.method=shared manages ip_forward automatically, so we only
|
||||||
|
# need to add the iptables port-redirect rules for the captive portal.
|
||||||
|
if not self._setup_iptables_redirect():
|
||||||
|
logger.error("Captive-portal redirect setup failed; rolling back AP profile")
|
||||||
|
self._remove_nm_dnsmasq_captive_conf()
|
||||||
|
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
||||||
|
capture_output=True, timeout=10)
|
||||||
|
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||||
|
capture_output=True, timeout=10)
|
||||||
|
self._clear_led_message()
|
||||||
|
return False, "AP started but captive-portal redirect setup failed"
|
||||||
|
|
||||||
|
# Verify the AP is actually running
|
||||||
|
status = self._get_ap_status_nmcli()
|
||||||
|
if status.get('active'):
|
||||||
|
ip = status.get('ip', '192.168.4.1')
|
||||||
|
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
||||||
|
self._show_led_message(f"WiFi Setup\n{ap_ssid}\nNo password\n{ip}:5000", duration=10)
|
||||||
|
return True, f"AP mode enabled (open network) - Access at {ip}:5000"
|
||||||
|
else:
|
||||||
|
logger.error("AP mode started but not verified by status check — rolling back")
|
||||||
|
self._teardown_iptables_redirect()
|
||||||
|
self._remove_nm_dnsmasq_captive_conf()
|
||||||
|
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
||||||
|
capture_output=True, timeout=10)
|
||||||
|
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||||
|
capture_output=True, timeout=10)
|
||||||
|
self._clear_led_message()
|
||||||
|
return False, "AP mode started but verification failed"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error starting AP mode with nmcli hotspot: {e}")
|
logger.error(f"Error starting AP mode with nmcli: {e}")
|
||||||
|
self._remove_nm_dnsmasq_captive_conf()
|
||||||
self._show_led_message("Setup mode error", duration=5)
|
self._show_led_message("Setup mode error", duration=5)
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
@@ -1976,7 +2133,12 @@ class WiFiManager:
|
|||||||
|
|
||||||
for line in result.stdout.strip().split('\n'):
|
for line in result.stdout.strip().split('\n'):
|
||||||
parts = line.split(':')
|
parts = line.split(':')
|
||||||
if len(parts) >= 2 and 'hotspot' in parts[1].lower():
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
conn_name = parts[0].strip()
|
||||||
|
conn_type = parts[1].strip().lower()
|
||||||
|
# Match our known AP profile name OR the legacy nmcli hotspot type
|
||||||
|
if conn_name == "LEDMatrix-Setup-AP" or 'hotspot' in conn_type:
|
||||||
# Get actual IP address (may be 192.168.4.1 or 10.42.0.1 depending on config)
|
# Get actual IP address (may be 192.168.4.1 or 10.42.0.1 depending on config)
|
||||||
ip = '192.168.4.1'
|
ip = '192.168.4.1'
|
||||||
interface = parts[2] if len(parts) > 2 else self._wifi_interface
|
interface = parts[2] if len(parts) > 2 else self._wifi_interface
|
||||||
@@ -2072,45 +2234,9 @@ class WiFiManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not remove dnsmasq drop-in config: {e}")
|
logger.warning(f"Could not remove dnsmasq drop-in config: {e}")
|
||||||
|
|
||||||
# Remove iptables port forwarding rules and disable IP forwarding (only for hostapd mode)
|
# Remove iptables redirect rules and restore ip_forward state (hostapd mode only)
|
||||||
if hostapd_active:
|
if hostapd_active:
|
||||||
try:
|
self._teardown_iptables_redirect()
|
||||||
# Check if iptables is available
|
|
||||||
iptables_check = subprocess.run(
|
|
||||||
["which", "iptables"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=2
|
|
||||||
)
|
|
||||||
|
|
||||||
if iptables_check.returncode == 0:
|
|
||||||
# Remove NAT redirect rule
|
|
||||||
subprocess.run(
|
|
||||||
["sudo", "iptables", "-t", "nat", "-D", "PREROUTING", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove INPUT rule
|
|
||||||
subprocess.run(
|
|
||||||
["sudo", "iptables", "-D", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Removed iptables port forwarding rules")
|
|
||||||
else:
|
|
||||||
logger.debug("iptables not available, skipping rule removal")
|
|
||||||
|
|
||||||
# Disable IP forwarding (restore to default client mode)
|
|
||||||
subprocess.run(
|
|
||||||
["sudo", "sysctl", "-w", "net.ipv4.ip_forward=0"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
logger.info("Disabled IP forwarding")
|
|
||||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e:
|
|
||||||
logger.warning(f"Could not remove iptables rules or disable forwarding: {e}")
|
|
||||||
# Continue anyway
|
|
||||||
|
|
||||||
# Clean up WiFi interface IP configuration
|
# Clean up WiFi interface IP configuration
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -2153,14 +2279,17 @@ class WiFiManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Final WiFi radio unblock attempt failed: {e}")
|
logger.error(f"Final WiFi radio unblock attempt failed: {e}")
|
||||||
else:
|
else:
|
||||||
# nmcli hotspot mode - restart not needed, just ensure WiFi radio is enabled
|
# nmcli AP mode — NM's ipv4.method=shared manages ip_forward automatically,
|
||||||
logger.info("Skipping NetworkManager restart (nmcli hotspot mode, restart not needed)")
|
# so we only need to remove the iptables redirect rules we added.
|
||||||
# Still ensure WiFi radio is enabled (may have been disabled by nmcli operations)
|
logger.info("Skipping NetworkManager restart (nmcli AP mode, restart not needed)")
|
||||||
# Use retries for safety
|
self._teardown_iptables_redirect()
|
||||||
|
self._remove_nm_dnsmasq_captive_conf()
|
||||||
|
# Ensure WiFi radio is enabled after nmcli operations
|
||||||
wifi_enabled = self._ensure_wifi_radio_enabled(max_retries=3)
|
wifi_enabled = self._ensure_wifi_radio_enabled(max_retries=3)
|
||||||
if not wifi_enabled:
|
if not wifi_enabled:
|
||||||
logger.warning("WiFi radio may be disabled after nmcli hotspot cleanup")
|
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
|
||||||
|
|
||||||
|
self._ap_enabled_at = None
|
||||||
logger.info("AP mode disabled successfully")
|
logger.info("AP mode disabled successfully")
|
||||||
return True, "AP mode disabled"
|
return True, "AP mode disabled"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2175,9 +2304,11 @@ class WiFiManager:
|
|||||||
try:
|
try:
|
||||||
config_dir = HOSTAPD_CONFIG_PATH.parent
|
config_dir = HOSTAPD_CONFIG_PATH.parent
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
|
# Use validated values — strips invalid chars and ensures channel is an int.
|
||||||
ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL)
|
# Also strip newlines from SSID to prevent config-file injection.
|
||||||
|
ap_ssid, ap_channel = self._validate_ap_config()
|
||||||
|
ap_ssid = ap_ssid.replace('\n', '').replace('\r', '')
|
||||||
|
|
||||||
# Open network configuration (no password) for easy setup access
|
# Open network configuration (no password) for easy setup access
|
||||||
config_content = f"""interface={self._wifi_interface}
|
config_content = f"""interface={self._wifi_interface}
|
||||||
@@ -2305,22 +2436,21 @@ address=/detectportal.firefox.com/192.168.4.1
|
|||||||
f"Ethernet={ethernet_connected}, AP_active={ap_active}, "
|
f"Ethernet={ethernet_connected}, AP_active={ap_active}, "
|
||||||
f"auto_enable={auto_enable}, disconnected_checks={self._disconnected_checks}")
|
f"auto_enable={auto_enable}, disconnected_checks={self._disconnected_checks}")
|
||||||
|
|
||||||
# Determine if we should have AP mode active
|
# Determine if we should have AP mode active.
|
||||||
# AP mode should only be auto-enabled if:
|
# AP-enable uses only the nmcli association state (fast, no network calls).
|
||||||
# - auto_enable_ap_mode is True AND
|
# This keeps the same reliable behaviour as before: momentary packet loss
|
||||||
# - WiFi is NOT connected AND
|
# while on working WiFi does NOT trigger AP mode. The internet-reachability
|
||||||
# - Ethernet is NOT connected AND
|
# check is performed separately in the daemon watchdog for NM recovery.
|
||||||
# - We've had multiple consecutive disconnected checks (grace period)
|
|
||||||
is_disconnected = not status.connected and not ethernet_connected
|
is_disconnected = not status.connected and not ethernet_connected
|
||||||
|
|
||||||
if is_disconnected:
|
if is_disconnected:
|
||||||
# Increment disconnected check counter
|
# Increment disconnected check counter
|
||||||
self._disconnected_checks += 1
|
self._disconnected_checks += 1
|
||||||
logger.debug(f"Network disconnected (check {self._disconnected_checks}/{self._disconnected_checks_required})")
|
logger.debug(f"Network disconnected (check {self._disconnected_checks}/{self._disconnected_checks_required})")
|
||||||
else:
|
else:
|
||||||
# Reset counter if we're connected
|
# Reset counter if we're associated
|
||||||
if self._disconnected_checks > 0:
|
if self._disconnected_checks > 0:
|
||||||
logger.debug(f"Network connected, resetting disconnected check counter")
|
logger.debug("Network connected, resetting disconnected check counter")
|
||||||
self._disconnected_checks = 0
|
self._disconnected_checks = 0
|
||||||
|
|
||||||
# Only enable AP if we've had enough consecutive disconnected checks
|
# Only enable AP if we've had enough consecutive disconnected checks
|
||||||
@@ -2365,6 +2495,24 @@ address=/detectportal.firefox.com/192.168.4.1
|
|||||||
# AP is active but auto_enable is disabled - this means it was manually enabled
|
# AP is active but auto_enable is disabled - this means it was manually enabled
|
||||||
# Don't disable it automatically, let it stay active
|
# Don't disable it automatically, let it stay active
|
||||||
logger.debug("AP mode is active (manually enabled), keeping active")
|
logger.debug("AP mode is active (manually enabled), keeping active")
|
||||||
|
|
||||||
|
# Idle-timeout check: disable AP if no client has connected within the window.
|
||||||
|
# Only applies when AP is active and we haven't just decided to enable/disable it.
|
||||||
|
if ap_active and self._ap_enabled_at is not None:
|
||||||
|
try:
|
||||||
|
idle_timeout_min = max(1, min(1440, int(self.config.get("ap_idle_timeout_minutes", 15))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
idle_timeout_min = 15
|
||||||
|
elapsed = time.time() - self._ap_enabled_at
|
||||||
|
if elapsed > idle_timeout_min * 60 and not self._has_ap_clients():
|
||||||
|
logger.info(
|
||||||
|
f"AP idle timeout ({idle_timeout_min} min, no clients) — disabling AP"
|
||||||
|
)
|
||||||
|
success, message = self.disable_ap_mode()
|
||||||
|
if success:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to disable AP on idle timeout: {message}")
|
||||||
|
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Wants=network.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
WorkingDirectory=__PROJECT_ROOT_DIR__
|
WorkingDirectory=__PROJECT_ROOT_DIR__
|
||||||
ExecStart=/usr/bin/python3 /home/ledpi/LEDMatrix/scripts/utils/wifi_monitor_daemon.py --interval 30
|
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/scripts/utils/wifi_monitor_daemon.py --interval 30
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
StandardOutput=syslog
|
StandardOutput=syslog
|
||||||
|
|||||||
284
test/test_backup_manager.py
Normal file
284
test/test_backup_manager.py
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
"""Tests for src.backup_manager."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import backup_manager
|
||||||
|
from src.backup_manager import (
|
||||||
|
BUNDLED_FONTS,
|
||||||
|
SCHEMA_VERSION,
|
||||||
|
RestoreOptions,
|
||||||
|
create_backup,
|
||||||
|
list_installed_plugins,
|
||||||
|
preview_backup_contents,
|
||||||
|
restore_backup,
|
||||||
|
validate_backup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_project(root: Path) -> Path:
|
||||||
|
"""Build a minimal fake project tree under ``root``."""
|
||||||
|
(root / "config").mkdir(parents=True)
|
||||||
|
(root / "config" / "config.json").write_text(
|
||||||
|
json.dumps({"web_ui": {"port": 8080}, "my-plugin": {"enabled": True, "favorites": ["A", "B"]}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(root / "config" / "config_secrets.json").write_text(
|
||||||
|
json.dumps({"ledmatrix-weather": {"api_key": "SECRET"}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(root / "config" / "wifi_config.json").write_text(
|
||||||
|
json.dumps({"ap_mode": {"ssid": "LEDMatrix"}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
fonts = root / "assets" / "fonts"
|
||||||
|
fonts.mkdir(parents=True)
|
||||||
|
# One bundled font (should be excluded) and one user-uploaded font.
|
||||||
|
(fonts / "5x7.bdf").write_text("BUNDLED", encoding="utf-8")
|
||||||
|
(fonts / "my-custom-font.ttf").write_bytes(b"\x00\x01USER")
|
||||||
|
|
||||||
|
uploads = root / "assets" / "plugins" / "static-image" / "uploads"
|
||||||
|
uploads.mkdir(parents=True)
|
||||||
|
(uploads / "image_1.png").write_bytes(b"\x89PNG\r\n\x1a\nfake")
|
||||||
|
(uploads / ".metadata.json").write_text(json.dumps({"a": 1}), encoding="utf-8")
|
||||||
|
|
||||||
|
# plugin-repos for installed-plugin enumeration.
|
||||||
|
plugin_dir = root / "plugin-repos" / "my-plugin"
|
||||||
|
plugin_dir.mkdir(parents=True)
|
||||||
|
(plugin_dir / "manifest.json").write_text(
|
||||||
|
json.dumps({"id": "my-plugin", "version": "1.2.3"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
# plugin_state.json
|
||||||
|
(root / "data").mkdir()
|
||||||
|
(root / "data" / "plugin_state.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"states": {
|
||||||
|
"my-plugin": {"version": "1.2.3", "enabled": True},
|
||||||
|
"other-plugin": {"version": "0.1.0", "enabled": False},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def project(tmp_path: Path) -> Path:
|
||||||
|
return _make_project(tmp_path / "src_project")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def empty_project(tmp_path: Path) -> Path:
|
||||||
|
root = tmp_path / "dst_project"
|
||||||
|
root.mkdir()
|
||||||
|
# Pre-seed only the bundled font to simulate a fresh install.
|
||||||
|
(root / "assets" / "fonts").mkdir(parents=True)
|
||||||
|
(root / "assets" / "fonts" / "5x7.bdf").write_text("BUNDLED", encoding="utf-8")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# BUNDLED_FONTS sanity
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundled_fonts_matches_repo() -> None:
|
||||||
|
"""Every entry in BUNDLED_FONTS must exist on disk in assets/fonts/.
|
||||||
|
|
||||||
|
The reverse direction is intentionally not checked: real installations
|
||||||
|
have user-uploaded fonts in the same directory, and they should be
|
||||||
|
treated as user data (not bundled).
|
||||||
|
"""
|
||||||
|
repo_fonts = Path(__file__).resolve().parent.parent / "assets" / "fonts"
|
||||||
|
if not repo_fonts.exists():
|
||||||
|
pytest.skip("assets/fonts not present in test env")
|
||||||
|
on_disk = {p.name for p in repo_fonts.iterdir() if p.is_file()}
|
||||||
|
missing = set(BUNDLED_FONTS) - on_disk
|
||||||
|
assert not missing, f"BUNDLED_FONTS references files not in assets/fonts/: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Preview / enumeration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_installed_plugins(project: Path) -> None:
|
||||||
|
plugins = list_installed_plugins(project)
|
||||||
|
ids = [p["plugin_id"] for p in plugins]
|
||||||
|
assert "my-plugin" in ids
|
||||||
|
assert "other-plugin" in ids
|
||||||
|
my = next(p for p in plugins if p["plugin_id"] == "my-plugin")
|
||||||
|
assert my["version"] == "1.2.3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_backup_contents(project: Path) -> None:
|
||||||
|
preview = preview_backup_contents(project)
|
||||||
|
assert preview["has_config"] is True
|
||||||
|
assert preview["has_secrets"] is True
|
||||||
|
assert preview["has_wifi"] is True
|
||||||
|
assert preview["user_fonts"] == ["my-custom-font.ttf"]
|
||||||
|
assert preview["plugin_uploads"] >= 2
|
||||||
|
assert any(p["plugin_id"] == "my-plugin" for p in preview["plugins"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Export
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_backup_contents(project: Path, tmp_path: Path) -> None:
|
||||||
|
out_dir = tmp_path / "exports"
|
||||||
|
zip_path = create_backup(project, output_dir=out_dir)
|
||||||
|
assert zip_path.exists()
|
||||||
|
assert zip_path.parent == out_dir
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
names = set(zf.namelist())
|
||||||
|
assert "manifest.json" in names
|
||||||
|
assert "config/config.json" in names
|
||||||
|
assert "config/config_secrets.json" in names
|
||||||
|
assert "config/wifi_config.json" in names
|
||||||
|
assert "assets/fonts/my-custom-font.ttf" in names
|
||||||
|
# Bundled font must NOT be included.
|
||||||
|
assert "assets/fonts/5x7.bdf" not in names
|
||||||
|
assert "assets/plugins/static-image/uploads/image_1.png" in names
|
||||||
|
assert "plugins.json" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_backup_manifest(project: Path, tmp_path: Path) -> None:
|
||||||
|
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
manifest = json.loads(zf.read("manifest.json"))
|
||||||
|
assert manifest["schema_version"] == backup_manager.SCHEMA_VERSION
|
||||||
|
assert "created_at" in manifest
|
||||||
|
assert set(manifest["contents"]) >= {"config", "secrets", "wifi", "fonts", "plugin_uploads", "plugins"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_backup_ok(project: Path, tmp_path: Path) -> None:
|
||||||
|
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||||
|
ok, err, manifest = validate_backup(zip_path)
|
||||||
|
assert ok, err
|
||||||
|
assert err == ""
|
||||||
|
assert "config" in manifest["detected_contents"]
|
||||||
|
assert "secrets" in manifest["detected_contents"]
|
||||||
|
assert any(p["plugin_id"] == "my-plugin" for p in manifest["plugins"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_backup_missing_manifest(tmp_path: Path) -> None:
|
||||||
|
zip_path = tmp_path / "bad.zip"
|
||||||
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||||
|
zf.writestr("config/config.json", "{}")
|
||||||
|
ok, err, _ = validate_backup(zip_path)
|
||||||
|
assert not ok
|
||||||
|
assert "manifest" in err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_backup_bad_schema_version(tmp_path: Path) -> None:
|
||||||
|
zip_path = tmp_path / "bad.zip"
|
||||||
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||||
|
zf.writestr("manifest.json", json.dumps({"schema_version": 999}))
|
||||||
|
ok, err, _ = validate_backup(zip_path)
|
||||||
|
assert not ok
|
||||||
|
assert "schema" in err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_backup_rejects_zip_traversal(tmp_path: Path) -> None:
|
||||||
|
zip_path = tmp_path / "malicious.zip"
|
||||||
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||||
|
zf.writestr("manifest.json", json.dumps({"schema_version": SCHEMA_VERSION, "contents": []}))
|
||||||
|
zf.writestr("../../etc/passwd", "x")
|
||||||
|
ok, err, _ = validate_backup(zip_path)
|
||||||
|
assert not ok
|
||||||
|
assert "unsafe" in err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_backup_not_a_zip(tmp_path: Path) -> None:
|
||||||
|
p = tmp_path / "nope.zip"
|
||||||
|
p.write_text("hello", encoding="utf-8")
|
||||||
|
ok, _err, _ = validate_backup(p)
|
||||||
|
assert not ok
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Restore
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_roundtrip(project: Path, empty_project: Path, tmp_path: Path) -> None:
|
||||||
|
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||||
|
result = restore_backup(zip_path, empty_project, RestoreOptions())
|
||||||
|
|
||||||
|
assert result.success, result.errors
|
||||||
|
assert "config" in result.restored
|
||||||
|
assert "secrets" in result.restored
|
||||||
|
assert "wifi" in result.restored
|
||||||
|
|
||||||
|
# Files exist with correct contents.
|
||||||
|
restored_config = json.loads((empty_project / "config" / "config.json").read_text())
|
||||||
|
assert restored_config["my-plugin"]["favorites"] == ["A", "B"]
|
||||||
|
|
||||||
|
restored_secrets = json.loads((empty_project / "config" / "config_secrets.json").read_text())
|
||||||
|
assert restored_secrets["ledmatrix-weather"]["api_key"] == "SECRET"
|
||||||
|
|
||||||
|
# User font restored, bundled font untouched.
|
||||||
|
assert (empty_project / "assets" / "fonts" / "my-custom-font.ttf").read_bytes() == b"\x00\x01USER"
|
||||||
|
assert (empty_project / "assets" / "fonts" / "5x7.bdf").read_text() == "BUNDLED"
|
||||||
|
|
||||||
|
# Plugin uploads restored.
|
||||||
|
assert (empty_project / "assets" / "plugins" / "static-image" / "uploads" / "image_1.png").exists()
|
||||||
|
|
||||||
|
# Plugins to install surfaced for the caller.
|
||||||
|
plugin_ids = {p["plugin_id"] for p in result.plugins_to_install}
|
||||||
|
assert "my-plugin" in plugin_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_honors_options(project: Path, empty_project: Path, tmp_path: Path) -> None:
|
||||||
|
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||||
|
opts = RestoreOptions(
|
||||||
|
restore_config=True,
|
||||||
|
restore_secrets=False,
|
||||||
|
restore_wifi=False,
|
||||||
|
restore_fonts=False,
|
||||||
|
restore_plugin_uploads=False,
|
||||||
|
reinstall_plugins=False,
|
||||||
|
)
|
||||||
|
result = restore_backup(zip_path, empty_project, opts)
|
||||||
|
assert result.success, result.errors
|
||||||
|
assert (empty_project / "config" / "config.json").exists()
|
||||||
|
assert not (empty_project / "config" / "config_secrets.json").exists()
|
||||||
|
assert not (empty_project / "config" / "wifi_config.json").exists()
|
||||||
|
assert not (empty_project / "assets" / "fonts" / "my-custom-font.ttf").exists()
|
||||||
|
assert result.plugins_to_install == []
|
||||||
|
assert "secrets" in result.skipped
|
||||||
|
assert "wifi" in result.skipped
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_rejects_malicious_zip(empty_project: Path, tmp_path: Path) -> None:
|
||||||
|
zip_path = tmp_path / "bad.zip"
|
||||||
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||||
|
zf.writestr("manifest.json", json.dumps({"schema_version": SCHEMA_VERSION, "contents": []}))
|
||||||
|
zf.writestr("../escape.txt", "x")
|
||||||
|
result = restore_backup(zip_path, empty_project, RestoreOptions())
|
||||||
|
# validate_backup catches it before extraction.
|
||||||
|
assert not result.success
|
||||||
|
assert any("unsafe" in e.lower() for e in result.errors)
|
||||||
747
test/test_store_manager_caches.py
Normal file
747
test/test_store_manager_caches.py
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
"""
|
||||||
|
Tests for the caching and tombstone behaviors added to PluginStoreManager
|
||||||
|
to fix the plugin-list slowness and the uninstall-resurrection bugs.
|
||||||
|
|
||||||
|
Coverage targets:
|
||||||
|
- ``mark_recently_uninstalled`` / ``was_recently_uninstalled`` lifecycle and
|
||||||
|
TTL expiry.
|
||||||
|
- ``_get_local_git_info`` mtime-gated cache: ``git`` subprocesses only run
|
||||||
|
when ``.git/HEAD`` mtime changes.
|
||||||
|
- ``fetch_registry`` stale-cache fallback on network failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
from src.plugin_system.store_manager import PluginStoreManager
|
||||||
|
|
||||||
|
|
||||||
|
class TestUninstallTombstone(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||||||
|
|
||||||
|
def test_unmarked_plugin_is_not_recent(self):
|
||||||
|
self.assertFalse(self.sm.was_recently_uninstalled("foo"))
|
||||||
|
|
||||||
|
def test_marking_makes_it_recent(self):
|
||||||
|
self.sm.mark_recently_uninstalled("foo")
|
||||||
|
self.assertTrue(self.sm.was_recently_uninstalled("foo"))
|
||||||
|
|
||||||
|
def test_tombstone_expires_after_ttl(self):
|
||||||
|
self.sm._uninstall_tombstone_ttl = 0.05
|
||||||
|
self.sm.mark_recently_uninstalled("foo")
|
||||||
|
self.assertTrue(self.sm.was_recently_uninstalled("foo"))
|
||||||
|
time.sleep(0.1)
|
||||||
|
self.assertFalse(self.sm.was_recently_uninstalled("foo"))
|
||||||
|
# Expired entry should also be pruned from the dict.
|
||||||
|
self.assertNotIn("foo", self.sm._uninstall_tombstones)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitInfoCache(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
self.plugins_dir = Path(self._tmp.name)
|
||||||
|
self.sm = PluginStoreManager(plugins_dir=str(self.plugins_dir))
|
||||||
|
|
||||||
|
# Minimal fake git checkout: .git/HEAD needs to exist so the cache
|
||||||
|
# key (its mtime) is stable, but we mock subprocess so no actual git
|
||||||
|
# is required.
|
||||||
|
self.plugin_path = self.plugins_dir / "plg"
|
||||||
|
(self.plugin_path / ".git").mkdir(parents=True)
|
||||||
|
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
||||||
|
|
||||||
|
def _fake_subprocess_run(self, *args, **kwargs):
|
||||||
|
# Return different dummy values depending on which git subcommand
|
||||||
|
# was invoked so the code paths that parse output all succeed.
|
||||||
|
cmd = args[0]
|
||||||
|
result = MagicMock()
|
||||||
|
result.returncode = 0
|
||||||
|
if "rev-parse" in cmd and "HEAD" in cmd and "--abbrev-ref" not in cmd:
|
||||||
|
result.stdout = "abcdef1234567890\n"
|
||||||
|
elif "--abbrev-ref" in cmd:
|
||||||
|
result.stdout = "main\n"
|
||||||
|
elif "config" in cmd:
|
||||||
|
result.stdout = "https://example.com/repo.git\n"
|
||||||
|
elif "log" in cmd:
|
||||||
|
result.stdout = "2026-04-08T12:00:00+00:00\n"
|
||||||
|
else:
|
||||||
|
result.stdout = ""
|
||||||
|
return result
|
||||||
|
|
||||||
|
def test_cache_hits_avoid_subprocess_calls(self):
|
||||||
|
with patch(
|
||||||
|
"src.plugin_system.store_manager.subprocess.run",
|
||||||
|
side_effect=self._fake_subprocess_run,
|
||||||
|
) as mock_run:
|
||||||
|
first = self.sm._get_local_git_info(self.plugin_path)
|
||||||
|
self.assertIsNotNone(first)
|
||||||
|
self.assertEqual(first["short_sha"], "abcdef1")
|
||||||
|
calls_after_first = mock_run.call_count
|
||||||
|
self.assertEqual(calls_after_first, 4)
|
||||||
|
|
||||||
|
# Second call with unchanged HEAD: zero new subprocess calls.
|
||||||
|
second = self.sm._get_local_git_info(self.plugin_path)
|
||||||
|
self.assertEqual(second, first)
|
||||||
|
self.assertEqual(mock_run.call_count, calls_after_first)
|
||||||
|
|
||||||
|
def test_cache_invalidates_on_head_mtime_change(self):
|
||||||
|
with patch(
|
||||||
|
"src.plugin_system.store_manager.subprocess.run",
|
||||||
|
side_effect=self._fake_subprocess_run,
|
||||||
|
) as mock_run:
|
||||||
|
self.sm._get_local_git_info(self.plugin_path)
|
||||||
|
calls_after_first = mock_run.call_count
|
||||||
|
|
||||||
|
# Bump mtime on .git/HEAD to simulate a new commit being checked out.
|
||||||
|
head = self.plugin_path / ".git" / "HEAD"
|
||||||
|
new_time = head.stat().st_mtime + 10
|
||||||
|
os.utime(head, (new_time, new_time))
|
||||||
|
|
||||||
|
self.sm._get_local_git_info(self.plugin_path)
|
||||||
|
self.assertEqual(mock_run.call_count, calls_after_first + 4)
|
||||||
|
|
||||||
|
def test_no_git_directory_returns_none(self):
|
||||||
|
non_git = self.plugins_dir / "no_git"
|
||||||
|
non_git.mkdir()
|
||||||
|
self.assertIsNone(self.sm._get_local_git_info(non_git))
|
||||||
|
|
||||||
|
def test_cache_invalidates_on_git_config_change(self):
|
||||||
|
"""A config-only change (e.g. ``git remote set-url``) must invalidate
|
||||||
|
the cache, because the cached ``result`` dict includes ``remote_url``
|
||||||
|
which is read from ``.git/config``. Without config in the signature,
|
||||||
|
a stale remote URL would be served indefinitely.
|
||||||
|
"""
|
||||||
|
head_file = self.plugin_path / ".git" / "HEAD"
|
||||||
|
head_file.write_text("ref: refs/heads/main\n")
|
||||||
|
refs_heads = self.plugin_path / ".git" / "refs" / "heads"
|
||||||
|
refs_heads.mkdir(parents=True, exist_ok=True)
|
||||||
|
(refs_heads / "main").write_text("a" * 40 + "\n")
|
||||||
|
config_file = self.plugin_path / ".git" / "config"
|
||||||
|
config_file.write_text(
|
||||||
|
'[remote "origin"]\n\turl = https://old.example.com/repo.git\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
remote_url = {"current": "https://old.example.com/repo.git"}
|
||||||
|
|
||||||
|
def fake_subprocess_run(*args, **kwargs):
|
||||||
|
cmd = args[0]
|
||||||
|
result = MagicMock()
|
||||||
|
result.returncode = 0
|
||||||
|
if "rev-parse" in cmd and "--abbrev-ref" not in cmd:
|
||||||
|
result.stdout = "a" * 40 + "\n"
|
||||||
|
elif "--abbrev-ref" in cmd:
|
||||||
|
result.stdout = "main\n"
|
||||||
|
elif "config" in cmd:
|
||||||
|
result.stdout = remote_url["current"] + "\n"
|
||||||
|
elif "log" in cmd:
|
||||||
|
result.stdout = "2026-04-08T12:00:00+00:00\n"
|
||||||
|
else:
|
||||||
|
result.stdout = ""
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.plugin_system.store_manager.subprocess.run",
|
||||||
|
side_effect=fake_subprocess_run,
|
||||||
|
):
|
||||||
|
first = self.sm._get_local_git_info(self.plugin_path)
|
||||||
|
self.assertEqual(first["remote_url"], "https://old.example.com/repo.git")
|
||||||
|
|
||||||
|
# Simulate ``git remote set-url origin https://new.example.com/repo.git``:
|
||||||
|
# ``.git/config`` contents AND mtime change. HEAD is untouched.
|
||||||
|
time.sleep(0.01) # ensure a detectable mtime delta
|
||||||
|
config_file.write_text(
|
||||||
|
'[remote "origin"]\n\turl = https://new.example.com/repo.git\n'
|
||||||
|
)
|
||||||
|
new_time = config_file.stat().st_mtime + 10
|
||||||
|
os.utime(config_file, (new_time, new_time))
|
||||||
|
remote_url["current"] = "https://new.example.com/repo.git"
|
||||||
|
|
||||||
|
second = self.sm._get_local_git_info(self.plugin_path)
|
||||||
|
self.assertEqual(
|
||||||
|
second["remote_url"], "https://new.example.com/repo.git",
|
||||||
|
"config-only change did not invalidate the cache — "
|
||||||
|
".git/config mtime/contents must be part of the signature",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cache_invalidates_on_fast_forward_of_current_branch(self):
|
||||||
|
"""Regression: .git/HEAD mtime alone is not enough.
|
||||||
|
|
||||||
|
``git pull`` that fast-forwards the current branch touches
|
||||||
|
``.git/refs/heads/<branch>`` (or packed-refs) but NOT HEAD. If
|
||||||
|
we cache on HEAD mtime alone, we serve a stale SHA indefinitely.
|
||||||
|
"""
|
||||||
|
# Build a realistic loose-ref layout.
|
||||||
|
refs_heads = self.plugin_path / ".git" / "refs" / "heads"
|
||||||
|
refs_heads.mkdir(parents=True)
|
||||||
|
branch_file = refs_heads / "main"
|
||||||
|
branch_file.write_text("a" * 40 + "\n")
|
||||||
|
# Overwrite HEAD to point at refs/heads/main.
|
||||||
|
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
||||||
|
|
||||||
|
call_log = []
|
||||||
|
|
||||||
|
def fake_subprocess_run(*args, **kwargs):
|
||||||
|
call_log.append(args[0])
|
||||||
|
result = MagicMock()
|
||||||
|
result.returncode = 0
|
||||||
|
cmd = args[0]
|
||||||
|
if "rev-parse" in cmd and "--abbrev-ref" not in cmd:
|
||||||
|
result.stdout = branch_file.read_text().strip() + "\n"
|
||||||
|
elif "--abbrev-ref" in cmd:
|
||||||
|
result.stdout = "main\n"
|
||||||
|
elif "config" in cmd:
|
||||||
|
result.stdout = "https://example.com/repo.git\n"
|
||||||
|
elif "log" in cmd:
|
||||||
|
result.stdout = "2026-04-08T12:00:00+00:00\n"
|
||||||
|
else:
|
||||||
|
result.stdout = ""
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.plugin_system.store_manager.subprocess.run",
|
||||||
|
side_effect=fake_subprocess_run,
|
||||||
|
):
|
||||||
|
first = self.sm._get_local_git_info(self.plugin_path)
|
||||||
|
calls_after_first = len(call_log)
|
||||||
|
self.assertIsNotNone(first)
|
||||||
|
self.assertTrue(first["sha"].startswith("a"))
|
||||||
|
|
||||||
|
# Second call: unchanged. Cache hit → no new subprocess calls.
|
||||||
|
self.sm._get_local_git_info(self.plugin_path)
|
||||||
|
self.assertEqual(len(call_log), calls_after_first,
|
||||||
|
"cache should hit on unchanged state")
|
||||||
|
|
||||||
|
# Simulate a fast-forward: the branch ref file gets a new SHA
|
||||||
|
# and a new mtime, but .git/HEAD is untouched.
|
||||||
|
branch_file.write_text("b" * 40 + "\n")
|
||||||
|
new_time = branch_file.stat().st_mtime + 10
|
||||||
|
os.utime(branch_file, (new_time, new_time))
|
||||||
|
|
||||||
|
second = self.sm._get_local_git_info(self.plugin_path)
|
||||||
|
# Cache MUST have been invalidated — we should have re-run git.
|
||||||
|
self.assertGreater(
|
||||||
|
len(call_log), calls_after_first,
|
||||||
|
"cache should have invalidated on branch ref update",
|
||||||
|
)
|
||||||
|
self.assertTrue(second["sha"].startswith("b"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchPluginsParallel(unittest.TestCase):
|
||||||
|
"""Plugin Store browse path — the per-plugin GitHub enrichment used to
|
||||||
|
run serially, turning a browse of 15 plugins into 30–45 sequential HTTP
|
||||||
|
requests on a cold cache. This batch of tests locks in the parallel
|
||||||
|
fan-out and verifies output shape/ordering haven't regressed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||||||
|
|
||||||
|
# Fake registry with 5 plugins.
|
||||||
|
self.registry = {
|
||||||
|
"plugins": [
|
||||||
|
{"id": f"plg{i}", "name": f"Plugin {i}",
|
||||||
|
"repo": f"https://github.com/owner/plg{i}", "category": "util"}
|
||||||
|
for i in range(5)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
self.sm.registry_cache = self.registry
|
||||||
|
self.sm.registry_cache_time = time.time()
|
||||||
|
|
||||||
|
self._enrich_calls = []
|
||||||
|
|
||||||
|
def fake_repo(repo_url):
|
||||||
|
self._enrich_calls.append(("repo", repo_url))
|
||||||
|
return {"stars": 1, "default_branch": "main",
|
||||||
|
"last_commit_iso": "2026-04-08T00:00:00Z",
|
||||||
|
"last_commit_date": "2026-04-08"}
|
||||||
|
|
||||||
|
def fake_commit(repo_url, branch):
|
||||||
|
self._enrich_calls.append(("commit", repo_url, branch))
|
||||||
|
return {"short_sha": "abc1234", "sha": "abc1234" + "0" * 33,
|
||||||
|
"date_iso": "2026-04-08T00:00:00Z", "date": "2026-04-08",
|
||||||
|
"message": "m", "author": "a", "branch": branch}
|
||||||
|
|
||||||
|
def fake_manifest(repo_url, branch, manifest_path):
|
||||||
|
self._enrich_calls.append(("manifest", repo_url, branch))
|
||||||
|
return {"description": "desc"}
|
||||||
|
|
||||||
|
self.sm._get_github_repo_info = fake_repo
|
||||||
|
self.sm._get_latest_commit_info = fake_commit
|
||||||
|
self.sm._fetch_manifest_from_github = fake_manifest
|
||||||
|
|
||||||
|
def test_results_preserve_registry_order(self):
|
||||||
|
results = self.sm.search_plugins(include_saved_repos=False)
|
||||||
|
self.assertEqual([p["id"] for p in results],
|
||||||
|
[f"plg{i}" for i in range(5)])
|
||||||
|
|
||||||
|
def test_filters_applied_before_enrichment(self):
|
||||||
|
# Filter down to a single plugin via category — ensures we don't
|
||||||
|
# waste GitHub calls enriching plugins that won't be returned.
|
||||||
|
self.registry["plugins"][2]["category"] = "special"
|
||||||
|
self.sm.registry_cache = self.registry
|
||||||
|
self._enrich_calls.clear()
|
||||||
|
results = self.sm.search_plugins(category="special", include_saved_repos=False)
|
||||||
|
self.assertEqual(len(results), 1)
|
||||||
|
self.assertEqual(results[0]["id"], "plg2")
|
||||||
|
# Only one plugin should have been enriched.
|
||||||
|
repo_calls = [c for c in self._enrich_calls if c[0] == "repo"]
|
||||||
|
self.assertEqual(len(repo_calls), 1)
|
||||||
|
|
||||||
|
def test_enrichment_runs_concurrently(self):
|
||||||
|
"""Verify the thread pool actually runs fetches in parallel.
|
||||||
|
|
||||||
|
Deterministic check: each stub repo fetch holds a lock while it
|
||||||
|
increments a "currently running" counter, then sleeps briefly,
|
||||||
|
then decrements. If execution is serial, the peak counter can
|
||||||
|
never exceed 1. If the thread pool is engaged, we see at least
|
||||||
|
2 concurrent workers.
|
||||||
|
|
||||||
|
We deliberately do NOT assert on elapsed wall time — that check
|
||||||
|
was flaky on low-power / CI boxes where scheduler noise dwarfed
|
||||||
|
the 50ms-per-worker budget. ``peak["count"] >= 2`` is the signal
|
||||||
|
we actually care about.
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
peak_lock = threading.Lock()
|
||||||
|
peak = {"count": 0, "current": 0}
|
||||||
|
|
||||||
|
def slow_repo(repo_url):
|
||||||
|
with peak_lock:
|
||||||
|
peak["current"] += 1
|
||||||
|
if peak["current"] > peak["count"]:
|
||||||
|
peak["count"] = peak["current"]
|
||||||
|
# Small sleep gives other workers a chance to enter the
|
||||||
|
# critical section before we leave it. 50ms is large enough
|
||||||
|
# to dominate any scheduling jitter without slowing the test
|
||||||
|
# suite meaningfully.
|
||||||
|
time.sleep(0.05)
|
||||||
|
with peak_lock:
|
||||||
|
peak["current"] -= 1
|
||||||
|
return {"stars": 0, "default_branch": "main",
|
||||||
|
"last_commit_iso": "", "last_commit_date": ""}
|
||||||
|
|
||||||
|
self.sm._get_github_repo_info = slow_repo
|
||||||
|
self.sm._get_latest_commit_info = lambda *a, **k: None
|
||||||
|
self.sm._fetch_manifest_from_github = lambda *a, **k: None
|
||||||
|
|
||||||
|
results = self.sm.search_plugins(fetch_commit_info=False, include_saved_repos=False)
|
||||||
|
|
||||||
|
self.assertEqual(len(results), 5)
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
peak["count"], 2,
|
||||||
|
"no concurrent fetches observed — thread pool not engaging",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaleOnErrorFallbacks(unittest.TestCase):
|
||||||
|
"""When GitHub is unreachable, previously-cached values should still be
|
||||||
|
returned rather than zero/None. Important on Pi's WiFi links.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||||||
|
|
||||||
|
def test_repo_info_stale_on_network_error(self):
|
||||||
|
cache_key = "owner/repo"
|
||||||
|
good = {"stars": 42, "default_branch": "main",
|
||||||
|
"last_commit_iso": "", "last_commit_date": "",
|
||||||
|
"forks": 0, "open_issues": 0, "updated_at_iso": "",
|
||||||
|
"language": "", "license": ""}
|
||||||
|
# Seed the cache with a known-good value, then force expiry.
|
||||||
|
self.sm.github_cache[cache_key] = (time.time() - 10_000, good)
|
||||||
|
self.sm.cache_timeout = 1 # force re-fetch
|
||||||
|
|
||||||
|
import requests as real_requests
|
||||||
|
with patch("src.plugin_system.store_manager.requests.get",
|
||||||
|
side_effect=real_requests.ConnectionError("boom")):
|
||||||
|
result = self.sm._get_github_repo_info("https://github.com/owner/repo")
|
||||||
|
self.assertEqual(result["stars"], 42)
|
||||||
|
|
||||||
|
def test_repo_info_stale_bumps_timestamp_into_backoff(self):
|
||||||
|
"""Regression: after serving stale, next lookup must hit cache.
|
||||||
|
|
||||||
|
Without the failure-backoff timestamp bump, a repeat request
|
||||||
|
would see the cache as still expired and re-hit the network,
|
||||||
|
amplifying the original failure. The fix is to update the
|
||||||
|
cached entry's timestamp so ``(now - ts) < cache_timeout`` holds
|
||||||
|
for the backoff window.
|
||||||
|
"""
|
||||||
|
cache_key = "owner/repo"
|
||||||
|
good = {"stars": 99, "default_branch": "main",
|
||||||
|
"last_commit_iso": "", "last_commit_date": "",
|
||||||
|
"forks": 0, "open_issues": 0, "updated_at_iso": "",
|
||||||
|
"language": "", "license": ""}
|
||||||
|
self.sm.github_cache[cache_key] = (time.time() - 10_000, good)
|
||||||
|
self.sm.cache_timeout = 1
|
||||||
|
self.sm._failure_backoff_seconds = 60
|
||||||
|
|
||||||
|
import requests as real_requests
|
||||||
|
call_count = {"n": 0}
|
||||||
|
|
||||||
|
def counting_get(*args, **kwargs):
|
||||||
|
call_count["n"] += 1
|
||||||
|
raise real_requests.ConnectionError("boom")
|
||||||
|
|
||||||
|
with patch("src.plugin_system.store_manager.requests.get", side_effect=counting_get):
|
||||||
|
first = self.sm._get_github_repo_info("https://github.com/owner/repo")
|
||||||
|
self.assertEqual(first["stars"], 99)
|
||||||
|
self.assertEqual(call_count["n"], 1)
|
||||||
|
|
||||||
|
# Second call must hit the bumped cache and NOT make another request.
|
||||||
|
second = self.sm._get_github_repo_info("https://github.com/owner/repo")
|
||||||
|
self.assertEqual(second["stars"], 99)
|
||||||
|
self.assertEqual(
|
||||||
|
call_count["n"], 1,
|
||||||
|
"stale-cache fallback must bump the timestamp to avoid "
|
||||||
|
"re-retrying on every request during the backoff window",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_repo_info_stale_on_403_also_backs_off(self):
|
||||||
|
"""Same backoff requirement for 403 rate-limit responses."""
|
||||||
|
cache_key = "owner/repo"
|
||||||
|
good = {"stars": 7, "default_branch": "main",
|
||||||
|
"last_commit_iso": "", "last_commit_date": "",
|
||||||
|
"forks": 0, "open_issues": 0, "updated_at_iso": "",
|
||||||
|
"language": "", "license": ""}
|
||||||
|
self.sm.github_cache[cache_key] = (time.time() - 10_000, good)
|
||||||
|
self.sm.cache_timeout = 1
|
||||||
|
|
||||||
|
rate_limited = MagicMock()
|
||||||
|
rate_limited.status_code = 403
|
||||||
|
rate_limited.text = "rate limited"
|
||||||
|
call_count = {"n": 0}
|
||||||
|
|
||||||
|
def counting_get(*args, **kwargs):
|
||||||
|
call_count["n"] += 1
|
||||||
|
return rate_limited
|
||||||
|
|
||||||
|
with patch("src.plugin_system.store_manager.requests.get", side_effect=counting_get):
|
||||||
|
self.sm._get_github_repo_info("https://github.com/owner/repo")
|
||||||
|
self.assertEqual(call_count["n"], 1)
|
||||||
|
self.sm._get_github_repo_info("https://github.com/owner/repo")
|
||||||
|
self.assertEqual(
|
||||||
|
call_count["n"], 1,
|
||||||
|
"403 stale fallback must also bump the timestamp",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_commit_info_stale_on_network_error(self):
|
||||||
|
cache_key = "owner/repo:main"
|
||||||
|
good = {"branch": "main", "sha": "a" * 40, "short_sha": "aaaaaaa",
|
||||||
|
"date_iso": "2026-04-08T00:00:00Z", "date": "2026-04-08",
|
||||||
|
"author": "x", "message": "y"}
|
||||||
|
self.sm.commit_info_cache[cache_key] = (time.time() - 10_000, good)
|
||||||
|
self.sm.commit_cache_timeout = 1 # force re-fetch
|
||||||
|
|
||||||
|
import requests as real_requests
|
||||||
|
with patch("src.plugin_system.store_manager.requests.get",
|
||||||
|
side_effect=real_requests.ConnectionError("boom")):
|
||||||
|
result = self.sm._get_latest_commit_info(
|
||||||
|
"https://github.com/owner/repo", branch="main"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result["short_sha"], "aaaaaaa")
|
||||||
|
|
||||||
|
def test_commit_info_preserves_good_cache_on_all_branches_404(self):
|
||||||
|
"""Regression: all-branches-404 used to overwrite good cache with None.
|
||||||
|
|
||||||
|
The previous implementation unconditionally wrote
|
||||||
|
``self.commit_info_cache[cache_key] = (time.time(), None)`` after
|
||||||
|
the branch loop, which meant a single transient failure (e.g. an
|
||||||
|
odd 5xx or an ls-refs hiccup) wiped out the commit info we had
|
||||||
|
just served to the UI the previous minute.
|
||||||
|
"""
|
||||||
|
cache_key = "owner/repo:main"
|
||||||
|
good = {"branch": "main", "sha": "a" * 40, "short_sha": "aaaaaaa",
|
||||||
|
"date_iso": "2026-04-08T00:00:00Z", "date": "2026-04-08",
|
||||||
|
"author": "x", "message": "y"}
|
||||||
|
self.sm.commit_info_cache[cache_key] = (time.time() - 10_000, good)
|
||||||
|
self.sm.commit_cache_timeout = 1
|
||||||
|
|
||||||
|
# Each branches_to_try attempt returns a 404. No network error
|
||||||
|
# exception — just a non-200 response. This is the code path
|
||||||
|
# that used to overwrite the cache with None.
|
||||||
|
not_found = MagicMock()
|
||||||
|
not_found.status_code = 404
|
||||||
|
not_found.text = "Not Found"
|
||||||
|
with patch("src.plugin_system.store_manager.requests.get", return_value=not_found):
|
||||||
|
result = self.sm._get_latest_commit_info(
|
||||||
|
"https://github.com/owner/repo", branch="main"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(result, "good cache was wiped out by transient 404s")
|
||||||
|
self.assertEqual(result["short_sha"], "aaaaaaa")
|
||||||
|
# The cache entry must still be the good value, not None.
|
||||||
|
self.assertIsNotNone(self.sm.commit_info_cache[cache_key][1])
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstallUpdateUninstallInvariants(unittest.TestCase):
|
||||||
|
"""Regression guard: the caching and tombstone work added in this PR
|
||||||
|
must not break the install / update / uninstall code paths.
|
||||||
|
|
||||||
|
Specifically:
|
||||||
|
- ``install_plugin`` bypasses commit/manifest caches via force_refresh,
|
||||||
|
so the 5→30 min TTL bump cannot cause users to install a stale commit.
|
||||||
|
- ``update_plugin`` does the same.
|
||||||
|
- The uninstall tombstone is only honored by the state reconciler, not
|
||||||
|
by explicit ``install_plugin`` calls — so a user can uninstall and
|
||||||
|
immediately reinstall from the store UI without the tombstone getting
|
||||||
|
in the way.
|
||||||
|
- ``was_recently_uninstalled`` is not touched by ``install_plugin``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||||||
|
|
||||||
|
def test_get_plugin_info_with_force_refresh_forwards_to_commit_fetch(self):
|
||||||
|
"""install_plugin's code path must reach the network bypass."""
|
||||||
|
self.sm.registry_cache = {
|
||||||
|
"plugins": [{"id": "foo", "repo": "https://github.com/o/r"}]
|
||||||
|
}
|
||||||
|
self.sm.registry_cache_time = time.time()
|
||||||
|
|
||||||
|
repo_calls = []
|
||||||
|
commit_calls = []
|
||||||
|
manifest_calls = []
|
||||||
|
|
||||||
|
def fake_repo(url):
|
||||||
|
repo_calls.append(url)
|
||||||
|
return {"default_branch": "main", "stars": 0,
|
||||||
|
"last_commit_iso": "", "last_commit_date": ""}
|
||||||
|
|
||||||
|
def fake_commit(url, branch, force_refresh=False):
|
||||||
|
commit_calls.append((url, branch, force_refresh))
|
||||||
|
return {"short_sha": "deadbee", "sha": "d" * 40,
|
||||||
|
"message": "m", "author": "a", "branch": branch,
|
||||||
|
"date": "2026-04-08", "date_iso": "2026-04-08T00:00:00Z"}
|
||||||
|
|
||||||
|
def fake_manifest(url, branch, manifest_path, force_refresh=False):
|
||||||
|
manifest_calls.append((url, branch, manifest_path, force_refresh))
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.sm._get_github_repo_info = fake_repo
|
||||||
|
self.sm._get_latest_commit_info = fake_commit
|
||||||
|
self.sm._fetch_manifest_from_github = fake_manifest
|
||||||
|
|
||||||
|
info = self.sm.get_plugin_info("foo", fetch_latest_from_github=True, force_refresh=True)
|
||||||
|
|
||||||
|
self.assertIsNotNone(info)
|
||||||
|
self.assertEqual(info["last_commit_sha"], "d" * 40)
|
||||||
|
# force_refresh must have propagated through to the fetch helpers.
|
||||||
|
self.assertTrue(commit_calls, "commit fetch was not called")
|
||||||
|
self.assertTrue(commit_calls[0][2], "force_refresh=True did not reach _get_latest_commit_info")
|
||||||
|
self.assertTrue(manifest_calls, "manifest fetch was not called")
|
||||||
|
self.assertTrue(manifest_calls[0][3], "force_refresh=True did not reach _fetch_manifest_from_github")
|
||||||
|
|
||||||
|
def test_install_plugin_is_not_blocked_by_tombstone(self):
|
||||||
|
"""A tombstone must only gate the reconciler, not explicit installs.
|
||||||
|
|
||||||
|
Uses a complete, valid manifest stub and a no-op dependency
|
||||||
|
installer so ``install_plugin`` runs all the way through to a
|
||||||
|
True return. Anything less (e.g. swallowing exceptions) would
|
||||||
|
hide real regressions in the install path.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
self.sm.registry_cache = {
|
||||||
|
"plugins": [{"id": "bar", "repo": "https://github.com/o/bar",
|
||||||
|
"plugin_path": ""}]
|
||||||
|
}
|
||||||
|
self.sm.registry_cache_time = time.time()
|
||||||
|
|
||||||
|
# Mark it recently uninstalled (simulates a user who just clicked
|
||||||
|
# uninstall and then immediately clicked install again).
|
||||||
|
self.sm.mark_recently_uninstalled("bar")
|
||||||
|
self.assertTrue(self.sm.was_recently_uninstalled("bar"))
|
||||||
|
|
||||||
|
# Stub the heavy bits so install_plugin can run without network.
|
||||||
|
self.sm._get_github_repo_info = lambda url: {
|
||||||
|
"default_branch": "main", "stars": 0,
|
||||||
|
"last_commit_iso": "", "last_commit_date": ""
|
||||||
|
}
|
||||||
|
self.sm._get_latest_commit_info = lambda *a, **k: {
|
||||||
|
"short_sha": "abc1234", "sha": "a" * 40, "branch": "main",
|
||||||
|
"message": "m", "author": "a",
|
||||||
|
"date": "2026-04-08", "date_iso": "2026-04-08T00:00:00Z",
|
||||||
|
}
|
||||||
|
self.sm._fetch_manifest_from_github = lambda *a, **k: None
|
||||||
|
# Skip dependency install entirely (real install calls pip).
|
||||||
|
self.sm._install_dependencies = lambda *a, **k: True
|
||||||
|
|
||||||
|
def fake_install_via_git(repo_url, plugin_path, branches):
|
||||||
|
# Write a COMPLETE valid manifest so install_plugin's
|
||||||
|
# post-download validation succeeds. Required fields come
|
||||||
|
# from install_plugin itself: id, name, class_name, display_modes.
|
||||||
|
plugin_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
manifest = {
|
||||||
|
"id": "bar",
|
||||||
|
"name": "Bar Plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"class_name": "BarPlugin",
|
||||||
|
"entry_point": "manager.py",
|
||||||
|
"display_modes": ["bar_mode"],
|
||||||
|
}
|
||||||
|
(plugin_path / "manifest.json").write_text(_json.dumps(manifest))
|
||||||
|
return branches[0]
|
||||||
|
|
||||||
|
self.sm._install_via_git = fake_install_via_git
|
||||||
|
|
||||||
|
# No exception-swallowing: if install_plugin fails for ANY reason
|
||||||
|
# unrelated to the tombstone, the test fails loudly.
|
||||||
|
result = self.sm.install_plugin("bar")
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
result,
|
||||||
|
"install_plugin returned False — the tombstone should not gate "
|
||||||
|
"explicit installs and all other stubs should allow success.",
|
||||||
|
)
|
||||||
|
# Tombstone survives install (harmless — nothing reads it for installed plugins).
|
||||||
|
self.assertTrue(self.sm.was_recently_uninstalled("bar"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistryStaleCacheFallback(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||||||
|
|
||||||
|
def test_network_failure_returns_stale_cache(self):
|
||||||
|
# Prime the cache with a known-good registry.
|
||||||
|
self.sm.registry_cache = {"plugins": [{"id": "cached"}]}
|
||||||
|
self.sm.registry_cache_time = time.time() - 10_000 # very old
|
||||||
|
self.sm.registry_cache_timeout = 1 # force re-fetch attempt
|
||||||
|
|
||||||
|
import requests as real_requests
|
||||||
|
with patch.object(
|
||||||
|
self.sm,
|
||||||
|
"_http_get_with_retries",
|
||||||
|
side_effect=real_requests.RequestException("boom"),
|
||||||
|
):
|
||||||
|
result = self.sm.fetch_registry()
|
||||||
|
|
||||||
|
self.assertEqual(result, {"plugins": [{"id": "cached"}]})
|
||||||
|
|
||||||
|
def test_network_failure_with_no_cache_returns_empty(self):
|
||||||
|
self.sm.registry_cache = None
|
||||||
|
import requests as real_requests
|
||||||
|
with patch.object(
|
||||||
|
self.sm,
|
||||||
|
"_http_get_with_retries",
|
||||||
|
side_effect=real_requests.RequestException("boom"),
|
||||||
|
):
|
||||||
|
result = self.sm.fetch_registry()
|
||||||
|
self.assertEqual(result, {"plugins": []})
|
||||||
|
|
||||||
|
def test_stale_fallback_bumps_timestamp_into_backoff(self):
|
||||||
|
"""Regression: after the stale-cache fallback fires, the next
|
||||||
|
fetch_registry call must NOT re-hit the network. Without the
|
||||||
|
timestamp bump, a flaky connection causes every request to pay
|
||||||
|
the network timeout before falling back to stale.
|
||||||
|
"""
|
||||||
|
self.sm.registry_cache = {"plugins": [{"id": "cached"}]}
|
||||||
|
self.sm.registry_cache_time = time.time() - 10_000 # expired
|
||||||
|
self.sm.registry_cache_timeout = 1
|
||||||
|
self.sm._failure_backoff_seconds = 60
|
||||||
|
|
||||||
|
import requests as real_requests
|
||||||
|
call_count = {"n": 0}
|
||||||
|
|
||||||
|
def counting_get(*args, **kwargs):
|
||||||
|
call_count["n"] += 1
|
||||||
|
raise real_requests.ConnectionError("boom")
|
||||||
|
|
||||||
|
with patch.object(self.sm, "_http_get_with_retries", side_effect=counting_get):
|
||||||
|
first = self.sm.fetch_registry()
|
||||||
|
self.assertEqual(first, {"plugins": [{"id": "cached"}]})
|
||||||
|
self.assertEqual(call_count["n"], 1)
|
||||||
|
|
||||||
|
second = self.sm.fetch_registry()
|
||||||
|
self.assertEqual(second, {"plugins": [{"id": "cached"}]})
|
||||||
|
self.assertEqual(
|
||||||
|
call_count["n"], 1,
|
||||||
|
"stale registry fallback must bump registry_cache_time so "
|
||||||
|
"subsequent requests hit the cache instead of re-retrying",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchRegistryRaiseOnFailure(unittest.TestCase):
|
||||||
|
"""``fetch_registry(raise_on_failure=True)`` must propagate errors
|
||||||
|
instead of silently falling back to the stale cache / empty dict.
|
||||||
|
|
||||||
|
Regression guard: the state reconciler relies on this to distinguish
|
||||||
|
"plugin genuinely not in registry" from "I can't reach the registry
|
||||||
|
right now". Without it, a fresh boot with flaky WiFi would poison
|
||||||
|
``_unrecoverable_missing_on_disk`` with every config entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
self.sm = PluginStoreManager(plugins_dir=self._tmp.name)
|
||||||
|
|
||||||
|
def test_request_exception_propagates_when_flag_set(self):
|
||||||
|
import requests as real_requests
|
||||||
|
self.sm.registry_cache = None # no stale cache
|
||||||
|
with patch.object(
|
||||||
|
self.sm,
|
||||||
|
"_http_get_with_retries",
|
||||||
|
side_effect=real_requests.RequestException("boom"),
|
||||||
|
):
|
||||||
|
with self.assertRaises(real_requests.RequestException):
|
||||||
|
self.sm.fetch_registry(raise_on_failure=True)
|
||||||
|
|
||||||
|
def test_request_exception_propagates_even_with_stale_cache(self):
|
||||||
|
"""Explicit caller opt-in beats the stale-cache convenience."""
|
||||||
|
import requests as real_requests
|
||||||
|
self.sm.registry_cache = {"plugins": [{"id": "stale"}]}
|
||||||
|
self.sm.registry_cache_time = time.time() - 10_000
|
||||||
|
self.sm.registry_cache_timeout = 1
|
||||||
|
with patch.object(
|
||||||
|
self.sm,
|
||||||
|
"_http_get_with_retries",
|
||||||
|
side_effect=real_requests.RequestException("boom"),
|
||||||
|
):
|
||||||
|
with self.assertRaises(real_requests.RequestException):
|
||||||
|
self.sm.fetch_registry(raise_on_failure=True)
|
||||||
|
|
||||||
|
def test_json_decode_error_propagates_when_flag_set(self):
|
||||||
|
import json as _json
|
||||||
|
self.sm.registry_cache = None
|
||||||
|
bad_response = MagicMock()
|
||||||
|
bad_response.status_code = 200
|
||||||
|
bad_response.raise_for_status = MagicMock()
|
||||||
|
bad_response.json = MagicMock(
|
||||||
|
side_effect=_json.JSONDecodeError("bad", "", 0)
|
||||||
|
)
|
||||||
|
with patch.object(self.sm, "_http_get_with_retries", return_value=bad_response):
|
||||||
|
with self.assertRaises(_json.JSONDecodeError):
|
||||||
|
self.sm.fetch_registry(raise_on_failure=True)
|
||||||
|
|
||||||
|
def test_default_behavior_unchanged_by_new_parameter(self):
|
||||||
|
"""UI callers that don't pass the flag still get stale-cache fallback."""
|
||||||
|
import requests as real_requests
|
||||||
|
self.sm.registry_cache = {"plugins": [{"id": "cached"}]}
|
||||||
|
self.sm.registry_cache_time = time.time() - 10_000
|
||||||
|
self.sm.registry_cache_timeout = 1
|
||||||
|
with patch.object(
|
||||||
|
self.sm,
|
||||||
|
"_http_get_with_retries",
|
||||||
|
side_effect=real_requests.RequestException("boom"),
|
||||||
|
):
|
||||||
|
result = self.sm.fetch_registry() # default raise_on_failure=False
|
||||||
|
self.assertEqual(result, {"plugins": [{"id": "cached"}]})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
395
test/test_uninstall_and_reconcile_endpoint.py
Normal file
395
test/test_uninstall_and_reconcile_endpoint.py
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
"""Regression tests for the transactional uninstall helper and the
|
||||||
|
``/plugins/state/reconcile`` endpoint's payload handling.
|
||||||
|
|
||||||
|
Bug 1: the original uninstall flow caught
|
||||||
|
``cleanup_plugin_config`` exceptions and only logged a warning before
|
||||||
|
proceeding to file deletion. A failure there would leave the plugin
|
||||||
|
files on disk with no config entry (orphan). The fix is a
|
||||||
|
``_do_transactional_uninstall`` helper that (a) aborts before touching
|
||||||
|
the filesystem if cleanup fails, and (b) restores the config+secrets
|
||||||
|
snapshot if file removal fails after cleanup succeeded.
|
||||||
|
|
||||||
|
Bug 2: the reconcile endpoint did ``payload.get('force', False)`` after
|
||||||
|
``request.get_json(silent=True) or {}``, which raises AttributeError if
|
||||||
|
the client sent a non-object JSON body (e.g. a bare string or array).
|
||||||
|
Additionally, ``bool("false")`` is ``True``, so string-encoded booleans
|
||||||
|
were mis-handled. The fix is an ``isinstance(payload, dict)`` guard plus
|
||||||
|
routing the value through ``_coerce_to_bool``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
project_root = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
|
||||||
|
_API_V3_MOCKED_ATTRS = (
|
||||||
|
'config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||||
|
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
|
||||||
|
'operation_queue', 'operation_history', 'cache_manager',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client():
|
||||||
|
"""Minimal Flask app + mocked deps that exercises the api_v3 blueprint.
|
||||||
|
|
||||||
|
Returns ``(client, module, cleanup_fn)``. Callers (test ``setUp``
|
||||||
|
methods) must register ``cleanup_fn`` with ``self.addCleanup(...)``
|
||||||
|
so the original api_v3 singleton attributes are restored at the end
|
||||||
|
of the test — otherwise the MagicMocks leak into later tests that
|
||||||
|
import api_v3 expecting fresh state.
|
||||||
|
"""
|
||||||
|
from web_interface.blueprints import api_v3 as api_v3_module
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3
|
||||||
|
|
||||||
|
# Snapshot the originals so we can restore them.
|
||||||
|
_SENTINEL = object()
|
||||||
|
originals = {
|
||||||
|
name: getattr(api_v3, name, _SENTINEL) for name in _API_V3_MOCKED_ATTRS
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mocks for all the bits the reconcile / uninstall endpoints touch.
|
||||||
|
api_v3.config_manager = MagicMock()
|
||||||
|
api_v3.config_manager.get_raw_file_content.return_value = {}
|
||||||
|
api_v3.config_manager.secrets_path = "/tmp/nonexistent_secrets.json"
|
||||||
|
api_v3.plugin_manager = MagicMock()
|
||||||
|
api_v3.plugin_manager.plugins = {}
|
||||||
|
api_v3.plugin_manager.plugins_dir = "/tmp"
|
||||||
|
api_v3.plugin_store_manager = MagicMock()
|
||||||
|
api_v3.plugin_state_manager = MagicMock()
|
||||||
|
api_v3.plugin_state_manager.get_all_states.return_value = {}
|
||||||
|
api_v3.saved_repositories_manager = MagicMock()
|
||||||
|
api_v3.schema_manager = MagicMock()
|
||||||
|
api_v3.operation_queue = None # force the direct (non-queue) path
|
||||||
|
api_v3.operation_history = MagicMock()
|
||||||
|
api_v3.cache_manager = MagicMock()
|
||||||
|
|
||||||
|
def _cleanup():
|
||||||
|
for name, original in originals.items():
|
||||||
|
if original is _SENTINEL:
|
||||||
|
# Attribute didn't exist before — remove it to match.
|
||||||
|
if hasattr(api_v3, name):
|
||||||
|
try:
|
||||||
|
delattr(api_v3, name)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
setattr(api_v3, name, original)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.config['SECRET_KEY'] = 'test'
|
||||||
|
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||||
|
return app.test_client(), api_v3_module, _cleanup
|
||||||
|
|
||||||
|
|
||||||
|
class TestTransactionalUninstall(unittest.TestCase):
|
||||||
|
"""Exercises ``_do_transactional_uninstall`` directly.
|
||||||
|
|
||||||
|
Using the direct (non-queue) code path via the Flask client gives us
|
||||||
|
the full uninstall endpoint behavior end-to-end, including the
|
||||||
|
rollback on mid-flight failures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client, self.mod, _cleanup = _make_client()
|
||||||
|
self.addCleanup(_cleanup)
|
||||||
|
self.api_v3 = self.mod.api_v3
|
||||||
|
|
||||||
|
def test_cleanup_failure_aborts_before_file_removal(self):
|
||||||
|
"""If cleanup_plugin_config raises, uninstall_plugin must NOT run."""
|
||||||
|
self.api_v3.config_manager.cleanup_plugin_config.side_effect = RuntimeError("disk full")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/v3/plugins/uninstall',
|
||||||
|
data=json.dumps({'plugin_id': 'thing'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 500)
|
||||||
|
# File removal must NOT have been attempted — otherwise we'd have
|
||||||
|
# deleted the plugin after failing to clean its config, leaving
|
||||||
|
# the reconciler to potentially resurrect it later.
|
||||||
|
self.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called()
|
||||||
|
|
||||||
|
def test_file_removal_failure_restores_snapshot(self):
|
||||||
|
"""If uninstall_plugin returns False after cleanup, snapshot must be restored."""
|
||||||
|
# Start with the plugin in main config and in secrets.
|
||||||
|
stored_main = {'thing': {'enabled': True, 'custom': 'stuff'}}
|
||||||
|
stored_secrets = {'thing': {'api_key': 'secret'}}
|
||||||
|
|
||||||
|
# get_raw_file_content is called twice during snapshot (main +
|
||||||
|
# secrets) and then again during restore. We track writes through
|
||||||
|
# save_raw_file_content so we can assert the restore happened.
|
||||||
|
def raw_get(file_type):
|
||||||
|
if file_type == 'main':
|
||||||
|
return dict(stored_main)
|
||||||
|
if file_type == 'secrets':
|
||||||
|
return dict(stored_secrets)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
self.api_v3.config_manager.get_raw_file_content.side_effect = raw_get
|
||||||
|
self.api_v3.config_manager.secrets_path = __file__ # any existing file
|
||||||
|
self.api_v3.config_manager.cleanup_plugin_config.return_value = None
|
||||||
|
self.api_v3.plugin_store_manager.uninstall_plugin.return_value = False
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/v3/plugins/uninstall',
|
||||||
|
data=json.dumps({'plugin_id': 'thing'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 500)
|
||||||
|
# After the file removal returned False, the helper must have
|
||||||
|
# written the snapshot back. Inspect save_raw_file_content calls.
|
||||||
|
calls = self.api_v3.config_manager.save_raw_file_content.call_args_list
|
||||||
|
file_types_written = [c.args[0] for c in calls]
|
||||||
|
self.assertIn('main', file_types_written,
|
||||||
|
f"main config was not restored after uninstall failure; calls={calls}")
|
||||||
|
# Find the main restore call and confirm our snapshot entry is present.
|
||||||
|
for c in calls:
|
||||||
|
if c.args[0] == 'main':
|
||||||
|
written = c.args[1]
|
||||||
|
self.assertIn('thing', written,
|
||||||
|
"main config was written without the restored snapshot entry")
|
||||||
|
self.assertEqual(written['thing'], stored_main['thing'])
|
||||||
|
break
|
||||||
|
|
||||||
|
def test_file_removal_raising_also_restores_snapshot(self):
|
||||||
|
"""Same restore path, but triggered by an exception instead of False."""
|
||||||
|
stored_main = {'thing': {'enabled': False}}
|
||||||
|
|
||||||
|
def raw_get(file_type):
|
||||||
|
if file_type == 'main':
|
||||||
|
return dict(stored_main)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
self.api_v3.config_manager.get_raw_file_content.side_effect = raw_get
|
||||||
|
self.api_v3.config_manager.cleanup_plugin_config.return_value = None
|
||||||
|
self.api_v3.plugin_store_manager.uninstall_plugin.side_effect = OSError("rm failed")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/v3/plugins/uninstall',
|
||||||
|
data=json.dumps({'plugin_id': 'thing'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 500)
|
||||||
|
calls = self.api_v3.config_manager.save_raw_file_content.call_args_list
|
||||||
|
self.assertTrue(
|
||||||
|
any(c.args[0] == 'main' for c in calls),
|
||||||
|
"main config was not restored after uninstall raised",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_happy_path_succeeds(self):
|
||||||
|
"""Sanity: the transactional rework did not break the happy path."""
|
||||||
|
self.api_v3.config_manager.get_raw_file_content.return_value = {}
|
||||||
|
self.api_v3.config_manager.cleanup_plugin_config.return_value = None
|
||||||
|
self.api_v3.plugin_store_manager.uninstall_plugin.return_value = True
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/v3/plugins/uninstall',
|
||||||
|
data=json.dumps({'plugin_id': 'thing'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.api_v3.plugin_store_manager.uninstall_plugin.assert_called_once_with('thing')
|
||||||
|
|
||||||
|
def test_file_removal_failure_reloads_previously_loaded_plugin(self):
|
||||||
|
"""Regression: rollback must restore BOTH config AND runtime state.
|
||||||
|
|
||||||
|
If the plugin was loaded at runtime before the uninstall
|
||||||
|
request, and file removal fails after unload has already
|
||||||
|
succeeded, the rollback must call ``load_plugin`` so the user
|
||||||
|
doesn't end up in a state where the files exist and the config
|
||||||
|
exists but the plugin is no longer loaded.
|
||||||
|
"""
|
||||||
|
# Plugin is currently loaded.
|
||||||
|
self.api_v3.plugin_manager.plugins = {'thing': MagicMock()}
|
||||||
|
self.api_v3.config_manager.get_raw_file_content.return_value = {
|
||||||
|
'thing': {'enabled': True}
|
||||||
|
}
|
||||||
|
self.api_v3.config_manager.cleanup_plugin_config.return_value = None
|
||||||
|
self.api_v3.plugin_manager.unload_plugin.return_value = None
|
||||||
|
self.api_v3.plugin_store_manager.uninstall_plugin.return_value = False
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/v3/plugins/uninstall',
|
||||||
|
data=json.dumps({'plugin_id': 'thing'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 500)
|
||||||
|
# Unload did happen (it's part of the uninstall sequence)...
|
||||||
|
self.api_v3.plugin_manager.unload_plugin.assert_called_once_with('thing')
|
||||||
|
# ...and because file removal failed, the rollback must have
|
||||||
|
# called load_plugin to restore runtime state.
|
||||||
|
self.api_v3.plugin_manager.load_plugin.assert_called_once_with('thing')
|
||||||
|
|
||||||
|
def test_snapshot_survives_config_read_error(self):
|
||||||
|
"""Regression: if get_raw_file_content raises an expected error
|
||||||
|
(OSError / ConfigError) during snapshot, the uninstall should
|
||||||
|
still proceed — we just won't have a rollback snapshot. Narrow
|
||||||
|
exception list must still cover the realistic failure modes.
|
||||||
|
"""
|
||||||
|
from src.exceptions import ConfigError
|
||||||
|
self.api_v3.config_manager.get_raw_file_content.side_effect = ConfigError(
|
||||||
|
"file missing", config_path="/tmp/missing"
|
||||||
|
)
|
||||||
|
self.api_v3.config_manager.cleanup_plugin_config.return_value = None
|
||||||
|
self.api_v3.plugin_store_manager.uninstall_plugin.return_value = True
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/v3/plugins/uninstall',
|
||||||
|
data=json.dumps({'plugin_id': 'thing'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Uninstall should still succeed — snapshot failure is logged
|
||||||
|
# but doesn't block the uninstall.
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.api_v3.plugin_store_manager.uninstall_plugin.assert_called_once_with('thing')
|
||||||
|
|
||||||
|
def test_snapshot_does_not_swallow_programmer_errors(self):
|
||||||
|
"""Regression: unexpected exceptions (TypeError, AttributeError)
|
||||||
|
must propagate out of the snapshot helper so bugs surface
|
||||||
|
during development instead of being silently logged and
|
||||||
|
ignored. Narrowing from ``except Exception`` to
|
||||||
|
``(OSError, ConfigError)`` is what makes this work.
|
||||||
|
"""
|
||||||
|
# Raise an exception that is NOT in the narrow catch list.
|
||||||
|
self.api_v3.config_manager.get_raw_file_content.side_effect = TypeError(
|
||||||
|
"unexpected kwarg"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/v3/plugins/uninstall',
|
||||||
|
data=json.dumps({'plugin_id': 'thing'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
# The TypeError should propagate up to the endpoint's outer
|
||||||
|
# try/except and produce a 500, NOT be silently swallowed like
|
||||||
|
# the previous ``except Exception`` did.
|
||||||
|
self.assertEqual(response.status_code, 500)
|
||||||
|
# uninstall_plugin must NOT have been called — the snapshot
|
||||||
|
# exception bubbled up before we got that far.
|
||||||
|
self.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called()
|
||||||
|
|
||||||
|
def test_unload_failure_restores_config_and_does_not_call_uninstall(self):
|
||||||
|
"""If unload_plugin itself raises, config must be restored and
|
||||||
|
uninstall_plugin must NOT be called."""
|
||||||
|
self.api_v3.plugin_manager.plugins = {'thing': MagicMock()}
|
||||||
|
self.api_v3.config_manager.get_raw_file_content.return_value = {
|
||||||
|
'thing': {'enabled': True}
|
||||||
|
}
|
||||||
|
self.api_v3.config_manager.cleanup_plugin_config.return_value = None
|
||||||
|
self.api_v3.plugin_manager.unload_plugin.side_effect = RuntimeError("unload boom")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/v3/plugins/uninstall',
|
||||||
|
data=json.dumps({'plugin_id': 'thing'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 500)
|
||||||
|
self.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called()
|
||||||
|
# Config should have been restored.
|
||||||
|
calls = self.api_v3.config_manager.save_raw_file_content.call_args_list
|
||||||
|
self.assertTrue(
|
||||||
|
any(c.args[0] == 'main' for c in calls),
|
||||||
|
"main config was not restored after unload_plugin raised",
|
||||||
|
)
|
||||||
|
# load_plugin must NOT have been called — unload didn't succeed,
|
||||||
|
# so runtime state is still what it was.
|
||||||
|
self.api_v3.plugin_manager.load_plugin.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconcileEndpointPayload(unittest.TestCase):
|
||||||
|
"""``/plugins/state/reconcile`` must handle weird JSON payloads without
|
||||||
|
crashing, and must accept string booleans for ``force``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client, self.mod, _cleanup = _make_client()
|
||||||
|
self.addCleanup(_cleanup)
|
||||||
|
self.api_v3 = self.mod.api_v3
|
||||||
|
# Stub the reconciler so we only test the payload plumbing, not
|
||||||
|
# the full reconciliation. We patch StateReconciliation at the
|
||||||
|
# module level where the endpoint imports it lazily.
|
||||||
|
self._reconciler_instance = MagicMock()
|
||||||
|
self._reconciler_instance.reconcile_state.return_value = MagicMock(
|
||||||
|
inconsistencies_found=[],
|
||||||
|
inconsistencies_fixed=[],
|
||||||
|
inconsistencies_manual=[],
|
||||||
|
message="ok",
|
||||||
|
)
|
||||||
|
# Patch the StateReconciliation class where it's imported inside
|
||||||
|
# the reconcile endpoint.
|
||||||
|
self._patcher = patch(
|
||||||
|
'src.plugin_system.state_reconciliation.StateReconciliation',
|
||||||
|
return_value=self._reconciler_instance,
|
||||||
|
)
|
||||||
|
self._patcher.start()
|
||||||
|
self.addCleanup(self._patcher.stop)
|
||||||
|
|
||||||
|
def _post(self, body, content_type='application/json'):
|
||||||
|
return self.client.post(
|
||||||
|
'/api/v3/plugins/state/reconcile',
|
||||||
|
data=body,
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_object_json_body_does_not_crash(self):
|
||||||
|
"""A bare string JSON body must not raise AttributeError."""
|
||||||
|
response = self._post('"just a string"')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
# force must default to False.
|
||||||
|
self._reconciler_instance.reconcile_state.assert_called_once_with(force=False)
|
||||||
|
|
||||||
|
def test_array_json_body_does_not_crash(self):
|
||||||
|
response = self._post('[1, 2, 3]')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self._reconciler_instance.reconcile_state.assert_called_once_with(force=False)
|
||||||
|
|
||||||
|
def test_null_json_body_does_not_crash(self):
|
||||||
|
response = self._post('null')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self._reconciler_instance.reconcile_state.assert_called_once_with(force=False)
|
||||||
|
|
||||||
|
def test_missing_force_key_defaults_to_false(self):
|
||||||
|
response = self._post('{}')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self._reconciler_instance.reconcile_state.assert_called_once_with(force=False)
|
||||||
|
|
||||||
|
def test_force_true_boolean(self):
|
||||||
|
response = self._post(json.dumps({'force': True}))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self._reconciler_instance.reconcile_state.assert_called_once_with(force=True)
|
||||||
|
|
||||||
|
def test_force_false_boolean(self):
|
||||||
|
response = self._post(json.dumps({'force': False}))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self._reconciler_instance.reconcile_state.assert_called_once_with(force=False)
|
||||||
|
|
||||||
|
def test_force_string_false_coerced_correctly(self):
|
||||||
|
"""``bool("false")`` is ``True`` — _coerce_to_bool must fix that."""
|
||||||
|
response = self._post(json.dumps({'force': 'false'}))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self._reconciler_instance.reconcile_state.assert_called_once_with(force=False)
|
||||||
|
|
||||||
|
def test_force_string_true_coerced_correctly(self):
|
||||||
|
response = self._post(json.dumps({'force': 'true'}))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self._reconciler_instance.reconcile_state.assert_called_once_with(force=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
334
test/test_wifi_manager_ap.py
Normal file
334
test/test_wifi_manager_ap.py
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for WiFi AP mode — src.wifi_manager.
|
||||||
|
|
||||||
|
Each test exercises logic that can be verified through the subprocess calls the
|
||||||
|
manager emits, without requiring root access, hardware, or a running Pi.
|
||||||
|
|
||||||
|
Scenarios covered:
|
||||||
|
1. nmcli AP profile is created with no security parameters (open/passwordless).
|
||||||
|
2. iptables PREROUTING and INPUT rules are added when the nmcli AP starts.
|
||||||
|
3. iptables rules and ip_forward are reverted when the AP is torn down.
|
||||||
|
4. LED matrix message includes the SSID, 'No password', and the setup URL.
|
||||||
|
5. Known AP profile names are deleted before the new profile is created.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _ok(stdout: str = "", stderr: str = "") -> MagicMock:
|
||||||
|
r = MagicMock()
|
||||||
|
r.returncode = 0
|
||||||
|
r.stdout = stdout
|
||||||
|
r.stderr = stderr
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(stdout: str = "", stderr: str = "error") -> MagicMock:
|
||||||
|
r = MagicMock()
|
||||||
|
r.returncode = 1
|
||||||
|
r.stdout = stdout
|
||||||
|
r.stderr = stderr
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _find_path_side_effect(name: str) -> str:
|
||||||
|
"""Deterministic fake for _find_command_path."""
|
||||||
|
return {"iptables": "/usr/sbin/iptables", "sysctl": "/usr/sbin/sysctl"}.get(
|
||||||
|
name, f"/usr/bin/{name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def wifi_config(tmp_path: Path) -> Path:
|
||||||
|
"""Minimal wifi_config.json in a temporary directory."""
|
||||||
|
cfg_dir = tmp_path / "config"
|
||||||
|
cfg_dir.mkdir()
|
||||||
|
cfg = {
|
||||||
|
"ap_ssid": "LEDMatrix-Setup",
|
||||||
|
"ap_channel": 7,
|
||||||
|
"auto_enable_ap_mode": True,
|
||||||
|
"saved_networks": [],
|
||||||
|
}
|
||||||
|
p = cfg_dir / "wifi_config.json"
|
||||||
|
p.write_text(json.dumps(cfg))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def manager(wifi_config: Path, tmp_path: Path) -> WiFiManager:
|
||||||
|
"""
|
||||||
|
WiFiManager with all system calls stubbed out during construction and the
|
||||||
|
ip_forward save file redirected to a per-test temporary path.
|
||||||
|
"""
|
||||||
|
with patch("src.wifi_manager.subprocess.run", return_value=_ok(stdout="wlan0\n")), \
|
||||||
|
patch.object(WiFiManager, "_detect_trixie", return_value=False):
|
||||||
|
mgr = WiFiManager(config_path=wifi_config)
|
||||||
|
|
||||||
|
# Force clean, deterministic state regardless of what __init__ inferred
|
||||||
|
mgr._wifi_interface = "wlan0"
|
||||||
|
mgr.has_nmcli = True
|
||||||
|
mgr.has_hostapd = False
|
||||||
|
mgr.has_dnsmasq = False
|
||||||
|
mgr.has_iwlist = False
|
||||||
|
mgr._is_trixie = False
|
||||||
|
# Redirect the ip_forward save file to tmp so tests never share state
|
||||||
|
mgr._IP_FORWARD_SAVE_PATH = tmp_path / "ip_fwd_saved"
|
||||||
|
return mgr
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. AP profile is open (no password)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_nmcli_ap_profile_has_no_security_params(manager: WiFiManager) -> None:
|
||||||
|
"""
|
||||||
|
The 'nmcli connection add' command must not include key-mgmt, psk, or any
|
||||||
|
WPA-related parameter. On Bookworm/Trixie, NM creates a WPA2-protected
|
||||||
|
hotspot even when those values are set to 'none'/empty via a later
|
||||||
|
'connection modify', so the profile must be created without a security
|
||||||
|
section from the start.
|
||||||
|
"""
|
||||||
|
captured: list[list[str]] = []
|
||||||
|
|
||||||
|
def _run(cmd, **kw):
|
||||||
|
captured.append(list(cmd))
|
||||||
|
return _ok()
|
||||||
|
|
||||||
|
with patch("src.wifi_manager.subprocess.run", side_effect=_run), \
|
||||||
|
patch.object(manager, "disconnect_from_network", return_value=(True, "ok")), \
|
||||||
|
patch.object(manager, "_setup_iptables_redirect", return_value=True), \
|
||||||
|
patch.object(manager, "_get_ap_status_nmcli",
|
||||||
|
return_value={"active": True, "ip": "192.168.4.1"}), \
|
||||||
|
patch.object(manager, "_show_led_message"):
|
||||||
|
|
||||||
|
success, _ = manager._enable_ap_mode_nmcli_hotspot()
|
||||||
|
|
||||||
|
assert success, "AP enable should report success"
|
||||||
|
|
||||||
|
add_calls = [c for c in captured if "nmcli" in c and "connection" in c and "add" in c]
|
||||||
|
assert add_calls, "Expected at least one 'nmcli connection add' invocation"
|
||||||
|
|
||||||
|
add_str = " ".join(add_calls[0])
|
||||||
|
assert "key-mgmt" not in add_str, "AP profile must not set key-mgmt"
|
||||||
|
assert "psk" not in add_str, "AP profile must not include a PSK/password"
|
||||||
|
assert "wpa" not in add_str.lower(), "AP profile must not reference WPA"
|
||||||
|
assert "802-11-wireless.mode" in add_str, "AP profile must declare wireless mode"
|
||||||
|
# Verify the value for 802-11-wireless.mode is exactly "ap" — check the element
|
||||||
|
# that immediately follows the key in the command list, not a loose substring match.
|
||||||
|
cmd = add_calls[0]
|
||||||
|
try:
|
||||||
|
mode_idx = cmd.index("802-11-wireless.mode")
|
||||||
|
assert cmd[mode_idx + 1] == "ap", \
|
||||||
|
f"802-11-wireless.mode value must be exactly 'ap', got {cmd[mode_idx + 1]!r}"
|
||||||
|
except ValueError:
|
||||||
|
pytest.fail("802-11-wireless.mode not found as a list element in nmcli command")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. iptables NAT rules are added when the AP starts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_iptables_nat_rules_added_on_ap_start(manager: WiFiManager) -> None:
|
||||||
|
"""
|
||||||
|
_setup_iptables_redirect must add:
|
||||||
|
- a PREROUTING REDIRECT rule that maps incoming TCP port 80 to port 5000, and
|
||||||
|
- an INPUT ACCEPT rule for port 5000 (the post-redirect destination port,
|
||||||
|
NOT port 80 which never hits the INPUT chain after PREROUTING rewrites it).
|
||||||
|
"""
|
||||||
|
captured: list[list[str]] = []
|
||||||
|
|
||||||
|
def _run(cmd, **kw):
|
||||||
|
captured.append(list(cmd))
|
||||||
|
# iptables -C (check) → rc=1 so the -A (add) branch executes
|
||||||
|
if "iptables" in " ".join(str(x) for x in cmd) and "-C" in cmd:
|
||||||
|
return _fail()
|
||||||
|
return _ok()
|
||||||
|
|
||||||
|
# Patch Path.read_text so /proc/sys/net/ipv4/ip_forward is readable on any OS
|
||||||
|
with patch("src.wifi_manager.subprocess.run", side_effect=_run), \
|
||||||
|
patch.object(manager, "_find_command_path", side_effect=_find_path_side_effect), \
|
||||||
|
patch("pathlib.Path.read_text", return_value="0\n"):
|
||||||
|
|
||||||
|
result = manager._setup_iptables_redirect()
|
||||||
|
|
||||||
|
assert result, "_setup_iptables_redirect must return True on success"
|
||||||
|
|
||||||
|
prerouting_adds = [c for c in captured if "iptables" in " ".join(c) and "-A" in c and "PREROUTING" in c]
|
||||||
|
assert prerouting_adds, "Expected 'iptables -A PREROUTING' invocation"
|
||||||
|
pr_str = " ".join(prerouting_adds[0])
|
||||||
|
assert "--dport" in pr_str and "80" in pr_str, "PREROUTING rule must match dport 80"
|
||||||
|
assert "5000" in pr_str, "PREROUTING rule must redirect to port 5000"
|
||||||
|
assert "REDIRECT" in pr_str, "PREROUTING rule must use REDIRECT target"
|
||||||
|
|
||||||
|
input_adds = [c for c in captured if "iptables" in " ".join(c) and "-A" in c and "INPUT" in c]
|
||||||
|
assert input_adds, "Expected 'iptables -A INPUT' invocation"
|
||||||
|
in_str = " ".join(input_adds[0])
|
||||||
|
assert "5000" in in_str, "INPUT rule must accept port 5000 (post-PREROUTING destination)"
|
||||||
|
assert "ACCEPT" in in_str, "INPUT rule must use ACCEPT target"
|
||||||
|
|
||||||
|
# Port 80 must NOT be used in the INPUT rule (it is already redirected by PREROUTING)
|
||||||
|
input_80 = [c for c in captured if "iptables" in " ".join(c) and "INPUT" in c and "--dport" in c and "80" in c]
|
||||||
|
assert not input_80, "INPUT rule must target port 5000, not port 80"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3a. iptables rules and ip_forward reverted on teardown
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_iptables_rules_and_ip_forward_reverted_on_teardown(manager: WiFiManager) -> None:
|
||||||
|
"""
|
||||||
|
_teardown_iptables_redirect must:
|
||||||
|
- remove the PREROUTING and INPUT iptables rules, and
|
||||||
|
- restore ip_forward to the exact value recorded in the save file.
|
||||||
|
"""
|
||||||
|
original_fwd = "0"
|
||||||
|
manager._IP_FORWARD_SAVE_PATH.write_text(original_fwd)
|
||||||
|
# Teardown dispatches on the backend recorded during setup
|
||||||
|
manager._redirect_backend = "iptables"
|
||||||
|
|
||||||
|
captured: list[list[str]] = []
|
||||||
|
|
||||||
|
with patch("src.wifi_manager.subprocess.run",
|
||||||
|
side_effect=lambda cmd, **kw: (captured.append(list(cmd)) or _ok())), \
|
||||||
|
patch.object(manager, "_find_command_path", side_effect=_find_path_side_effect):
|
||||||
|
|
||||||
|
manager._teardown_iptables_redirect()
|
||||||
|
|
||||||
|
assert [c for c in captured if "iptables" in " ".join(c) and "-D" in c and "PREROUTING" in c], \
|
||||||
|
"Expected 'iptables -D PREROUTING' invocation"
|
||||||
|
|
||||||
|
assert [c for c in captured if "iptables" in " ".join(c) and "-D" in c and "INPUT" in c], \
|
||||||
|
"Expected 'iptables -D INPUT' invocation"
|
||||||
|
|
||||||
|
restore_calls = [
|
||||||
|
c for c in captured
|
||||||
|
if "sysctl" in " ".join(c) and f"ip_forward={original_fwd}" in " ".join(c)
|
||||||
|
]
|
||||||
|
assert restore_calls, f"Expected sysctl to restore ip_forward to {original_fwd!r}"
|
||||||
|
|
||||||
|
assert not manager._IP_FORWARD_SAVE_PATH.exists(), \
|
||||||
|
"Save file must be removed after successful teardown"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3b. ip_forward untouched when no save file exists
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_ip_forward_not_restored_when_save_file_absent(manager: WiFiManager) -> None:
|
||||||
|
"""
|
||||||
|
When the save file is missing (setup never wrote it, e.g. because /proc was
|
||||||
|
unreadable or the write failed), teardown must NOT call sysctl so it does not
|
||||||
|
accidentally clobber ip_forward state owned by a VPN or NetworkManager.
|
||||||
|
"""
|
||||||
|
assert not manager._IP_FORWARD_SAVE_PATH.exists()
|
||||||
|
|
||||||
|
captured: list[list[str]] = []
|
||||||
|
|
||||||
|
with patch("src.wifi_manager.subprocess.run",
|
||||||
|
side_effect=lambda cmd, **kw: (captured.append(list(cmd)) or _ok())), \
|
||||||
|
patch.object(manager, "_find_command_path", side_effect=_find_path_side_effect):
|
||||||
|
|
||||||
|
manager._teardown_iptables_redirect()
|
||||||
|
|
||||||
|
sysctl_calls = [
|
||||||
|
c for c in captured
|
||||||
|
if "sysctl" in " ".join(c) and "ip_forward" in " ".join(c)
|
||||||
|
]
|
||||||
|
assert not sysctl_calls, \
|
||||||
|
"sysctl must not be called when no ip_forward save file exists"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. LED message content
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_led_message_shows_ssid_no_password_and_url(manager: WiFiManager) -> None:
|
||||||
|
"""
|
||||||
|
When the nmcli AP activates, the LED message must include:
|
||||||
|
- the AP SSID ('LEDMatrix-Setup')
|
||||||
|
- the string 'No password'
|
||||||
|
- the AP IP address (192.168.4.1) and Flask port (5000)
|
||||||
|
"""
|
||||||
|
led_messages: list[str] = []
|
||||||
|
|
||||||
|
with patch("src.wifi_manager.subprocess.run", return_value=_ok()), \
|
||||||
|
patch.object(manager, "disconnect_from_network", return_value=(True, "ok")), \
|
||||||
|
patch.object(manager, "_setup_iptables_redirect", return_value=True), \
|
||||||
|
patch.object(manager, "_get_ap_status_nmcli",
|
||||||
|
return_value={"active": True, "ip": "192.168.4.1"}), \
|
||||||
|
patch.object(manager, "_show_led_message",
|
||||||
|
side_effect=lambda msg, **kw: led_messages.append(msg)):
|
||||||
|
|
||||||
|
success, _ = manager._enable_ap_mode_nmcli_hotspot()
|
||||||
|
|
||||||
|
assert success, "AP enable should report success"
|
||||||
|
assert led_messages, "Expected at least one _show_led_message call"
|
||||||
|
|
||||||
|
combined = "\n".join(led_messages)
|
||||||
|
assert "No password" in combined, "LED message must say 'No password'"
|
||||||
|
assert "LEDMatrix-Setup" in combined, "LED message must include the AP SSID"
|
||||||
|
assert "192.168.4.1" in combined, "LED message must include the AP IP address"
|
||||||
|
assert "5000" in combined, "LED message must include the Flask port"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Stale AP profiles deleted before the new one is created
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_existing_ap_profiles_deleted_before_new_profile_created(manager: WiFiManager) -> None:
|
||||||
|
"""
|
||||||
|
Before 'nmcli connection add', the manager must issue
|
||||||
|
'nmcli connection down/delete' for every known AP profile name so stale
|
||||||
|
profiles (from a previous crash or partial setup) cannot block the new one.
|
||||||
|
"""
|
||||||
|
captured: list[list[str]] = []
|
||||||
|
|
||||||
|
def _run(cmd, **kw):
|
||||||
|
captured.append(list(cmd))
|
||||||
|
return _ok()
|
||||||
|
|
||||||
|
with patch("src.wifi_manager.subprocess.run", side_effect=_run), \
|
||||||
|
patch.object(manager, "disconnect_from_network", return_value=(True, "ok")), \
|
||||||
|
patch.object(manager, "_setup_iptables_redirect", return_value=True), \
|
||||||
|
patch.object(manager, "_get_ap_status_nmcli",
|
||||||
|
return_value={"active": True, "ip": "192.168.4.1"}), \
|
||||||
|
patch.object(manager, "_show_led_message"):
|
||||||
|
|
||||||
|
success, _ = manager._enable_ap_mode_nmcli_hotspot()
|
||||||
|
|
||||||
|
assert success
|
||||||
|
|
||||||
|
cmd_strs = [" ".join(c) for c in captured]
|
||||||
|
|
||||||
|
for profile in ("LEDMatrix-Setup-AP", "Hotspot", "TickerSetup-AP"):
|
||||||
|
assert any("connection delete" in s and profile in s for s in cmd_strs), \
|
||||||
|
f"Expected 'nmcli connection delete {profile}' before creating the new profile"
|
||||||
|
|
||||||
|
add_indices = [i for i, s in enumerate(cmd_strs) if "connection add" in s]
|
||||||
|
del_indices = [i for i, s in enumerate(cmd_strs) if "connection delete" in s]
|
||||||
|
|
||||||
|
assert add_indices, "Expected 'nmcli connection add' call"
|
||||||
|
assert del_indices, "Expected 'nmcli connection delete' calls"
|
||||||
|
assert max(del_indices) < min(add_indices), \
|
||||||
|
"All connection deletions must complete before the new profile is created"
|
||||||
@@ -342,6 +342,167 @@ class TestStateReconciliation(unittest.TestCase):
|
|||||||
self.assertEqual(state, {})
|
self.assertEqual(state, {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestStateReconciliationUnrecoverable(unittest.TestCase):
|
||||||
|
"""Tests for the unrecoverable-plugin cache and force reconcile.
|
||||||
|
|
||||||
|
Regression coverage for the infinite reinstall loop where a config
|
||||||
|
entry referenced a plugin not present in the registry (e.g. legacy
|
||||||
|
'github' / 'youtube' entries). The reconciler used to retry the
|
||||||
|
install on every HTTP request; it now caches the failure for the
|
||||||
|
process lifetime and only retries on an explicit ``force=True``
|
||||||
|
reconcile call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = Path(tempfile.mkdtemp())
|
||||||
|
self.plugins_dir = self.temp_dir / "plugins"
|
||||||
|
self.plugins_dir.mkdir()
|
||||||
|
|
||||||
|
self.state_manager = Mock(spec=PluginStateManager)
|
||||||
|
self.state_manager.get_all_states.return_value = {}
|
||||||
|
self.config_manager = Mock()
|
||||||
|
self.config_manager.load_config.return_value = {
|
||||||
|
"ghost": {"enabled": True}
|
||||||
|
}
|
||||||
|
self.plugin_manager = Mock()
|
||||||
|
self.plugin_manager.plugin_manifests = {}
|
||||||
|
self.plugin_manager.plugins = {}
|
||||||
|
|
||||||
|
# Store manager with an empty registry — install_plugin always fails
|
||||||
|
self.store_manager = Mock()
|
||||||
|
self.store_manager.fetch_registry.return_value = {"plugins": []}
|
||||||
|
self.store_manager.install_plugin.return_value = False
|
||||||
|
self.store_manager.was_recently_uninstalled.return_value = False
|
||||||
|
|
||||||
|
self.reconciler = StateReconciliation(
|
||||||
|
state_manager=self.state_manager,
|
||||||
|
config_manager=self.config_manager,
|
||||||
|
plugin_manager=self.plugin_manager,
|
||||||
|
plugins_dir=self.plugins_dir,
|
||||||
|
store_manager=self.store_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.temp_dir)
|
||||||
|
|
||||||
|
def test_not_in_registry_marks_unrecoverable_without_install(self):
|
||||||
|
"""If the plugin isn't in the registry at all, skip install_plugin."""
|
||||||
|
result = self.reconciler.reconcile_state()
|
||||||
|
|
||||||
|
# One inconsistency, unfixable, no install attempt made.
|
||||||
|
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||||
|
self.assertEqual(len(result.inconsistencies_fixed), 0)
|
||||||
|
self.store_manager.install_plugin.assert_not_called()
|
||||||
|
self.assertIn("ghost", self.reconciler._unrecoverable_missing_on_disk)
|
||||||
|
|
||||||
|
def test_subsequent_reconcile_does_not_retry(self):
|
||||||
|
"""Second reconcile pass must not touch install_plugin or fetch_registry again."""
|
||||||
|
self.reconciler.reconcile_state()
|
||||||
|
self.store_manager.fetch_registry.reset_mock()
|
||||||
|
self.store_manager.install_plugin.reset_mock()
|
||||||
|
|
||||||
|
result = self.reconciler.reconcile_state()
|
||||||
|
|
||||||
|
# Still one inconsistency, still no install attempt, no new registry fetch
|
||||||
|
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||||
|
inc = result.inconsistencies_found[0]
|
||||||
|
self.assertFalse(inc.can_auto_fix)
|
||||||
|
self.assertEqual(inc.fix_action, FixAction.MANUAL_FIX_REQUIRED)
|
||||||
|
self.store_manager.install_plugin.assert_not_called()
|
||||||
|
self.store_manager.fetch_registry.assert_not_called()
|
||||||
|
|
||||||
|
def test_force_reconcile_clears_unrecoverable_cache(self):
|
||||||
|
"""force=True must re-attempt previously-failed plugins."""
|
||||||
|
self.reconciler.reconcile_state()
|
||||||
|
self.assertIn("ghost", self.reconciler._unrecoverable_missing_on_disk)
|
||||||
|
|
||||||
|
# Now pretend the registry gained the plugin so the pre-check passes
|
||||||
|
# and install_plugin is actually invoked.
|
||||||
|
self.store_manager.fetch_registry.return_value = {
|
||||||
|
"plugins": [{"id": "ghost"}]
|
||||||
|
}
|
||||||
|
self.store_manager.install_plugin.return_value = True
|
||||||
|
self.store_manager.install_plugin.reset_mock()
|
||||||
|
|
||||||
|
# Config still references ghost; disk still missing it — the
|
||||||
|
# reconciler should re-attempt install now that force=True cleared
|
||||||
|
# the cache. Use assert_called_once_with so a future regression
|
||||||
|
# that accidentally triggers a second install attempt on force=True
|
||||||
|
# is caught.
|
||||||
|
result = self.reconciler.reconcile_state(force=True)
|
||||||
|
|
||||||
|
self.store_manager.install_plugin.assert_called_once_with("ghost")
|
||||||
|
|
||||||
|
def test_registry_unreachable_does_not_mark_unrecoverable(self):
|
||||||
|
"""Transient registry failures should not poison the cache."""
|
||||||
|
self.store_manager.fetch_registry.side_effect = Exception("network down")
|
||||||
|
|
||||||
|
result = self.reconciler.reconcile_state()
|
||||||
|
|
||||||
|
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||||
|
self.assertNotIn("ghost", self.reconciler._unrecoverable_missing_on_disk)
|
||||||
|
self.store_manager.install_plugin.assert_not_called()
|
||||||
|
|
||||||
|
def test_recently_uninstalled_skips_auto_repair(self):
|
||||||
|
"""A freshly-uninstalled plugin must not be resurrected by the reconciler."""
|
||||||
|
self.store_manager.was_recently_uninstalled.return_value = True
|
||||||
|
self.store_manager.fetch_registry.return_value = {
|
||||||
|
"plugins": [{"id": "ghost"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = self.reconciler.reconcile_state()
|
||||||
|
|
||||||
|
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||||
|
inc = result.inconsistencies_found[0]
|
||||||
|
self.assertFalse(inc.can_auto_fix)
|
||||||
|
self.assertEqual(inc.fix_action, FixAction.MANUAL_FIX_REQUIRED)
|
||||||
|
self.store_manager.install_plugin.assert_not_called()
|
||||||
|
|
||||||
|
def test_real_store_manager_empty_registry_on_network_failure(self):
|
||||||
|
"""Regression: using the REAL PluginStoreManager (not a Mock), verify
|
||||||
|
the reconciler does NOT poison the unrecoverable cache when
|
||||||
|
``fetch_registry`` fails with no stale cache available.
|
||||||
|
|
||||||
|
Previously, the default stale-cache fallback in ``fetch_registry``
|
||||||
|
silently returned ``{"plugins": []}`` on network failure with no
|
||||||
|
cache. The reconciler's ``_auto_repair_missing_plugin`` saw "no
|
||||||
|
candidates in registry" and marked everything unrecoverable — a
|
||||||
|
regression that would bite every user doing a fresh boot on flaky
|
||||||
|
WiFi. The fix is ``fetch_registry(raise_on_failure=True)`` in
|
||||||
|
``_auto_repair_missing_plugin`` so the reconciler can tell a real
|
||||||
|
registry miss from a network error.
|
||||||
|
"""
|
||||||
|
from src.plugin_system.store_manager import PluginStoreManager
|
||||||
|
import requests as real_requests
|
||||||
|
|
||||||
|
real_store = PluginStoreManager(plugins_dir=str(self.plugins_dir))
|
||||||
|
real_store.registry_cache = None # fresh boot, no cache
|
||||||
|
real_store.registry_cache_time = None
|
||||||
|
|
||||||
|
# Stub the underlying HTTP so no real network call is made but the
|
||||||
|
# real fetch_registry code path runs.
|
||||||
|
real_store._http_get_with_retries = Mock(
|
||||||
|
side_effect=real_requests.ConnectionError("wifi down")
|
||||||
|
)
|
||||||
|
|
||||||
|
reconciler = StateReconciliation(
|
||||||
|
state_manager=self.state_manager,
|
||||||
|
config_manager=self.config_manager,
|
||||||
|
plugin_manager=self.plugin_manager,
|
||||||
|
plugins_dir=self.plugins_dir,
|
||||||
|
store_manager=real_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = reconciler.reconcile_state()
|
||||||
|
|
||||||
|
# One inconsistency (ghost is in config, not on disk), but
|
||||||
|
# because the registry lookup failed transiently, we must NOT
|
||||||
|
# have marked it unrecoverable — a later reconcile (after the
|
||||||
|
# network comes back) can still auto-repair.
|
||||||
|
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||||
|
self.assertNotIn("ghost", reconciler._unrecoverable_missing_on_disk)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|||||||
@@ -667,8 +667,20 @@ import threading as _threading
|
|||||||
_reconciliation_lock = _threading.Lock()
|
_reconciliation_lock = _threading.Lock()
|
||||||
|
|
||||||
def _run_startup_reconciliation() -> None:
|
def _run_startup_reconciliation() -> None:
|
||||||
"""Run state reconciliation in background to auto-repair missing plugins."""
|
"""Run state reconciliation in background to auto-repair missing plugins.
|
||||||
global _reconciliation_done, _reconciliation_started
|
|
||||||
|
Reconciliation runs exactly once per process lifetime, regardless of
|
||||||
|
whether every inconsistency could be auto-fixed. Previously, a failed
|
||||||
|
auto-repair (e.g. a config entry referencing a plugin that no longer
|
||||||
|
exists in the registry) would reset ``_reconciliation_started`` to False,
|
||||||
|
causing the ``@app.before_request`` hook to re-trigger reconciliation on
|
||||||
|
every single HTTP request — an infinite install-retry loop that pegged
|
||||||
|
the CPU and flooded the log. Unresolved issues are now left in place for
|
||||||
|
the user to address via the UI; the reconciler itself also caches
|
||||||
|
per-plugin unrecoverable failures internally so repeated reconcile calls
|
||||||
|
stay cheap.
|
||||||
|
"""
|
||||||
|
global _reconciliation_done
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
_logger = get_logger('reconciliation')
|
_logger = get_logger('reconciliation')
|
||||||
|
|
||||||
@@ -684,18 +696,22 @@ def _run_startup_reconciliation() -> None:
|
|||||||
result = reconciler.reconcile_state()
|
result = reconciler.reconcile_state()
|
||||||
if result.inconsistencies_found:
|
if result.inconsistencies_found:
|
||||||
_logger.info("[Reconciliation] %s", result.message)
|
_logger.info("[Reconciliation] %s", result.message)
|
||||||
if result.reconciliation_successful:
|
if result.inconsistencies_fixed:
|
||||||
if result.inconsistencies_fixed:
|
plugin_manager.discover_plugins()
|
||||||
plugin_manager.discover_plugins()
|
if not result.reconciliation_successful:
|
||||||
_reconciliation_done = True
|
_logger.warning(
|
||||||
else:
|
"[Reconciliation] Finished with %d unresolved issue(s); "
|
||||||
_logger.warning("[Reconciliation] Finished with unresolved issues, will retry")
|
"will not retry automatically. Use the Plugin Store or the "
|
||||||
with _reconciliation_lock:
|
"manual 'Reconcile' action to resolve.",
|
||||||
_reconciliation_started = False
|
len(result.inconsistencies_manual),
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
||||||
with _reconciliation_lock:
|
finally:
|
||||||
_reconciliation_started = False
|
# Always mark done — we do not want an unhandled exception (or an
|
||||||
|
# unresolved inconsistency) to cause the @before_request hook to
|
||||||
|
# retrigger reconciliation on every subsequent request.
|
||||||
|
_reconciliation_done = True
|
||||||
|
|
||||||
# Initialize health monitor and run reconciliation on first request
|
# Initialize health monitor and run reconciliation on first request
|
||||||
@app.before_request
|
@app.before_request
|
||||||
@@ -710,4 +726,6 @@ def check_health_monitor():
|
|||||||
_threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
|
_threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
# threaded=True is Flask's default since 1.0 but stated explicitly so that
|
||||||
|
# long-lived /api/v3/stream/* SSE connections don't starve other requests.
|
||||||
|
app.run(host='0.0.0.0', port=5000, debug=True, threaded=True)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from html import escape as html_escape
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from src.web_interface.secret_helpers import mask_secret_fields
|
from src.web_interface.secret_helpers import mask_secret_fields
|
||||||
|
|
||||||
@@ -76,6 +77,8 @@ def load_partial(partial_name):
|
|||||||
return _load_logs_partial()
|
return _load_logs_partial()
|
||||||
elif partial_name == 'raw-json':
|
elif partial_name == 'raw-json':
|
||||||
return _load_raw_json_partial()
|
return _load_raw_json_partial()
|
||||||
|
elif partial_name == 'backup-restore':
|
||||||
|
return _load_backup_restore_partial()
|
||||||
elif partial_name == 'wifi':
|
elif partial_name == 'wifi':
|
||||||
return _load_wifi_partial()
|
return _load_wifi_partial()
|
||||||
elif partial_name == 'cache':
|
elif partial_name == 'cache':
|
||||||
@@ -296,6 +299,13 @@ def _load_raw_json_partial():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error: {str(e)}", 500
|
return f"Error: {str(e)}", 500
|
||||||
|
|
||||||
|
def _load_backup_restore_partial():
|
||||||
|
"""Load backup & restore partial."""
|
||||||
|
try:
|
||||||
|
return render_template('v3/partials/backup_restore.html')
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {str(e)}", 500
|
||||||
|
|
||||||
@pages_v3.route('/setup')
|
@pages_v3.route('/setup')
|
||||||
def captive_setup():
|
def captive_setup():
|
||||||
"""Lightweight captive portal setup page — self-contained, no frameworks."""
|
"""Lightweight captive portal setup page — self-contained, no frameworks."""
|
||||||
@@ -345,7 +355,7 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
||||||
|
|
||||||
if not plugin_info:
|
if not plugin_info:
|
||||||
return f'<div class="text-red-500 p-4">Plugin "{plugin_id}" not found</div>', 404
|
return f'<div class="text-red-500 p-4">Plugin "{html_escape(plugin_id)}" not found</div>', 404
|
||||||
|
|
||||||
# Get plugin instance (may be None if not loaded)
|
# Get plugin instance (may be None if not loaded)
|
||||||
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
||||||
|
|||||||
@@ -120,7 +120,11 @@ def main():
|
|||||||
|
|
||||||
# Run the web server with error handling for client disconnections
|
# Run the web server with error handling for client disconnections
|
||||||
try:
|
try:
|
||||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
# threaded=True is Flask's default since 1.0, but set it explicitly
|
||||||
|
# so it's self-documenting: the two /api/v3/stream/* SSE endpoints
|
||||||
|
# hold long-lived connections and would starve other requests under
|
||||||
|
# a single-threaded server.
|
||||||
|
app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)
|
||||||
except (OSError, BrokenPipeError) as e:
|
except (OSError, BrokenPipeError) as e:
|
||||||
# Suppress non-critical socket errors (client disconnections)
|
# Suppress non-critical socket errors (client disconnections)
|
||||||
if isinstance(e, OSError) and e.errno in (113, 32, 104): # No route to host, Broken pipe, Connection reset
|
if isinstance(e, OSError) and e.errno in (113, 32, 104): # No route to host, Broken pipe, Connection reset
|
||||||
|
|||||||
@@ -1004,3 +1004,39 @@ button.bg-white {
|
|||||||
[data-theme="dark"] .theme-toggle-btn {
|
[data-theme="dark"] .theme-toggle-btn {
|
||||||
color: #fbbf24;
|
color: #fbbf24;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Update available banner */
|
||||||
|
.update-banner {
|
||||||
|
background-color: #eff6ff;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
.update-banner-action {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.update-banner-action:hover {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
.update-banner-dismiss {
|
||||||
|
color: #1e40af;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.update-banner-dismiss:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .update-banner {
|
||||||
|
background-color: #1e293b;
|
||||||
|
border-color: #334155;
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .update-banner-action {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .update-banner-action:hover {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .update-banner-dismiss {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
const xOptions = config['x-options'] || config['x_options'] || {};
|
const xOptions = config['x-options'] || config['x_options'] || {};
|
||||||
const requestedFormat = xOptions.format || 'long';
|
const requestedFormat = xOptions.format || 'long';
|
||||||
// Validate format exists in DAY_LABELS, default to 'long' if not
|
// Validate format exists in DAY_LABELS, default to 'long' if not
|
||||||
const format = DAY_LABELS.hasOwnProperty(requestedFormat) ? requestedFormat : 'long';
|
const format = Object.prototype.hasOwnProperty.call(DAY_LABELS, requestedFormat) ? requestedFormat : 'long';
|
||||||
const layout = xOptions.layout || 'horizontal';
|
const layout = xOptions.layout || 'horizontal';
|
||||||
const showSelectAll = xOptions.selectAll !== false;
|
const showSelectAll = xOptions.selectAll !== false;
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,7 @@
|
|||||||
}
|
}
|
||||||
notifyFn(`Upload error: ${error.message}`, 'error');
|
notifyFn(`Upload error: ${error.message}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
|
const fileInput = document.getElementById(`${fieldId}_file_input`);
|
||||||
if (fileInput) fileInput.value = '';
|
if (fileInput) fileInput.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -898,6 +898,10 @@ window.currentPluginConfig = null;
|
|||||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||||
let storeFilteredList = [];
|
let storeFilteredList = [];
|
||||||
|
|
||||||
|
function storeCacheExpired() {
|
||||||
|
return !cacheTimestamp || (Date.now() - cacheTimestamp >= CACHE_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Plugin Store Filter State ───────────────────────────────────────────
|
// ── Plugin Store Filter State ───────────────────────────────────────────
|
||||||
const storeFilterState = {
|
const storeFilterState = {
|
||||||
sort: safeLocalStorage.getItem('storeSort') || 'a-z',
|
sort: safeLocalStorage.getItem('storeSort') || 'a-z',
|
||||||
@@ -1165,9 +1169,11 @@ function initializePluginPageWhenReady() {
|
|||||||
if (target.id === 'plugins-content' ||
|
if (target.id === 'plugins-content' ||
|
||||||
target.querySelector('#installed-plugins-grid')) {
|
target.querySelector('#installed-plugins-grid')) {
|
||||||
console.log('HTMX swap detected for plugins, initializing...');
|
console.log('HTMX swap detected for plugins, initializing...');
|
||||||
// Reset initialization flag to allow re-initialization after HTMX swap
|
// Reset all initialization flags so the fresh empty DOM gets populated
|
||||||
window.pluginManager.initialized = false;
|
window.pluginManager.initialized = false;
|
||||||
window.pluginManager.initializing = false;
|
window.pluginManager.initializing = false;
|
||||||
|
window.pluginManager._reswap = true; // signal: use cached store, don't re-fetch GitHub
|
||||||
|
pluginsInitialized = false;
|
||||||
initTimer = setTimeout(attemptInit, 100);
|
initTimer = setTimeout(attemptInit, 100);
|
||||||
}
|
}
|
||||||
}, { once: false }); // Allow multiple swaps
|
}, { once: false }); // Allow multiple swaps
|
||||||
@@ -1211,9 +1217,19 @@ function initializePlugins() {
|
|||||||
console.warn('[INIT] checkGitHubAuthStatus not available yet');
|
console.warn('[INIT] checkGitHubAuthStatus not available yet');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load both installed plugins and plugin store
|
// Load both installed plugins and plugin store.
|
||||||
loadInstalledPlugins();
|
// On HTMX re-swaps with a still-warm cache, skip GitHub metadata to avoid
|
||||||
searchPluginStore(true); // Load plugin store with fresh metadata from GitHub
|
// re-hitting the API on every tab switch. If the cache TTL has expired even
|
||||||
|
// during a re-swap, fetch fresh data including GitHub commit/version info.
|
||||||
|
const isReswapWarm = !!window.pluginManager._reswap && !storeCacheExpired();
|
||||||
|
window.pluginManager._reswap = false;
|
||||||
|
// Await the installed-plugins fetch so window.installedPlugins is populated before
|
||||||
|
// searchPluginStore renders Installed/Reinstall badges against it.
|
||||||
|
loadInstalledPlugins().then(() => {
|
||||||
|
searchPluginStore(!isReswapWarm);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[PluginStore] loadInstalledPlugins failed:', err);
|
||||||
|
});
|
||||||
|
|
||||||
// Setup search functionality (with guard against duplicate listeners)
|
// Setup search functionality (with guard against duplicate listeners)
|
||||||
const searchInput = document.getElementById('plugin-search');
|
const searchInput = document.getElementById('plugin-search');
|
||||||
@@ -5127,10 +5143,13 @@ function refreshPlugins() {
|
|||||||
pluginStoreCache = null;
|
pluginStoreCache = null;
|
||||||
cacheTimestamp = null;
|
cacheTimestamp = null;
|
||||||
|
|
||||||
loadInstalledPlugins();
|
// refreshInstalledPlugins() is async (returns a Promise via loadInstalledPlugins).
|
||||||
// Fetch latest metadata from GitHub when refreshing
|
// Only search the store and notify after window.installedPlugins is updated so
|
||||||
searchPluginStore(true);
|
// that Installed/Reinstall badges reflect the freshly fetched state.
|
||||||
showNotification('Plugins refreshed with latest metadata from GitHub', 'success');
|
refreshInstalledPlugins().then(() => {
|
||||||
|
searchPluginStore(true);
|
||||||
|
showNotification('Plugins refreshed with latest metadata from GitHub', 'success');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function restartDisplay() {
|
function restartDisplay() {
|
||||||
@@ -7161,6 +7180,13 @@ window.getSchemaProperty = getSchemaProperty;
|
|||||||
window.escapeHtml = escapeHtml;
|
window.escapeHtml = escapeHtml;
|
||||||
window.escapeAttribute = escapeAttribute;
|
window.escapeAttribute = escapeAttribute;
|
||||||
|
|
||||||
|
// Expose GitHub install handlers. These must be assigned inside the IIFE —
|
||||||
|
// from outside the IIFE, `typeof attachInstallButtonHandler` evaluates to
|
||||||
|
// 'undefined' and the fallback path at the bottom of this file fires a
|
||||||
|
// [FALLBACK] attachInstallButtonHandler not available on window warning.
|
||||||
|
window.attachInstallButtonHandler = attachInstallButtonHandler;
|
||||||
|
window.setupGitHubInstallHandlers = setupGitHubInstallHandlers;
|
||||||
|
|
||||||
})(); // End IIFE
|
})(); // End IIFE
|
||||||
|
|
||||||
// Functions to handle array-of-objects
|
// Functions to handle array-of-objects
|
||||||
@@ -7390,16 +7416,8 @@ if (typeof loadInstalledPlugins !== 'undefined') {
|
|||||||
if (typeof renderInstalledPlugins !== 'undefined') {
|
if (typeof renderInstalledPlugins !== 'undefined') {
|
||||||
window.renderInstalledPlugins = renderInstalledPlugins;
|
window.renderInstalledPlugins = renderInstalledPlugins;
|
||||||
}
|
}
|
||||||
// Expose GitHub install handlers for debugging and manual testing
|
// GitHub install handlers are now exposed inside the IIFE (see above).
|
||||||
if (typeof setupGitHubInstallHandlers !== 'undefined') {
|
// searchPluginStore is also exposed inside the IIFE after its definition.
|
||||||
window.setupGitHubInstallHandlers = setupGitHubInstallHandlers;
|
|
||||||
console.log('[GLOBAL] setupGitHubInstallHandlers exposed to window');
|
|
||||||
}
|
|
||||||
if (typeof attachInstallButtonHandler !== 'undefined') {
|
|
||||||
window.attachInstallButtonHandler = attachInstallButtonHandler;
|
|
||||||
console.log('[GLOBAL] attachInstallButtonHandler exposed to window');
|
|
||||||
}
|
|
||||||
// searchPluginStore is now exposed inside the IIFE after its definition
|
|
||||||
|
|
||||||
// Verify critical functions are available
|
// Verify critical functions are available
|
||||||
if (_PLUGIN_DEBUG_EARLY) {
|
if (_PLUGIN_DEBUG_EARLY) {
|
||||||
|
|||||||
@@ -786,56 +786,25 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Alpine.js for reactive components -->
|
<!-- Alpine.js for reactive components.
|
||||||
<!-- Use local file when in AP mode (192.168.4.x) to avoid CDN dependency -->
|
Load the local copy first (always works, no CDN round-trip, no AP-mode
|
||||||
|
branch needed). `defer` on an HTML-parsed <script> is honored and runs
|
||||||
|
after DOM parse but before DOMContentLoaded, which is exactly what
|
||||||
|
Alpine wants — so no deferLoadingAlpine gymnastics are needed.
|
||||||
|
The inline rescue below only fires if the local file is missing. -->
|
||||||
|
<script defer src="{{ url_for('static', filename='v3/js/alpinejs.min.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
// Rescue: if the local Alpine didn't load for any reason, pull the CDN
|
||||||
// Prevent Alpine from auto-initializing by setting deferLoadingAlpine before it loads
|
// copy once on window load. This is a last-ditch fallback, not the
|
||||||
window.deferLoadingAlpine = function(callback) {
|
// primary path.
|
||||||
// Wait for DOM to be ready
|
window.addEventListener('load', function() {
|
||||||
function waitForReady() {
|
if (typeof window.Alpine === 'undefined') {
|
||||||
if (document.readyState === 'loading') {
|
console.warn('[Alpine] Local file failed to load, falling back to CDN');
|
||||||
document.addEventListener('DOMContentLoaded', waitForReady);
|
const s = document.createElement('script');
|
||||||
return;
|
s.src = 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js';
|
||||||
}
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
// app() is already defined in head, so we can initialize Alpine
|
});
|
||||||
if (callback && typeof callback === 'function') {
|
|
||||||
callback();
|
|
||||||
} else if (window.Alpine && typeof window.Alpine.start === 'function') {
|
|
||||||
// If callback not provided but Alpine is available, start it
|
|
||||||
try {
|
|
||||||
window.Alpine.start();
|
|
||||||
} catch (e) {
|
|
||||||
// Alpine may already be initialized, ignore
|
|
||||||
console.warn('Alpine start error (may already be initialized):', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
waitForReady();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Detect AP mode by IP address
|
|
||||||
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
||||||
window.location.hostname.startsWith('192.168.4.');
|
|
||||||
|
|
||||||
const alpineSrc = isAPMode ? '/static/v3/js/alpinejs.min.js' : 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js';
|
|
||||||
const alpineFallback = isAPMode ? 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js' : '/static/v3/js/alpinejs.min.js';
|
|
||||||
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.defer = true;
|
|
||||||
script.src = alpineSrc;
|
|
||||||
script.onerror = function() {
|
|
||||||
if (alpineSrc !== alpineFallback) {
|
|
||||||
const fallback = document.createElement('script');
|
|
||||||
fallback.defer = true;
|
|
||||||
fallback.src = alpineFallback;
|
|
||||||
document.head.appendChild(fallback);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.head.appendChild(script);
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- CodeMirror for JSON editing - lazy loaded when needed -->
|
<!-- CodeMirror for JSON editing - lazy loaded when needed -->
|
||||||
@@ -931,6 +900,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Update available banner -->
|
||||||
|
<div id="update-banner" style="display:none"
|
||||||
|
class="update-banner border-b transition-all duration-300 ease-in-out">
|
||||||
|
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<i class="fas fa-arrow-circle-up text-lg"></i>
|
||||||
|
<span class="text-sm font-medium" id="update-banner-text"
|
||||||
|
aria-live="polite" aria-atomic="true">
|
||||||
|
A new LEDMatrix update is available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<button onclick="applyUpdate()" id="update-banner-btn"
|
||||||
|
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md
|
||||||
|
update-banner-action transition-colors duration-150">
|
||||||
|
<i class="fas fa-download mr-1"></i> Update Now
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="dismissUpdateBanner()"
|
||||||
|
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
|
||||||
|
title="Dismiss" aria-label="Dismiss update">
|
||||||
|
<i class="fas fa-times text-sm"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
|
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
|
||||||
<!-- Navigation tabs -->
|
<!-- Navigation tabs -->
|
||||||
@@ -968,6 +965,11 @@
|
|||||||
class="nav-tab">
|
class="nav-tab">
|
||||||
<i class="fas fa-file-code"></i>Config Editor
|
<i class="fas fa-file-code"></i>Config Editor
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="activeTab = 'backup-restore'"
|
||||||
|
:class="activeTab === 'backup-restore' ? 'nav-tab-active' : ''"
|
||||||
|
class="nav-tab">
|
||||||
|
<i class="fas fa-save"></i>Backup & Restore
|
||||||
|
</button>
|
||||||
<button @click="activeTab = 'fonts'"
|
<button @click="activeTab = 'fonts'"
|
||||||
:class="activeTab === 'fonts' ? 'nav-tab-active' : ''"
|
:class="activeTab === 'fonts' ? 'nav-tab-active' : ''"
|
||||||
class="nav-tab">
|
class="nav-tab">
|
||||||
@@ -1170,6 +1172,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup & Restore tab -->
|
||||||
|
<div x-show="activeTab === 'backup-restore'" x-transition>
|
||||||
|
<div id="backup-restore-content" hx-get="/v3/partials/backup-restore" hx-trigger="revealed" hx-swap="innerHTML">
|
||||||
|
<div class="animate-pulse">
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
|
<div class="h-32 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Config Editor tab -->
|
<!-- Config Editor tab -->
|
||||||
<div x-show="activeTab === 'config-editor'" x-transition>
|
<div x-show="activeTab === 'config-editor'" x-transition>
|
||||||
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="revealed" hx-swap="innerHTML">
|
||||||
@@ -4888,6 +4902,77 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Update banner logic -->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes
|
||||||
|
|
||||||
|
function getDismissedSha() {
|
||||||
|
try { return sessionStorage.getItem('update-sha-dismissed'); } catch(e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkForUpdate() {
|
||||||
|
fetch('/api/v3/system/check-update')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.update_available && getDismissedSha() !== data.remote_sha) {
|
||||||
|
var n = data.commits_behind || 0;
|
||||||
|
var msg = 'A new LEDMatrix update is available';
|
||||||
|
if (n > 0) msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')';
|
||||||
|
document.getElementById('update-banner-text').textContent = msg;
|
||||||
|
document.getElementById('update-banner').style.display = '';
|
||||||
|
try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {}
|
||||||
|
} else {
|
||||||
|
document.getElementById('update-banner').style.display = 'none';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dismissUpdateBanner = function() {
|
||||||
|
document.getElementById('update-banner').style.display = 'none';
|
||||||
|
try {
|
||||||
|
var sha = sessionStorage.getItem('update-sha');
|
||||||
|
if (sha) sessionStorage.setItem('update-sha-dismissed', sha);
|
||||||
|
} catch(e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.applyUpdate = function() {
|
||||||
|
var btn = document.getElementById('update-banner-btn');
|
||||||
|
var originalHTML = '<i class="fas fa-download mr-1"></i> Update Now';
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Updating...';
|
||||||
|
btn.disabled = true;
|
||||||
|
fetch('/api/v3/system/action', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'git_pull' })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
btn.innerHTML = originalHTML;
|
||||||
|
btn.disabled = false;
|
||||||
|
if (data.status === 'success') {
|
||||||
|
document.getElementById('update-banner').style.display = 'none';
|
||||||
|
try { sessionStorage.removeItem('update-sha-dismissed'); } catch(e) {}
|
||||||
|
}
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification(data.message || 'Update complete', data.status || 'success');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
btn.innerHTML = originalHTML;
|
||||||
|
btn.disabled = false;
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification('Update failed — check your connection', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial check shortly after page load, then periodic
|
||||||
|
setTimeout(checkForUpdate, 2000);
|
||||||
|
setInterval(checkForUpdate, CHECK_INTERVAL);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,10 @@ function doConnect() {
|
|||||||
// Poll for the new IP
|
// Poll for the new IP
|
||||||
setTimeout(function() { checkNewIP(ssid); }, 3000);
|
setTimeout(function() { checkNewIP(ssid); }, 3000);
|
||||||
} else {
|
} else {
|
||||||
showMsg(data.message || 'Connection failed', 'err');
|
var msg = data.error_type === 'wrong_password'
|
||||||
|
? 'Incorrect password — please try again'
|
||||||
|
: (data.message || 'Connection failed');
|
||||||
|
showMsg(msg, 'err');
|
||||||
connecting = false;
|
connecting = false;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = 'Connect';
|
btn.innerHTML = 'Connect';
|
||||||
|
|||||||
358
web_interface/templates/v3/partials/backup_restore.html
Normal file
358
web_interface/templates/v3/partials/backup_restore.html
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
<div class="space-y-6" id="backup-restore-root">
|
||||||
|
|
||||||
|
<!-- Security warning -->
|
||||||
|
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-exclamation-triangle text-red-600"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-red-800">Backup files contain secrets in plaintext</h3>
|
||||||
|
<div class="mt-1 text-sm text-red-700">
|
||||||
|
Your API keys (weather, Spotify, YouTube, GitHub, etc.) and any saved WiFi passwords
|
||||||
|
are stored inside the backup ZIP as plain text. Treat the file like a password —
|
||||||
|
store it somewhere private and delete it when you no longer need it.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export card -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Export backup</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">
|
||||||
|
Download a single ZIP with all of your settings so you can restore it later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="exportBackup()" id="export-backup-btn"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
||||||
|
<i class="fas fa-download mr-2"></i>
|
||||||
|
Download backup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="export-preview" class="text-sm text-gray-600">
|
||||||
|
<div class="animate-pulse">Loading summary…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restore card -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Restore from backup</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">
|
||||||
|
Upload a backup ZIP exported from this or another LEDMatrix install.
|
||||||
|
You'll see a summary before anything is written to disk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Backup file</label>
|
||||||
|
<input type="file" id="restore-file-input" accept=".zip"
|
||||||
|
class="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button onclick="validateRestoreFile()" id="validate-restore-btn"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-check-circle mr-2"></i>
|
||||||
|
Inspect file
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="restore-preview" class="hidden bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 mb-2">Backup contents</h3>
|
||||||
|
<dl id="restore-preview-body" class="text-sm text-gray-700 space-y-1"></dl>
|
||||||
|
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 mt-4 mb-2">Choose what to restore</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-gray-700">
|
||||||
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-config" checked> <span>Main configuration</span></label>
|
||||||
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-secrets" checked> <span>API keys (secrets)</span></label>
|
||||||
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-wifi" checked> <span>WiFi configuration</span></label>
|
||||||
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-fonts" checked> <span>User-uploaded fonts</span></label>
|
||||||
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-plugin-uploads" checked> <span>Plugin image uploads</span></label>
|
||||||
|
<label class="flex items-center gap-2"><input type="checkbox" id="opt-reinstall" checked> <span>Reinstall missing plugins</span></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<button onclick="runRestore()" id="run-restore-btn"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
|
||||||
|
<i class="fas fa-upload mr-2"></i>
|
||||||
|
Restore now
|
||||||
|
</button>
|
||||||
|
<button onclick="clearRestore()"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="restore-result" class="hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History card -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Backup history</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Previously exported backups stored on this device.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="loadBackupList()"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-sync-alt mr-2"></i>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="backup-history" class="text-sm text-gray-600">Loading…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
let inspectedFile = null;
|
||||||
|
|
||||||
|
function notify(message, kind) {
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification(message, kind || 'info');
|
||||||
|
} else {
|
||||||
|
console.log('[backup]', kind || 'info', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let i = 0, size = bytes;
|
||||||
|
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||||
|
return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
|
||||||
|
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreview() {
|
||||||
|
const el = document.getElementById('export-preview');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v3/backup/preview');
|
||||||
|
const payload = await res.json();
|
||||||
|
if (payload.status !== 'success') throw new Error(payload.message || 'Preview failed');
|
||||||
|
const d = payload.data || {};
|
||||||
|
el.innerHTML = `
|
||||||
|
<ul class="list-disc pl-5 space-y-1">
|
||||||
|
<li>Main config: <strong>${d.has_config ? 'yes' : 'no'}</strong></li>
|
||||||
|
<li>Secrets: <strong>${d.has_secrets ? 'yes' : 'no'}</strong></li>
|
||||||
|
<li>WiFi config: <strong>${d.has_wifi ? 'yes' : 'no'}</strong></li>
|
||||||
|
<li>User fonts: <strong>${(d.user_fonts || []).length}</strong> ${d.user_fonts && d.user_fonts.length ? '(' + d.user_fonts.map(escapeHtml).join(', ') + ')' : ''}</li>
|
||||||
|
<li>Plugin image uploads: <strong>${d.plugin_uploads || 0}</strong> file(s)</li>
|
||||||
|
<li>Installed plugins: <strong>${(d.plugins || []).length}</strong></li>
|
||||||
|
</ul>`;
|
||||||
|
} catch (err) {
|
||||||
|
el.textContent = 'Could not load preview: ' + err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBackupList() {
|
||||||
|
const el = document.getElementById('backup-history');
|
||||||
|
el.textContent = 'Loading…';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v3/backup/list');
|
||||||
|
const payload = await res.json();
|
||||||
|
if (payload.status !== 'success') throw new Error(payload.message || 'List failed');
|
||||||
|
const entries = payload.data || [];
|
||||||
|
if (!entries.length) {
|
||||||
|
el.innerHTML = '<p>No backups have been created yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = `
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-left py-2">Filename</th>
|
||||||
|
<th class="text-left py-2">Size</th>
|
||||||
|
<th class="text-left py-2">Created</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
${entries.map(e => `
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 font-mono text-xs">${escapeHtml(e.filename)}</td>
|
||||||
|
<td class="py-2">${formatSize(e.size)}</td>
|
||||||
|
<td class="py-2">${escapeHtml(e.created_at)}</td>
|
||||||
|
<td class="py-2 text-right space-x-2">
|
||||||
|
<a href="/api/v3/backup/download/${encodeURIComponent(e.filename)}"
|
||||||
|
class="text-blue-600 hover:underline">Download</a>
|
||||||
|
<button data-filename="${escapeHtml(e.filename)}"
|
||||||
|
class="text-red-600 hover:underline backup-delete-btn">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
el.querySelectorAll('.backup-delete-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => deleteBackup(btn.dataset.filename));
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
el.textContent = 'Could not load backups: ' + err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportBackup() {
|
||||||
|
const btn = document.getElementById('export-backup-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Creating…';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v3/backup/export', { method: 'POST' });
|
||||||
|
const payload = await res.json();
|
||||||
|
if (payload.status !== 'success') throw new Error(payload.message || 'Export failed');
|
||||||
|
notify('Backup created: ' + payload.filename, 'success');
|
||||||
|
// Trigger browser download immediately.
|
||||||
|
window.location.href = '/api/v3/backup/download/' + encodeURIComponent(payload.filename);
|
||||||
|
await loadBackupList();
|
||||||
|
} catch (err) {
|
||||||
|
notify('Export failed: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-download mr-2"></i>Download backup';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBackup(filename) {
|
||||||
|
if (!confirm('Delete ' + filename + '?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v3/backup/' + encodeURIComponent(filename), { method: 'DELETE' });
|
||||||
|
const payload = await res.json();
|
||||||
|
if (payload.status !== 'success') throw new Error(payload.message || 'Delete failed');
|
||||||
|
notify('Backup deleted', 'success');
|
||||||
|
await loadBackupList();
|
||||||
|
} catch (err) {
|
||||||
|
notify('Delete failed: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateRestoreFile() {
|
||||||
|
const input = document.getElementById('restore-file-input');
|
||||||
|
if (!input.files || !input.files[0]) {
|
||||||
|
notify('Choose a backup file first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = input.files[0];
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('backup_file', file);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v3/backup/validate', { method: 'POST', body: fd });
|
||||||
|
const payload = await res.json();
|
||||||
|
if (payload.status !== 'success') throw new Error(payload.message || 'Validation failed');
|
||||||
|
inspectedFile = file;
|
||||||
|
renderRestorePreview(payload.data);
|
||||||
|
} catch (err) {
|
||||||
|
notify('Invalid backup: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRestorePreview(manifest) {
|
||||||
|
const wrap = document.getElementById('restore-preview');
|
||||||
|
const body = document.getElementById('restore-preview-body');
|
||||||
|
const detected = manifest.detected_contents || [];
|
||||||
|
const plugins = manifest.plugins || [];
|
||||||
|
body.innerHTML = `
|
||||||
|
<div><strong>Created:</strong> ${escapeHtml(manifest.created_at || 'unknown')}</div>
|
||||||
|
<div><strong>Source host:</strong> ${escapeHtml(manifest.hostname || 'unknown')}</div>
|
||||||
|
<div><strong>LEDMatrix version:</strong> ${escapeHtml(manifest.ledmatrix_version || 'unknown')}</div>
|
||||||
|
<div><strong>Includes:</strong> ${detected.length ? detected.map(escapeHtml).join(', ') : '(nothing detected)'}</div>
|
||||||
|
<div><strong>Plugins referenced:</strong> ${plugins.length ? plugins.map(p => escapeHtml(p.plugin_id)).join(', ') : 'none'}</div>
|
||||||
|
`;
|
||||||
|
wrap.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRestore() {
|
||||||
|
inspectedFile = null;
|
||||||
|
document.getElementById('restore-preview').classList.add('hidden');
|
||||||
|
document.getElementById('restore-result').classList.add('hidden');
|
||||||
|
document.getElementById('restore-file-input').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRestore() {
|
||||||
|
if (!inspectedFile) {
|
||||||
|
notify('Inspect the file before restoring', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm('Restore from this backup? Current configuration will be overwritten.')) return;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
restore_config: document.getElementById('opt-config').checked,
|
||||||
|
restore_secrets: document.getElementById('opt-secrets').checked,
|
||||||
|
restore_wifi: document.getElementById('opt-wifi').checked,
|
||||||
|
restore_fonts: document.getElementById('opt-fonts').checked,
|
||||||
|
restore_plugin_uploads: document.getElementById('opt-plugin-uploads').checked,
|
||||||
|
reinstall_plugins: document.getElementById('opt-reinstall').checked,
|
||||||
|
};
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('backup_file', inspectedFile);
|
||||||
|
fd.append('options', JSON.stringify(options));
|
||||||
|
|
||||||
|
const btn = document.getElementById('run-restore-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Restoring…';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v3/backup/restore', { method: 'POST', body: fd });
|
||||||
|
const payload = await res.json();
|
||||||
|
if (payload.status !== 'success') {
|
||||||
|
const msgs = (payload.data?.errors || []).join('; ');
|
||||||
|
throw new Error(payload.message || msgs || 'Restore had errors');
|
||||||
|
}
|
||||||
|
const data = payload.data || {};
|
||||||
|
const hasPartial = (data.plugins_failed || []).length > 0 || (data.errors || []).length > 0;
|
||||||
|
const result = document.getElementById('restore-result');
|
||||||
|
result.className = (hasPartial
|
||||||
|
? 'bg-yellow-50 border-yellow-200 text-yellow-800'
|
||||||
|
: 'bg-green-50 border-green-200 text-green-800') + ' border rounded-md p-4';
|
||||||
|
result.classList.remove('hidden');
|
||||||
|
result.innerHTML = `
|
||||||
|
<h3 class="font-medium mb-2">${hasPartial ? 'Restore complete with warnings' : 'Restore complete'}</h3>
|
||||||
|
<div><strong>Restored:</strong> ${(data.restored || []).map(escapeHtml).join(', ') || 'none'}</div>
|
||||||
|
<div><strong>Skipped:</strong> ${(data.skipped || []).map(escapeHtml).join(', ') || 'none'}</div>
|
||||||
|
<div><strong>Plugins installed:</strong> ${(data.plugins_installed || []).map(escapeHtml).join(', ') || 'none'}</div>
|
||||||
|
<div><strong>Plugins failed:</strong> ${(data.plugins_failed || []).map(p => escapeHtml(p.plugin_id + ' (' + p.error + ')')).join(', ') || 'none'}</div>
|
||||||
|
<div><strong>Errors:</strong> ${(data.errors || []).map(escapeHtml).join('; ') || 'none'}</div>
|
||||||
|
${((data.restored || []).length || (data.plugins_installed || []).length) ? '<p class="mt-2">Restart the display service to apply all changes.</p>' : ''}
|
||||||
|
`;
|
||||||
|
notify(hasPartial ? 'Restore complete with warnings' : 'Restore complete', hasPartial ? 'warning' : 'success');
|
||||||
|
} catch (err) {
|
||||||
|
notify('Restore failed: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-upload mr-2"></i>Restore now';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose handlers to inline onclick attributes.
|
||||||
|
window.exportBackup = exportBackup;
|
||||||
|
window.loadBackupList = loadBackupList;
|
||||||
|
window.validateRestoreFile = validateRestoreFile;
|
||||||
|
window.clearRestore = clearRestore;
|
||||||
|
window.runRestore = runRestore;
|
||||||
|
|
||||||
|
// Clear inspection state whenever the user picks a new file.
|
||||||
|
document.getElementById('restore-file-input').addEventListener('change', function () {
|
||||||
|
inspectedFile = null;
|
||||||
|
document.getElementById('restore-preview').classList.add('hidden');
|
||||||
|
document.getElementById('restore-result').classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load.
|
||||||
|
loadPreview();
|
||||||
|
loadBackupList();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user